diff --git a/.changeset/provider-usage-center.md b/.changeset/provider-usage-center.md new file mode 100644 index 00000000000..5560fd84f86 --- /dev/null +++ b/.changeset/provider-usage-center.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": minor +"kilo-code": minor +--- + +View current provider plan usage, quota windows, and personal top-up status in the CLI and VS Code profile. diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index 21afcdcab96..ab68d19a43b 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -11,10 +11,15 @@ - +- + +- + - - + - @@ -151,6 +156,10 @@ - +- + +- + - - diff --git a/packages/kilo-gateway/src/api/trpc.ts b/packages/kilo-gateway/src/api/trpc.ts new file mode 100644 index 00000000000..fa46328c6dd --- /dev/null +++ b/packages/kilo-gateway/src/api/trpc.ts @@ -0,0 +1,236 @@ +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 status = z.enum([ + "active", + "canceled", + "incomplete", + "incomplete_expired", + "past_due", + "paused", + "trialing", + "unpaid", +]) + +const KiloPassStateSchema = z.object({ + subscription: z + .object({ + subscriptionId: z.string(), + tier: z.enum(["tier_19", "tier_49", "tier_199"]), + cadence: z.enum(["monthly", "yearly"]), + status, + cancelAtPeriodEnd: z.boolean(), + currentStreakMonths: z.number(), + nextYearlyIssueAt: z.string().nullable(), + startedAt: z.string().nullable(), + resumesAt: z.string().nullable(), + nextBonusCreditsUsd: z.number().nullable(), + nextBillingAt: z.string().nullable(), + currentPeriodBaseCreditsUsd: z.number(), + currentPeriodUsageUsd: z.number(), + currentPeriodHostingCostUsd: z.number(), + currentPeriodBonusCreditsUsd: z.number().nullable(), + isBonusUnlocked: z.boolean(), + refillAt: z.string().nullable(), + }) + .nullable(), +}) + +const AutoTopUpStateSchema = z.object({ + enabled: z.boolean(), + amountCents: z.number().int().nonnegative(), + thresholdCents: z.number().int().nonnegative(), + paymentMethod: z + .object({ + type: z.string(), + brand: z.string().nullable(), + last4: z.string().nullable(), + }) + .nullable(), +}) + +const CodingPlanSubscriptionSchema = z.object({ + id: z.string(), + planId: z.string(), + planName: z.string(), + providerName: z.string(), + providerId: z.string(), + routeLabel: z.string(), + hasInstalledByokKey: z.boolean(), + status: z.enum(["active", "past_due", "canceled"]), + billingPeriodDays: z.number().int().positive(), + currentPeriodStart: z.string(), + currentPeriodEnd: z.string(), + creditRenewalAt: z.string(), + cancelAtPeriodEnd: z.boolean(), + paymentGraceExpiresAt: z.string().nullable(), + canceledAt: z.string().nullable(), + cancellationReason: z.string().nullable(), + createdAt: z.string(), + costKiloCredits: z.number().nonnegative(), +}) + +const ByokEntrySchema = z.object({ + id: z.string(), + provider_id: z.string(), + management_source: z.enum(["user", "coding_plan"]), + is_enabled: z.boolean(), +}) + +const NativeNumberSchema = z.number().finite() +const NativeIntegerSchema = z.number().int().safe() +const MiniMaxModelRemainsSchema = z.object({ + model_name: z.string(), + current_interval_total_count: NativeIntegerSchema.nonnegative().optional(), + current_interval_usage_count: NativeIntegerSchema.nonnegative().optional(), + start_time: NativeIntegerSchema.nonnegative().optional(), + end_time: NativeIntegerSchema.nonnegative().optional(), + remains_time: NativeIntegerSchema.nonnegative().optional(), + interval_boost_permill: NativeIntegerSchema.nonnegative().optional(), + interval_boost_permille: NativeIntegerSchema.nonnegative().optional(), + current_interval_remaining_percent: NativeNumberSchema.optional(), + current_interval_status: NativeIntegerSchema.optional(), + current_weekly_total_count: NativeIntegerSchema.nonnegative().optional(), + current_weekly_usage_count: NativeIntegerSchema.nonnegative().optional(), + weekly_start_time: NativeIntegerSchema.nonnegative().optional(), + weekly_end_time: NativeIntegerSchema.nonnegative().optional(), + weekly_remains_time: NativeIntegerSchema.nonnegative().optional(), + weekly_boost_permill: NativeIntegerSchema.nonnegative().optional(), + weekly_boost_permille: NativeIntegerSchema.nonnegative().optional(), + current_weekly_remaining_percent: NativeNumberSchema.optional(), + current_weekly_status: NativeIntegerSchema.optional(), +}) + +export const MiniMaxNativeUsageSchema = z.object({ + base_resp: z.object({ status_code: NativeIntegerSchema }), + model_remains: z.array(MiniMaxModelRemainsSchema), +}) + +const CodingPlanUsageSchema = z.object({ + subscriptionId: z.string(), + providerId: z.literal("minimax"), + region: z.literal("global"), + fetchedAt: z.string(), + native: MiniMaxNativeUsageSchema, +}) + +const envelope = z.object({ + result: z.object({ data: z.unknown() }).optional(), + error: z.unknown().optional(), +}) + +export type KiloPassState = z.infer +export type AutoTopUpState = z.infer +export type CodingPlanSubscription = z.infer +export type ByokEntry = z.infer +export type CodingPlanUsage = z.infer +export type MiniMaxNativeUsage = 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 !== undefined) 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 getKiloPassState(token: string) { + return query("kiloPass.getState", token, KiloPassStateSchema) +} + +export function getAutoTopUpState(token: string) { + return query("user.getAutoTopUpPaymentMethod", token, AutoTopUpStateSchema) +} + +export function listCodingPlanSubscriptions(token: string) { + return query("codingPlans.listSubscriptions", token, z.array(CodingPlanSubscriptionSchema)) +} + +export function listByokEntries(token: string) { + return query("byok.list", token, z.array(ByokEntrySchema), {}) +} + +export function getCodingPlanUsage(token: string, subscriptionId: string) { + return query("codingPlans.getUsage", token, CodingPlanUsageSchema, { subscriptionId }) +} diff --git a/packages/kilo-gateway/src/index.ts b/packages/kilo-gateway/src/index.ts index bc8d21df72e..a3b8cda64a4 100644 --- a/packages/kilo-gateway/src/index.ts +++ b/packages/kilo-gateway/src/index.ts @@ -59,6 +59,21 @@ export { type OrganizationModeConfig, } from "./api/modes.js" export { fetchKilocodeNotifications, type KilocodeNotification } from "./api/notifications.js" +export { + CloudTrpcError, + MiniMaxNativeUsageSchema, + getAutoTopUpState, + getCodingPlanUsage, + getKiloPassState, + listByokEntries, + listCodingPlanSubscriptions, + type AutoTopUpState, + type ByokEntry, + type CodingPlanSubscription, + type CodingPlanUsage, + type KiloPassState, + type MiniMaxNativeUsage, +} from "./api/trpc.js" export { fetchCloudSession, fetchCloudSessionForImport, importSessionToDb } from "./cloud-sessions.js" // ============================================================================ 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..322edc19212 --- /dev/null +++ b/packages/kilo-gateway/test/api/trpc.test.ts @@ -0,0 +1,181 @@ +import { afterEach, describe, expect, mock, test } from "bun:test" +import { + CloudTrpcError, + getAutoTopUpState, + getCodingPlanUsage, + getKiloPassState, + listByokEntries, + listCodingPlanSubscriptions, +} 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" }, + }) + +afterEach(() => { + global.fetch = original +}) + +describe("Cloud tRPC client", () => { + test("uses unbatched GET queries without an organization header", async () => { + const fn = mock(() => + Promise.resolve( + result([ + { + id: "subscription", + planId: "minimax-token-plan-plus", + planName: "Token Plan Plus", + providerName: "MiniMax", + providerId: "minimax", + routeLabel: "MiniMax via Kilo Gateway", + hasInstalledByokKey: true, + status: "active", + billingPeriodDays: 30, + currentPeriodStart: "2026-06-01T00:00:00.000Z", + currentPeriodEnd: "2026-07-01T00:00:00.000Z", + creditRenewalAt: "2026-07-01T00:00:00.000Z", + cancelAtPeriodEnd: false, + 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 listCodingPlanSubscriptions("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 and strips sensitive auto-top-up fields", async () => { + const fn = mock(() => + Promise.resolve( + result({ + enabled: true, + amountCents: 5000, + thresholdCents: 500, + paymentMethod: { + type: "card", + brand: "visa", + last4: "4242", + stripePaymentMethodId: "pm_secret", + linkEmail: "private@example.com", + }, + }), + ), + ) + global.fetch = fn as unknown as typeof fetch + + const state = await getAutoTopUpState("token") + expect(state).toEqual({ + enabled: true, + amountCents: 5000, + thresholdCents: 500, + paymentMethod: { type: "card", brand: "visa", last4: "4242" }, + }) + + global.fetch = mock(() => Promise.resolve(result([]))) as unknown as typeof fetch + await listByokEntries("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 = { + "kiloPass.getState": { + subscription: { + subscriptionId: "pass", + tier: "tier_49", + cadence: "monthly", + status: "active", + cancelAtPeriodEnd: false, + currentStreakMonths: 2, + nextYearlyIssueAt: null, + startedAt: "2026-06-01T00:00:00.000Z", + resumesAt: null, + nextBonusCreditsUsd: 5, + nextBillingAt: "2026-07-01T00:00:00.000Z", + currentPeriodBaseCreditsUsd: 49, + currentPeriodUsageUsd: 12, + currentPeriodHostingCostUsd: 2, + currentPeriodBonusCreditsUsd: 5, + isBonusUnlocked: true, + refillAt: "2026-07-01T00:00:00.000Z", + }, + }, + "codingPlans.getUsage": { + subscriptionId: "plan", + providerId: "minimax", + region: "global", + fetchedAt: "2026-06-19T00:00:00.000Z", + native: { + base_resp: { status_code: 0, status_msg: "stripped" }, + model_remains: [ + { + model_name: "general", + current_interval_remaining_percent: 80, + current_interval_status: 1, + end_time: 1_781_280_000_000, + }, + ], + }, + }, + } + 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 pass = await getKiloPassState("token") + expect(pass.subscription?.currentPeriodUsageUsd).toBe(12) + const usage = await getCodingPlanUsage("token", "plan") + expect(usage.native.base_resp).toEqual({ status_code: 0 }) + const call = (global.fetch as unknown as { mock: { calls: Array<[string]> } }).mock.calls[1] + 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 getKiloPassState("secret-token").catch((value) => value) + expect(error).toBeInstanceOf(CloudTrpcError) + expect(error).toMatchObject({ kind: "procedure", message: "Kilo Cloud data is temporarily unavailable." }) + expect(JSON.stringify(error)).not.toContain("raw private error") + expect(JSON.stringify(error)).not.toContain("secret-token") + }) + + 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(getKiloPassState("token")).rejects.toMatchObject({ kind: "protocol" }) + + global.fetch = mock(() => + Promise.resolve(result({ subscription: { status: "unknown" } })), + ) as unknown as typeof fetch + await expect(getKiloPassState("token")).rejects.toMatchObject({ kind: "schema" }) + }) +}) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 48328ea1ba8..2936a6dcfb7 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -279,6 +279,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private configWarningsShown = false /** Cached notificationsLoaded payload */ private cachedNotificationsMessage: unknown = null + /** Cached provider usage payload for profile view remounts and temporary disconnects. */ + private cachedProviderUsageMessage: unknown = null + private providerUsageRequested = false + private providerUsageGeneration = 0 private pendingKiloModelID: string | null = null private pendingReviewComments: { comments: unknown[]; autoSend: boolean }[] = [] private readyResolvers: (() => void)[] = [] @@ -394,7 +398,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper public setProjectDirectory(directory: string | null): void { if (this.projectDirectory === directory) return this.projectDirectory = directory + this.providerUsageGeneration++ + this.cachedProviderUsageMessage = null this.postMessage({ type: "workspaceDirectoryChanged", directory: directory ?? "" }) + if (this.providerUsageRequested) void this.fetchAndSendProviderUsage() } public setDiffVirtualProvider(provider: import("./DiffVirtualProvider").DiffVirtualProvider): void { @@ -891,6 +898,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper case "refreshProfile": await handleRefreshProfile(this.authCtx) break + case "requestProviderUsage": + await this.fetchAndSendProviderUsage() + break + case "refreshProviderUsage": + await this.fetchAndSendProviderUsage(true) + break case "openSettingsPanel": vscode.commands.executeCommand("kilo-code.new.settingsButtonClicked", message.tab) break @@ -1786,6 +1799,34 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + private async fetchAndSendProviderUsage(force = false): Promise { + this.providerUsageRequested = true + const generation = ++this.providerUsageGeneration + const client = this.client + if (!client) { + if (this.cachedProviderUsageMessage) this.postMessage(this.cachedProviderUsageMessage) + return + } + + const directory = this.getProjectDirectory(this.currentSession?.id) + const result = await ( + force ? client.kilocode.providerUsage.refresh({ directory }) : client.kilocode.providerUsage.get({ directory }) + ).catch(() => undefined) + if (generation !== this.providerUsageGeneration) return + if (!result?.data) { + if (this.cachedProviderUsageMessage) { + this.postMessage(this.cachedProviderUsageMessage) + return + } + this.postMessage({ type: "providerUsageLoaded", error: "Provider usage could not be loaded." }) + return + } + + const message = { type: "providerUsageLoaded", data: result.data } + this.cachedProviderUsageMessage = message + this.postMessage(message) + } + /** Fetch providers and send to webview. Coalesced: at most one in-flight + one queued. */ private async fetchAndSendProviders(): Promise { const next = ++this.providersGeneration @@ -2997,6 +3038,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** Re-fetch all server-side state after an auth change. */ private async reloadAfterAuthChange(): Promise { + this.providerUsageGeneration++ + this.cachedProviderUsageMessage = null + if (this.providerUsageRequested) this.postMessage({ type: "providerUsageLoaded", reset: true }) await this.fetchAndSendConfig() await Promise.all([ this.fetchAndSendProviders(), @@ -3005,6 +3049,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.fetchAndSendCommands(), this.fetchAndSendIndexingStatus(), this.fetchAndSendNotifications(), + this.providerUsageRequested ? this.fetchAndSendProviderUsage() : Promise.resolve(), ]) } diff --git a/packages/kilo-vscode/tests/accessibility.spec.ts b/packages/kilo-vscode/tests/accessibility.spec.ts index 4600a9f0cd6..5afde75c134 100644 --- a/packages/kilo-vscode/tests/accessibility.spec.ts +++ b/packages/kilo-vscode/tests/accessibility.spec.ts @@ -10,6 +10,10 @@ const STORIES = [ { id: "profile--not-logged-in", name: "Profile / not logged in" }, { id: "profile--logged-in-personal", name: "Profile / personal account" }, { id: "profile--logged-in", name: "Profile / organization account" }, + { id: "profile--organization-context", name: "Profile / selected organization" }, + { id: "profile--stale-and-unavailable", name: "Profile / stale usage" }, + { id: "profile--balance-and-credits", name: "Profile / balance and credits" }, + { id: "profile--empty-usage", name: "Profile / empty usage" }, { id: "settings--providers-configure", name: "Settings / providers empty state" }, { id: "marketplace--skills-tab-empty", name: "Marketplace / skills empty state" }, { id: "marketplace--agents-tab-empty", name: "Marketplace / agents empty state" }, diff --git a/packages/kilo-vscode/tests/unit/provider-usage.test.ts b/packages/kilo-vscode/tests/unit/provider-usage.test.ts new file mode 100644 index 00000000000..77ecaba8930 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/provider-usage.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "bun:test" +import type { ProviderUsage, ProviderUsageWindow } from "@kilocode/sdk/v2/client" +import { formatWindowValue, windowProgress } from "../../webview-ui/src/components/profile/provider-usage-format" + +const { KiloProvider } = await import("../../src/KiloProvider") + +const data: ProviderUsage = { + generatedAt: "2026-06-19T00:00:00.000Z", + items: [], +} + +type Internals = { + providerUsageRequested: boolean + cachedProviderUsageMessage: unknown + fetchAndSendProviderUsage: (force?: boolean) => Promise + reloadAfterAuthChange: () => Promise + postMessage: (message: unknown) => void + fetchAndSendConfig: () => Promise + fetchAndSendProviders: () => Promise + fetchAndSendAgents: () => Promise + fetchAndSendSkills: () => Promise + fetchAndSendCommands: () => Promise + fetchAndSendIndexingStatus: () => Promise + fetchAndSendNotifications: () => Promise +} + +describe("provider usage presentation", () => { + const window = (value: Partial): ProviderUsageWindow => ({ + id: "quota", + label: "Quota", + resource: "general", + kind: "quota", + unit: "percent", + orientation: "remaining_percent", + state: "active", + ...value, + }) + + it("formats used and remaining orientations without provider branching", () => { + expect(formatWindowValue(window({ remaining: 75, limit: 100 }))).toBe("75% remaining") + expect(formatWindowValue(window({ orientation: "used_percent", used: 25, limit: 100 }))).toBe("25% used") + expect(windowProgress(window({ remaining: 75, limit: 100 }))).toBe(25) + }) + + it("keeps known zero distinct from unknown and preserves contract states", () => { + expect(formatWindowValue(window({ remaining: 0, limit: 100, state: "exhausted" }))).toBe("0% remaining") + expect(formatWindowValue(window({ state: "unknown" }))).toBe("Unknown") + expect(formatWindowValue(window({ state: "unlimited" }))).toBe("Unlimited") + expect(formatWindowValue(window({ state: "not_in_plan" }))).toBe("Not in plan") + }) +}) + +describe("KiloProvider provider usage bridge", () => { + it("uses cache-aware GET and explicit refresh POST", async () => { + const get: Array<{ directory?: string }> = [] + const refresh: Array<{ directory?: string }> = [] + const messages: unknown[] = [] + const provider = new KiloProvider( + {} as never, + { + getClient: () => ({ + kilocode: { + providerUsage: { + get: async (input: { directory?: string }) => { + get.push(input) + return { data } + }, + refresh: async (input: { directory?: string }) => { + refresh.push(input) + return { data } + }, + }, + }, + }), + } as never, + undefined, + { projectDirectory: "/repo" }, + ) + const internal = provider as unknown as Internals + internal.postMessage = (message) => messages.push(message) + + await internal.fetchAndSendProviderUsage() + await internal.fetchAndSendProviderUsage(true) + + expect(get).toEqual([{ directory: "/repo" }]) + expect(refresh).toEqual([{ directory: "/repo" }]) + expect(messages).toEqual([ + { type: "providerUsageLoaded", data }, + { type: "providerUsageLoaded", data }, + ]) + expect(internal.providerUsageRequested).toBe(true) + expect(internal.cachedProviderUsageMessage).toEqual({ type: "providerUsageLoaded", data }) + }) + + it("posts a terminal loading error when the backend has no cached response", async () => { + const messages: unknown[] = [] + const provider = new KiloProvider( + {} as never, + { + getClient: () => ({ + kilocode: { + providerUsage: { + get: async () => ({ error: { _tag: "ServiceUnavailable" } }), + }, + }, + }), + } as never, + undefined, + { projectDirectory: "/repo" }, + ) + const internal = provider as unknown as Internals + internal.postMessage = (message) => messages.push(message) + + await internal.fetchAndSendProviderUsage() + + expect(messages).toEqual([{ type: "providerUsageLoaded", error: "Provider usage could not be loaded." }]) + }) + + it("refreshes usage after auth invalidation only after the profile requested it", async () => { + const provider = new KiloProvider({} as never, {} as never) + const internal = provider as unknown as Internals + let usage = 0 + internal.fetchAndSendConfig = async () => {} + internal.fetchAndSendProviders = async () => {} + internal.fetchAndSendAgents = async () => {} + internal.fetchAndSendSkills = async () => {} + internal.fetchAndSendCommands = async () => {} + internal.fetchAndSendIndexingStatus = async () => {} + internal.fetchAndSendNotifications = async () => {} + internal.fetchAndSendProviderUsage = async () => { + usage++ + } + + await internal.reloadAfterAuthChange() + expect(usage).toBe(0) + internal.providerUsageRequested = true + await internal.reloadAfterAuthChange() + expect(usage).toBe(1) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 26f775a8bf5..780998cf8e8 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -330,8 +330,13 @@ const AppContent: Component = () => { diff --git a/packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx b/packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx index df4658ca43b..2de7eb0cfa5 100644 --- a/packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx @@ -7,14 +7,20 @@ import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { useVSCode } from "../../context/vscode" import { useLanguage } from "../../context/language" import DeviceAuthCard from "./DeviceAuthCard" -import type { ProfileData, DeviceAuthState } from "../../types/messages" +import type { ProfileData, ProviderUsageData, DeviceAuthState } from "../../types/messages" +import { ProviderUsageCards } from "./ProviderUsageCards" export type { ProfileData } export interface ProfileViewProps { profileData: ProfileData | null | undefined deviceAuth: DeviceAuthState + providerUsage?: ProviderUsageData + providerUsageLoading?: boolean + providerUsageError?: string onLogin: () => void + onRequestProviderUsage?: () => void + onRefreshProviderUsage?: () => void } const formatBalance = (amount: number): string => { @@ -37,6 +43,7 @@ const ProfileView: Component = (props) => { // Always fetch fresh profile+balance when navigating to this view onMount(() => { vscode.postMessage({ type: "refreshProfile" }) + props.onRequestProviderUsage?.() }) // Reset pending target whenever profileData changes (success or failure both send a fresh profile) @@ -93,6 +100,10 @@ const ProfileView: Component = (props) => { vscode.postMessage({ type: "openExternal", url: "https://app.kilo.ai/profile" }) } + const openExternal = (url: string) => { + vscode.postMessage({ type: "openExternal", url }) + } + const handleCancelLogin = () => { vscode.postMessage({ type: "cancelLogin" }) } @@ -262,6 +273,14 @@ const ProfileView: Component = (props) => { )} + + props.onRefreshProviderUsage?.()} + onOpen={openExternal} + /> ) diff --git a/packages/kilo-vscode/webview-ui/src/components/profile/ProviderUsageCards.tsx b/packages/kilo-vscode/webview-ui/src/components/profile/ProviderUsageCards.tsx new file mode 100644 index 00000000000..5fafdd7dfdb --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/profile/ProviderUsageCards.tsx @@ -0,0 +1,279 @@ +import { Component, For, Show } from "solid-js" +import type { ProviderUsageData } from "../../types/messages" +import type { ProviderUsageSnapshot } from "@kilocode/sdk/v2/client" +import { Button } from "@kilocode/kilo-ui/button" +import { Card, CardActions, CardDescription, CardTitle } from "@kilocode/kilo-ui/card" +import { Progress } from "@kilocode/kilo-ui/progress" +import { Spinner } from "@kilocode/kilo-ui/spinner" +import { Tag } from "@kilocode/kilo-ui/tag" +import { useLanguage } from "../../context/language" +import { formatWindowValue, windowProgress } from "./provider-usage-format" + +export interface ProviderUsageCardsProps { + data: ProviderUsageData | undefined + loading: boolean + error?: string + onRefresh: () => void + onOpen: (url: string) => void +} + +type Language = ReturnType + +const source = (item: ProviderUsageSnapshot, language: Language) => { + if (item.sourceKind === "kilo_pass" || item.sourceKind === "kilo_managed") + return language.t("profile.usage.source.viaKilo") + if (item.sourceKind === "codex") return language.t("profile.usage.source.chatgpt") + return language.t("profile.usage.source.direct") +} + +const labels = (language: Language) => ({ + unlimited: language.t("profile.usage.status.unlimited"), + notInPlan: language.t("profile.usage.status.notInPlan"), + unknown: language.t("profile.usage.status.unknown"), + exhausted: language.t("profile.usage.status.exhausted"), + used: (value: string) => language.t("profile.usage.window.used", { value }), + remaining: (value: string) => language.t("profile.usage.window.remaining", { value }), + remainingOf: (value: string, limit: string) => language.t("profile.usage.window.remainingOf", { value, limit }), + usedOf: (value: string, limit: string) => language.t("profile.usage.window.usedOf", { value, limit }), +}) + +const variant = (item: ProviderUsageSnapshot) => { + if (item.fetchState === "error") return "error" as const + if (item.fetchState !== "ready" || item.planState === "past_due") return "warning" as const + return "normal" as const +} + +const UsageCard: Component<{ + item: ProviderUsageSnapshot + onOpen: (url: string) => void + language: Language +}> = (props) => ( + +
+
+ {props.item.providerLabel} + {props.item.planLabel} +
+ {source(props.item, props.language)} +
+ + +

+ {props.language.t( + props.item.planState === "past_due" + ? "profile.usage.plan.pastDue" + : props.item.planState === "canceling" + ? "profile.usage.plan.canceling" + : "profile.usage.plan.unknown", + )} +

+
+ + +

+ {props.item.fetchState === "stale" + ? props.language.t("profile.usage.state.stale") + : props.language.t("profile.usage.state.unavailable")} +

+
+ +
+ + {(window) => { + const progress = () => windowProgress(window) + return ( +
+
+ {window.label} + {formatWindowValue(window, labels(props.language))} +
+ + + + + {(reset) => ( + + {props.language.t("profile.usage.reset", { date: new Date(reset()).toLocaleString() })} + + )} + +
+ ) + }} +
+ + + {(balance) => ( +
+
+ {balance.label} + + {balance.total} {balance.currency} + {balance.available === false ? ` ${props.language.t("profile.usage.balance.unavailable")}` : ""} + +
+ + + {props.language.t("profile.usage.balance.breakdown", { + granted: balance.granted ?? props.language.t("profile.usage.status.unknown"), + toppedUp: balance.toppedUp ?? props.language.t("profile.usage.status.unknown"), + })} + + +
+ )} +
+ + + {(credit) => ( +
+ {credit.label} + + {credit.unlimited + ? props.language.t("profile.usage.status.unlimited") + : credit.balance !== undefined + ? `${credit.balance}${credit.unit ? ` ${credit.unit}` : ""}` + : credit.availableResets !== undefined + ? props.language.t("profile.usage.credits.resets", { count: String(credit.availableResets) }) + : props.language.t("profile.usage.status.unknown")} + +
+ )} +
+
+ + +

+ {props.language.t("profile.usage.routing", { + state: props.language.t(`profile.usage.routingState.${props.item.routingState}`), + })} +

+
+ + + {(url) => ( + + + + )} + +
+) + +const BillingCard: Component<{ + billing: NonNullable + onOpen: (url: string) => void + language: Language +}> = (props) => ( + + {props.language.t("profile.usage.topups.title")} + + {(auto) => ( +
+
+ {props.language.t("profile.usage.topups.auto")} + {props.language.t(auto().enabled ? "profile.usage.topups.on" : "profile.usage.topups.off")} +
+

+ {props.language.t("profile.usage.topups.rule", { + amount: (auto().amountCents / 100).toFixed(2), + threshold: (auto().thresholdCents / 100).toFixed(2), + })} + {auto().paymentLast4 + ? ` | ${props.language.t("profile.usage.topups.payment", { + brand: auto().paymentBrand ?? auto().paymentType ?? "Payment method", + last4: auto().paymentLast4 ?? "", + })}` + : ""} +

+
+ )} +
+ +

{props.language.t("profile.usage.state.unavailable")}

+
+ + + + +
+) + +export const ProviderUsageCards: Component = (props) => { + const language = useLanguage() + return ( +
+
+
+

{language.t("profile.usage.title")}

+

{language.t("profile.usage.description")}

+
+ +
+ + + + + } + > + {(error) => ( + + {error()} + + )} + + } + > + {(data) => ( +
+ 0} + fallback={ + + {language.t("profile.usage.empty")} + + } + > + + {(item) => } + + + + {(billing) => } + +
+ )} + +
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/src/components/profile/provider-usage-format.ts b/packages/kilo-vscode/webview-ui/src/components/profile/provider-usage-format.ts new file mode 100644 index 00000000000..306bb39fcfe --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/profile/provider-usage-format.ts @@ -0,0 +1,53 @@ +import type { ProviderUsageWindow } from "@kilocode/sdk/v2/client" + +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}` +} + +interface Labels { + 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 +} + +const english: Labels = { + 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`, +} + +export const formatWindowValue = (window: ProviderUsageWindow, labels: Labels = 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: ProviderUsageWindow) => { + 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-vscode/webview-ui/src/context/server.tsx b/packages/kilo-vscode/webview-ui/src/context/server.tsx index 5dd468fcb42..8034ad39643 100644 --- a/packages/kilo-vscode/webview-ui/src/context/server.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/server.tsx @@ -5,7 +5,14 @@ import { createContext, useContext, createSignal, onMount, onCleanup, ParentComponent, Accessor } from "solid-js" import { useVSCode } from "./vscode" -import type { ConnectionState, ServerInfo, ProfileData, DeviceAuthState, ExtensionMessage } from "../types/messages" +import type { + ConnectionState, + ServerInfo, + ProfileData, + ProviderUsageData, + DeviceAuthState, + ExtensionMessage, +} from "../types/messages" import { applyFontSize } from "../font-size" interface ServerContextValue { @@ -16,6 +23,11 @@ interface ServerContextValue { errorDetails: Accessor isConnected: Accessor profileData: Accessor + providerUsage: Accessor + providerUsageLoading: Accessor + providerUsageError: Accessor + requestProviderUsage: () => void + refreshProviderUsage: () => void deviceAuth: Accessor startLogin: () => void goToLogin: () => void @@ -38,6 +50,10 @@ export const ServerProvider: ParentComponent = (props) => { const [errorMessage, setErrorMessage] = createSignal() const [errorDetails, setErrorDetails] = createSignal() const [profileData, setProfileData] = createSignal(null) + const [providerUsage, setProviderUsage] = createSignal() + const [providerUsageLoading, setProviderUsageLoading] = createSignal(false) + const [providerUsageError, setProviderUsageError] = createSignal() + let providerUsageRetry: ReturnType | undefined const [deviceAuth, setDeviceAuth] = createSignal(initialDeviceAuth) const [vscodeLanguage, setVscodeLanguage] = createSignal() const [languageOverride, setLanguageOverride] = createSignal() @@ -53,6 +69,35 @@ export const ServerProvider: ParentComponent = (props) => { if (m.type === "fontSizeChanged") applyFontSize(m.fontSize) }) + const usageSub = vscode.onMessage((m: ExtensionMessage) => { + if (m.type !== "providerUsageLoaded") return + if (providerUsageRetry) clearTimeout(providerUsageRetry) + providerUsageRetry = undefined + if (m.reset) { + setProviderUsage(undefined) + setProviderUsageError(undefined) + setProviderUsageLoading(true) + return + } + if (m.data) setProviderUsage(m.data) + setProviderUsageError(m.error) + setProviderUsageLoading(false) + }) + + const usageReadySub = vscode.onMessage((m: ExtensionMessage) => { + if (m.type !== "extensionDataReady" || !providerUsageLoading() || providerUsage()) return + if (providerUsageRetry) clearTimeout(providerUsageRetry) + providerUsageRetry = undefined + vscode.postMessage({ type: "requestProviderUsage" }) + }) + + const resetProviderUsageForDirectory = () => { + if (providerUsage() === undefined && !providerUsageLoading()) return + setProviderUsage(undefined) + setProviderUsageError(undefined) + setProviderUsageLoading(true) + } + onMount(() => { const unsubscribe = vscode.onMessage((message: ExtensionMessage) => { switch (message.type) { @@ -76,6 +121,7 @@ export const ServerProvider: ParentComponent = (props) => { case "workspaceDirectoryChanged": setWorkspaceDirectory(message.directory) + resetProviderUsageForDirectory() break case "languageChanged": @@ -137,6 +183,9 @@ export const ServerProvider: ParentComponent = (props) => { onCleanup(() => { gitSub() fontSub() + usageSub() + usageReadySub() + if (providerUsageRetry) clearTimeout(providerUsageRetry) unsubscribe() }) @@ -168,6 +217,27 @@ export const ServerProvider: ParentComponent = (props) => { startLogin() } + const retryProviderUsage = () => { + if (providerUsageRetry) clearTimeout(providerUsageRetry) + providerUsageRetry = setTimeout(() => { + providerUsageRetry = undefined + if (providerUsageLoading() && !providerUsage()) vscode.postMessage({ type: "requestProviderUsage" }) + }, 3000) + } + + const requestProviderUsage = () => { + setProviderUsageLoading(true) + setProviderUsageError(undefined) + vscode.postMessage({ type: "requestProviderUsage" }) + retryProviderUsage() + } + + const refreshProviderUsage = () => { + setProviderUsageLoading(true) + setProviderUsageError(undefined) + vscode.postMessage({ type: "refreshProviderUsage" }) + } + const value: ServerContextValue = { connectionState, serverInfo, @@ -176,6 +246,11 @@ export const ServerProvider: ParentComponent = (props) => { errorDetails, isConnected: () => connectionState() === "connected", profileData, + providerUsage, + providerUsageLoading, + providerUsageError, + requestProviderUsage, + refreshProviderUsage, deviceAuth, startLogin, goToLogin, diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index b0084e40d41..662187304fa 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -1084,6 +1084,44 @@ export const dict = { "profile.action.login": "تسجيل الدخول باستخدام Kilo Code", "profile.balance.title": "الرصيد", "profile.balance.refresh": "تحديث الرصيد", + "profile.usage.title": "الخطط والاستخدام", + "profile.usage.description": "حصة الخطة الحالية والأرصدة", + "profile.usage.refresh": "تحديث استخدام مزودي الخدمة", + "profile.usage.empty": "لم يتم اكتشاف أي مصادر لاستخدام مزودي الخدمة.", + "profile.usage.source.viaKilo": "عبر Kilo", + "profile.usage.source.direct": "مباشر", + "profile.usage.source.chatgpt": "ChatGPT", + "profile.usage.state.stale": "يتم عرض بيانات الاستخدام في آخر تحديث.", + "profile.usage.state.unavailable": "بيانات الاستخدام غير متوفرة.", + "profile.usage.plan.pastDue": "الخطة: الدفع متأخر", + "profile.usage.plan.canceling": "الخطة: تُلغى في نهاية الفترة", + "profile.usage.plan.unknown": "الخطة: الحالة غير معروفة", + "profile.usage.action.manage": "إدارة", + "profile.usage.action.addCredits": "إضافة رصيد", + "profile.usage.action.manageBilling": "إدارة الفوترة", + "profile.usage.routing": "فوترة الخطة مفعّلة. توجيه Kilo Gateway {{state}}.", + "profile.usage.routingState.disabled": "معطّل", + "profile.usage.routingState.missing": "مفقود", + "profile.usage.routingState.replaced": "تم استبداله", + "profile.usage.routingState.unknown": "غير معروف", + "profile.usage.topups.title": "عمليات التعبئة الشخصية", + "profile.usage.topups.auto": "التعبئة التلقائية", + "profile.usage.topups.on": "تشغيل", + "profile.usage.topups.off": "إيقاف", + "profile.usage.topups.rule": "أضف ${{amount}} عندما يصل الرصيد إلى ${{threshold}}", + "profile.usage.topups.payment": "{{brand}} المنتهي بـ {{last4}}", + "profile.usage.balance.breakdown": "ممنوح {{granted}} | تمت التعبئة {{toppedUp}}", + "profile.usage.window.used": "تم استخدام {{value}}", + "profile.usage.window.remaining": "متبقي {{value}}", + "profile.usage.window.remainingOf": "متبقي {{value}} من {{limit}}", + "profile.usage.window.usedOf": "تم استخدام {{value}} من {{limit}}", + "profile.usage.balance.unavailable": "غير متاح", + "profile.usage.credits.resets": "{{count}} عمليات إعادة ضبط", + "profile.usage.reset": "تتم إعادة الضبط في {{date}}", + "profile.usage.status.unknown": "غير معروف", + "profile.usage.status.unlimited": "غير محدود", + "profile.usage.status.notInPlan": "غير مشمول في الخطة", + "profile.usage.status.exhausted": "مستنفد", "profile.action.dashboard": "لوحة التحكم", "profile.action.logout": "تسجيل الخروج", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index ae04ef3315f..2ca607b28fd 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -1101,6 +1101,44 @@ export const dict = { "profile.action.login": "Entrar com Kilo Code", "profile.balance.title": "Saldo", "profile.balance.refresh": "Atualizar saldo", + "profile.usage.title": "Planos e uso", + "profile.usage.description": "Cota e saldos do plano atual", + "profile.usage.refresh": "Atualizar uso dos provedores", + "profile.usage.empty": "Nenhuma fonte de uso dos provedores detectada.", + "profile.usage.source.viaKilo": "via Kilo", + "profile.usage.source.direct": "Direto", + "profile.usage.source.chatgpt": "ChatGPT", + "profile.usage.state.stale": "Exibindo os dados de uso da última atualização.", + "profile.usage.state.unavailable": "Dados de uso indisponíveis.", + "profile.usage.plan.pastDue": "Plano: Pagamento em atraso", + "profile.usage.plan.canceling": "Plano: Cancela no fim do período", + "profile.usage.plan.unknown": "Plano: Status desconhecido", + "profile.usage.action.manage": "Gerenciar", + "profile.usage.action.addCredits": "Adicionar créditos", + "profile.usage.action.manageBilling": "Gerenciar cobrança", + "profile.usage.routing": "A cobrança do plano está ativa. O roteamento do Kilo Gateway está {{state}}.", + "profile.usage.routingState.disabled": "desativado", + "profile.usage.routingState.missing": "ausente", + "profile.usage.routingState.replaced": "substituído", + "profile.usage.routingState.unknown": "desconhecido", + "profile.usage.topups.title": "Recargas pessoais", + "profile.usage.topups.auto": "Recarga automática", + "profile.usage.topups.on": "Ativada", + "profile.usage.topups.off": "Desativada", + "profile.usage.topups.rule": "Adicionar ${{amount}} quando o saldo chegar a ${{threshold}}", + "profile.usage.topups.payment": "{{brand}} com final {{last4}}", + "profile.usage.balance.breakdown": "Concedido {{granted}} | Recarregado {{toppedUp}}", + "profile.usage.window.used": "{{value}} usado", + "profile.usage.window.remaining": "{{value}} restante", + "profile.usage.window.remainingOf": "{{value}} de {{limit}} restantes", + "profile.usage.window.usedOf": "{{value}} de {{limit}} usados", + "profile.usage.balance.unavailable": "indisponível", + "profile.usage.credits.resets": "{{count}} redefinições", + "profile.usage.reset": "Redefine em {{date}}", + "profile.usage.status.unknown": "Desconhecido", + "profile.usage.status.unlimited": "Ilimitado", + "profile.usage.status.notInPlan": "Não incluído no plano", + "profile.usage.status.exhausted": "Esgotado", "profile.action.dashboard": "Painel", "profile.action.logout": "Sair", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index b6e5725db7a..a63d1f45df2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -1141,6 +1141,44 @@ export const dict = { "profile.action.login": "Prijavite se putem Kilo Code", "profile.balance.title": "Stanje", "profile.balance.refresh": "Osvježi stanje", + "profile.usage.title": "Planovi i korištenje", + "profile.usage.description": "Kvota i stanja trenutnog plana", + "profile.usage.refresh": "Osvježi korištenje provajdera", + "profile.usage.empty": "Nisu otkriveni izvori korištenja provajdera.", + "profile.usage.source.viaKilo": "putem Kilo", + "profile.usage.source.direct": "Direktno", + "profile.usage.source.chatgpt": "ChatGPT", + "profile.usage.state.stale": "Prikazuju se posljednji ažurirani podaci o korištenju.", + "profile.usage.state.unavailable": "Podaci o korištenju nisu dostupni.", + "profile.usage.plan.pastDue": "Plan: Plaćanje kasni", + "profile.usage.plan.canceling": "Plan: Otkazuje se na kraju perioda", + "profile.usage.plan.unknown": "Plan: Status nepoznat", + "profile.usage.action.manage": "Upravljaj", + "profile.usage.action.addCredits": "Dodaj kredit", + "profile.usage.action.manageBilling": "Upravljaj naplatom", + "profile.usage.routing": "Naplata plana je aktivna. Kilo Gateway usmjeravanje je {{state}}.", + "profile.usage.routingState.disabled": "onemogućeno", + "profile.usage.routingState.missing": "odsutno", + "profile.usage.routingState.replaced": "zamijenjeno", + "profile.usage.routingState.unknown": "nepoznato", + "profile.usage.topups.title": "Lične dopune", + "profile.usage.topups.auto": "Automatska dopuna", + "profile.usage.topups.on": "Uključeno", + "profile.usage.topups.off": "Isključeno", + "profile.usage.topups.rule": "Dodaj ${{amount}} kada stanje dostigne ${{threshold}}", + "profile.usage.topups.payment": "{{brand}} s posljednje četiri cifre {{last4}}", + "profile.usage.balance.breakdown": "Dodijeljeno {{granted}} | Dopunjeno {{toppedUp}}", + "profile.usage.window.used": "Iskorišteno {{value}}", + "profile.usage.window.remaining": "Preostalo {{value}}", + "profile.usage.window.remainingOf": "Preostalo {{value}} od {{limit}}", + "profile.usage.window.usedOf": "Iskorišteno {{value}} od {{limit}}", + "profile.usage.balance.unavailable": "nedostupno", + "profile.usage.credits.resets": "{{count}} obnavljanja", + "profile.usage.reset": "Obnavlja se {{date}}", + "profile.usage.status.unknown": "Nepoznato", + "profile.usage.status.unlimited": "Neograničeno", + "profile.usage.status.notInPlan": "Nije u planu", + "profile.usage.status.exhausted": "Iscrpljeno", "profile.action.dashboard": "Kontrolna ploča", "profile.action.logout": "Odjava", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 241e3c45116..ff330bb1abe 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -1134,6 +1134,44 @@ export const dict = { "profile.action.login": "Log ind med Kilo Code", "profile.balance.title": "Saldo", "profile.balance.refresh": "Opdatér saldo", + "profile.usage.title": "Abonnementer og forbrug", + "profile.usage.description": "Kvote og saldi for det aktuelle abonnement", + "profile.usage.refresh": "Opdatér udbyderforbrug", + "profile.usage.empty": "Ingen kilder til udbyderforbrug fundet.", + "profile.usage.source.viaKilo": "via Kilo", + "profile.usage.source.direct": "Direkte", + "profile.usage.source.chatgpt": "ChatGPT", + "profile.usage.state.stale": "Viser de senest opdaterede forbrugsdata.", + "profile.usage.state.unavailable": "Forbrugsdata er ikke tilgængelige.", + "profile.usage.plan.pastDue": "Abonnement: Betaling forfalden", + "profile.usage.plan.canceling": "Abonnement: Opsiges ved periodens udgang", + "profile.usage.plan.unknown": "Abonnement: Status ukendt", + "profile.usage.action.manage": "Administrer", + "profile.usage.action.addCredits": "Tilføj kredit", + "profile.usage.action.manageBilling": "Administrer fakturering", + "profile.usage.routing": "Abonnementsfakturering er aktiv. Kilo Gateway-routing er {{state}}.", + "profile.usage.routingState.disabled": "deaktiveret", + "profile.usage.routingState.missing": "fraværende", + "profile.usage.routingState.replaced": "erstattet", + "profile.usage.routingState.unknown": "ukendt", + "profile.usage.topups.title": "Personlige optankninger", + "profile.usage.topups.auto": "Automatisk optankning", + "profile.usage.topups.on": "Til", + "profile.usage.topups.off": "Fra", + "profile.usage.topups.rule": "Tilføj ${{amount}}, når saldoen når ${{threshold}}", + "profile.usage.topups.payment": "{{brand}}, der slutter på {{last4}}", + "profile.usage.balance.breakdown": "Tildelt {{granted}} | Optanket {{toppedUp}}", + "profile.usage.window.used": "{{value}} brugt", + "profile.usage.window.remaining": "{{value}} tilbage", + "profile.usage.window.remainingOf": "{{value}} af {{limit}} tilbage", + "profile.usage.window.usedOf": "{{value}} af {{limit}} brugt", + "profile.usage.balance.unavailable": "ikke tilgængelig", + "profile.usage.credits.resets": "{{count}} nulstillinger", + "profile.usage.reset": "Nulstilles den {{date}}", + "profile.usage.status.unknown": "Ukendt", + "profile.usage.status.unlimited": "Ubegrænset", + "profile.usage.status.notInPlan": "Ikke i abonnement", + "profile.usage.status.exhausted": "Opbrugt", "profile.action.dashboard": "Dashboard", "profile.action.logout": "Log ud", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 04ffc2f2c13..320217dcdc4 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -1150,6 +1150,44 @@ export const dict = { "profile.action.login": "Mit Kilo Code anmelden", "profile.balance.title": "Guthaben", "profile.balance.refresh": "Guthaben aktualisieren", + "profile.usage.title": "Tarife & Nutzung", + "profile.usage.description": "Kontingent und Guthaben des aktuellen Tarifs", + "profile.usage.refresh": "Anbieternutzung aktualisieren", + "profile.usage.empty": "Keine Quellen für Anbieternutzung erkannt.", + "profile.usage.source.viaKilo": "über Kilo", + "profile.usage.source.direct": "Direkt", + "profile.usage.source.chatgpt": "ChatGPT", + "profile.usage.state.stale": "Zuletzt aktualisierte Nutzungsdaten werden angezeigt.", + "profile.usage.state.unavailable": "Nutzungsdaten nicht verfügbar.", + "profile.usage.plan.pastDue": "Tarif: Zahlung überfällig", + "profile.usage.plan.canceling": "Tarif: Kündigung zum Ende des Abrechnungszeitraums", + "profile.usage.plan.unknown": "Tarif: Status unbekannt", + "profile.usage.action.manage": "Verwalten", + "profile.usage.action.addCredits": "Guthaben hinzufügen", + "profile.usage.action.manageBilling": "Abrechnung verwalten", + "profile.usage.routing": "Tarifabrechnung ist aktiv. Kilo-Gateway-Routing ist {{state}}.", + "profile.usage.routingState.disabled": "deaktiviert", + "profile.usage.routingState.missing": "nicht vorhanden", + "profile.usage.routingState.replaced": "ersetzt", + "profile.usage.routingState.unknown": "unbekannt", + "profile.usage.topups.title": "Persönliche Aufladungen", + "profile.usage.topups.auto": "Automatische Aufladung", + "profile.usage.topups.on": "An", + "profile.usage.topups.off": "Aus", + "profile.usage.topups.rule": "${{amount}} hinzufügen, wenn das Guthaben ${{threshold}} erreicht", + "profile.usage.topups.payment": "{{brand}} mit Endziffern {{last4}}", + "profile.usage.balance.breakdown": "Gewährt {{granted}} | Aufgeladen {{toppedUp}}", + "profile.usage.window.used": "{{value}} verwendet", + "profile.usage.window.remaining": "{{value}} verbleibend", + "profile.usage.window.remainingOf": "{{value}} von {{limit}} verbleibend", + "profile.usage.window.usedOf": "{{value}} von {{limit}} verwendet", + "profile.usage.balance.unavailable": "nicht verfügbar", + "profile.usage.credits.resets": "{{count}} Zurücksetzungen", + "profile.usage.reset": "Wird am {{date}} zurückgesetzt", + "profile.usage.status.unknown": "Unbekannt", + "profile.usage.status.unlimited": "Unbegrenzt", + "profile.usage.status.notInPlan": "Nicht im Tarif", + "profile.usage.status.exhausted": "Aufgebraucht", "profile.action.dashboard": "Dashboard", "profile.action.logout": "Abmelden", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index affab854d31..3096d71f329 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -1059,6 +1059,44 @@ export const dict = { "profile.action.login": "Login with Kilo Code", "profile.balance.title": "Balance", "profile.balance.refresh": "Refresh balance", + "profile.usage.title": "Plans & usage", + "profile.usage.description": "Current plan quota and balances", + "profile.usage.refresh": "Refresh provider usage", + "profile.usage.empty": "No provider usage sources detected.", + "profile.usage.source.viaKilo": "via Kilo", + "profile.usage.source.direct": "Direct", + "profile.usage.source.chatgpt": "ChatGPT", + "profile.usage.state.stale": "Showing last updated usage.", + "profile.usage.state.unavailable": "Usage unavailable.", + "profile.usage.plan.pastDue": "Plan: Past due", + "profile.usage.plan.canceling": "Plan: Cancels at period end", + "profile.usage.plan.unknown": "Plan: Status unknown", + "profile.usage.action.manage": "Manage", + "profile.usage.action.addCredits": "Add credits", + "profile.usage.action.manageBilling": "Manage billing", + "profile.usage.routing": "Plan billing is active. Kilo Gateway routing is {{state}}.", + "profile.usage.routingState.disabled": "disabled", + "profile.usage.routingState.missing": "missing", + "profile.usage.routingState.replaced": "replaced", + "profile.usage.routingState.unknown": "unknown", + "profile.usage.topups.title": "Personal top-ups", + "profile.usage.topups.auto": "Auto-top-up", + "profile.usage.topups.on": "On", + "profile.usage.topups.off": "Off", + "profile.usage.topups.rule": "Add ${{amount}} when balance reaches ${{threshold}}", + "profile.usage.topups.payment": "{{brand}} ending {{last4}}", + "profile.usage.balance.breakdown": "Granted {{granted}} | Topped up {{toppedUp}}", + "profile.usage.window.used": "{{value}} used", + "profile.usage.window.remaining": "{{value}} remaining", + "profile.usage.window.remainingOf": "{{value}} of {{limit}} remaining", + "profile.usage.window.usedOf": "{{value}} of {{limit}} used", + "profile.usage.balance.unavailable": "unavailable", + "profile.usage.credits.resets": "{{count}} resets", + "profile.usage.reset": "Resets {{date}}", + "profile.usage.status.unknown": "Unknown", + "profile.usage.status.unlimited": "Unlimited", + "profile.usage.status.notInPlan": "Not in plan", + "profile.usage.status.exhausted": "Exhausted", "profile.action.dashboard": "Dashboard", "profile.action.logout": "Log Out", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 68d58c4c587..7f292973288 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -1146,6 +1146,44 @@ export const dict = { "profile.action.login": "Iniciar sesión con Kilo Code", "profile.balance.title": "Saldo", "profile.balance.refresh": "Actualizar saldo", + "profile.usage.title": "Planes y uso", + "profile.usage.description": "Cuota y saldos del plan actual", + "profile.usage.refresh": "Actualizar uso del proveedor", + "profile.usage.empty": "No se detectaron fuentes de uso de proveedores.", + "profile.usage.source.viaKilo": "a través de Kilo", + "profile.usage.source.direct": "Directo", + "profile.usage.source.chatgpt": "ChatGPT", + "profile.usage.state.stale": "Se muestran los últimos datos de uso actualizados.", + "profile.usage.state.unavailable": "Datos de uso no disponibles.", + "profile.usage.plan.pastDue": "Plan: Pago atrasado", + "profile.usage.plan.canceling": "Plan: Se cancela al final del período", + "profile.usage.plan.unknown": "Plan: Estado desconocido", + "profile.usage.action.manage": "Gestionar", + "profile.usage.action.addCredits": "Añadir créditos", + "profile.usage.action.manageBilling": "Gestionar facturación", + "profile.usage.routing": "La facturación del plan está activa. El enrutamiento de Kilo Gateway está {{state}}.", + "profile.usage.routingState.disabled": "deshabilitado", + "profile.usage.routingState.missing": "ausente", + "profile.usage.routingState.replaced": "reemplazado", + "profile.usage.routingState.unknown": "desconocido", + "profile.usage.topups.title": "Recargas personales", + "profile.usage.topups.auto": "Recarga automática", + "profile.usage.topups.on": "Activada", + "profile.usage.topups.off": "Desactivada", + "profile.usage.topups.rule": "Añadir ${{amount}} cuando el saldo llegue a ${{threshold}}", + "profile.usage.topups.payment": "{{brand}} terminada en {{last4}}", + "profile.usage.balance.breakdown": "Concedido {{granted}} | Recargado {{toppedUp}}", + "profile.usage.window.used": "{{value}} usado", + "profile.usage.window.remaining": "{{value}} restante", + "profile.usage.window.remainingOf": "{{value}} de {{limit}} restantes", + "profile.usage.window.usedOf": "{{value}} de {{limit}} usados", + "profile.usage.balance.unavailable": "no disponible", + "profile.usage.credits.resets": "{{count}} restablecimientos", + "profile.usage.reset": "Se restablece el {{date}}", + "profile.usage.status.unknown": "Desconocido", + "profile.usage.status.unlimited": "Ilimitado", + "profile.usage.status.notInPlan": "No incluido en el plan", + "profile.usage.status.exhausted": "Agotado", "profile.action.dashboard": "Panel", "profile.action.logout": "Cerrar sesión", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 382d972c4d7..aa1531c9945 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -1157,6 +1157,44 @@ export const dict = { "profile.action.login": "Se connecter avec Kilo Code", "profile.balance.title": "Solde", "profile.balance.refresh": "Actualiser le solde", + "profile.usage.title": "Forfaits et utilisation", + "profile.usage.description": "Quota et soldes du forfait actuel", + "profile.usage.refresh": "Actualiser l'utilisation du fournisseur", + "profile.usage.empty": "Aucune source d'utilisation de fournisseur détectée.", + "profile.usage.source.viaKilo": "via Kilo", + "profile.usage.source.direct": "Direct", + "profile.usage.source.chatgpt": "ChatGPT", + "profile.usage.state.stale": "Affichage des dernières données d'utilisation mises à jour.", + "profile.usage.state.unavailable": "Données d'utilisation indisponibles.", + "profile.usage.plan.pastDue": "Forfait : paiement en retard", + "profile.usage.plan.canceling": "Forfait : résiliation à la fin de la période", + "profile.usage.plan.unknown": "Forfait : statut inconnu", + "profile.usage.action.manage": "Gérer", + "profile.usage.action.addCredits": "Ajouter des crédits", + "profile.usage.action.manageBilling": "Gérer la facturation", + "profile.usage.routing": "La facturation du forfait est active. Le routage via Kilo Gateway est {{state}}.", + "profile.usage.routingState.disabled": "désactivé", + "profile.usage.routingState.missing": "manquant", + "profile.usage.routingState.replaced": "remplacé", + "profile.usage.routingState.unknown": "inconnu", + "profile.usage.topups.title": "Recharges personnelles", + "profile.usage.topups.auto": "Recharge automatique", + "profile.usage.topups.on": "Activée", + "profile.usage.topups.off": "Désactivée", + "profile.usage.topups.rule": "Ajouter ${{amount}} lorsque le solde atteint ${{threshold}}", + "profile.usage.topups.payment": "{{brand}} se terminant par {{last4}}", + "profile.usage.balance.breakdown": "Accordé {{granted}} | Rechargé {{toppedUp}}", + "profile.usage.window.used": "{{value}} utilisé", + "profile.usage.window.remaining": "{{value}} restant", + "profile.usage.window.remainingOf": "{{value}} sur {{limit}} restants", + "profile.usage.window.usedOf": "{{value}} sur {{limit}} utilisés", + "profile.usage.balance.unavailable": "indisponible", + "profile.usage.credits.resets": "{{count}} réinitialisations", + "profile.usage.reset": "Réinitialisation le {{date}}", + "profile.usage.status.unknown": "Inconnu", + "profile.usage.status.unlimited": "Illimité", + "profile.usage.status.notInPlan": "Non inclus dans le forfait", + "profile.usage.status.exhausted": "Épuisé", "profile.action.dashboard": "Tableau de bord", "profile.action.logout": "Déconnexion", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 4656f1e8950..6ecdf2648b0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -949,6 +949,44 @@ export const dict = { "profile.action.login": "Accedi con Kilo Code", "profile.balance.title": "Saldo", "profile.balance.refresh": "Aggiorna saldo", + "profile.usage.title": "Piani e utilizzo", + "profile.usage.description": "Quota e saldi del piano attuale", + "profile.usage.refresh": "Aggiorna l'utilizzo dei provider", + "profile.usage.empty": "Non è stata rilevata alcuna fonte di utilizzo dei provider.", + "profile.usage.source.viaKilo": "tramite Kilo", + "profile.usage.source.direct": "Diretto", + "profile.usage.source.chatgpt": "ChatGPT", + "profile.usage.state.stale": "Vengono mostrati i dati di utilizzo dell'ultimo aggiornamento.", + "profile.usage.state.unavailable": "Dati di utilizzo non disponibili.", + "profile.usage.plan.pastDue": "Piano: Pagamento scaduto", + "profile.usage.plan.canceling": "Piano: Si annulla al termine del periodo", + "profile.usage.plan.unknown": "Piano: Stato sconosciuto", + "profile.usage.action.manage": "Gestisci", + "profile.usage.action.addCredits": "Aggiungi crediti", + "profile.usage.action.manageBilling": "Gestisci fatturazione", + "profile.usage.routing": "La fatturazione del piano è attiva. L'instradamento tramite Kilo Gateway è {{state}}.", + "profile.usage.routingState.disabled": "disabilitato", + "profile.usage.routingState.missing": "mancante", + "profile.usage.routingState.replaced": "sostituito", + "profile.usage.routingState.unknown": "sconosciuto", + "profile.usage.topups.title": "Ricariche personali", + "profile.usage.topups.auto": "Ricarica automatica", + "profile.usage.topups.on": "Attiva", + "profile.usage.topups.off": "Disattiva", + "profile.usage.topups.rule": "Aggiungi ${{amount}} quando il saldo raggiunge ${{threshold}}", + "profile.usage.topups.payment": "{{brand}} con finale {{last4}}", + "profile.usage.balance.breakdown": "Concessi {{granted}} | Ricaricati {{toppedUp}}", + "profile.usage.window.used": "{{value}} utilizzato", + "profile.usage.window.remaining": "{{value}} rimanente", + "profile.usage.window.remainingOf": "{{value}} di {{limit}} rimanenti", + "profile.usage.window.usedOf": "{{value}} di {{limit}} utilizzati", + "profile.usage.balance.unavailable": "non disponibile", + "profile.usage.credits.resets": "{{count}} azzeramenti", + "profile.usage.reset": "Si azzera il {{date}}", + "profile.usage.status.unknown": "Sconosciuto", + "profile.usage.status.unlimited": "Illimitato", + "profile.usage.status.notInPlan": "Non incluso nel piano", + "profile.usage.status.exhausted": "Esaurito", "profile.action.dashboard": "Dashboard", "profile.action.logout": "Esci", "settings.section.configuration": "Configurazione", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 277ea1a10bc..dfd886aeca0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -1128,6 +1128,44 @@ export const dict = { "profile.action.login": "Kilo Codeでログイン", "profile.balance.title": "残高", "profile.balance.refresh": "残高を更新", + "profile.usage.title": "プランと使用状況", + "profile.usage.description": "現在のプランの利用枠と残高", + "profile.usage.refresh": "プロバイダーの使用状況を更新", + "profile.usage.empty": "プロバイダー使用量の取得元が検出されませんでした。", + "profile.usage.source.viaKilo": "Kilo経由", + "profile.usage.source.direct": "直接", + "profile.usage.source.chatgpt": "ChatGPT", + "profile.usage.state.stale": "最後に更新された使用状況を表示しています。", + "profile.usage.state.unavailable": "使用状況を取得できません。", + "profile.usage.plan.pastDue": "プラン:支払い期限切れ", + "profile.usage.plan.canceling": "プラン:期間終了時に解約", + "profile.usage.plan.unknown": "プラン:ステータス不明", + "profile.usage.action.manage": "管理", + "profile.usage.action.addCredits": "クレジットを追加", + "profile.usage.action.manageBilling": "請求を管理", + "profile.usage.routing": "プランの請求は有効です。Kilo Gatewayのルーティングは{{state}}です。", + "profile.usage.routingState.disabled": "無効", + "profile.usage.routingState.missing": "欠落", + "profile.usage.routingState.replaced": "置換済み", + "profile.usage.routingState.unknown": "不明", + "profile.usage.topups.title": "個人チャージ", + "profile.usage.topups.auto": "自動チャージ", + "profile.usage.topups.on": "オン", + "profile.usage.topups.off": "オフ", + "profile.usage.topups.rule": "残高が${{threshold}}に達したら${{amount}}を追加", + "profile.usage.topups.payment": "{{brand}}(末尾{{last4}})", + "profile.usage.balance.breakdown": "付与分 {{granted}} | チャージ分 {{toppedUp}}", + "profile.usage.window.used": "{{value}} 使用済み", + "profile.usage.window.remaining": "残り {{value}}", + "profile.usage.window.remainingOf": "{{limit}} のうち残り {{value}}", + "profile.usage.window.usedOf": "{{limit}} のうち {{value}} 使用済み", + "profile.usage.balance.unavailable": "利用不可", + "profile.usage.credits.resets": "{{count}} 回リセット", + "profile.usage.reset": "{{date}}にリセット", + "profile.usage.status.unknown": "不明", + "profile.usage.status.unlimited": "無制限", + "profile.usage.status.notInPlan": "プラン対象外", + "profile.usage.status.exhausted": "使い切り", "profile.action.dashboard": "ダッシュボード", "profile.action.logout": "ログアウト", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index fdaf39d51d6..4840835f3e8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -1089,6 +1089,44 @@ export const dict = { "profile.action.login": "Kilo Code로 로그인", "profile.balance.title": "잔액", "profile.balance.refresh": "잔액 새로고침", + "profile.usage.title": "요금제 및 사용량", + "profile.usage.description": "현재 요금제 할당량 및 잔액", + "profile.usage.refresh": "공급자 사용량 새로고침", + "profile.usage.empty": "감지된 공급자 사용량 소스가 없습니다.", + "profile.usage.source.viaKilo": "Kilo 경유", + "profile.usage.source.direct": "직접", + "profile.usage.source.chatgpt": "ChatGPT", + "profile.usage.state.stale": "마지막으로 업데이트된 사용량을 표시합니다.", + "profile.usage.state.unavailable": "사용량을 확인할 수 없습니다.", + "profile.usage.plan.pastDue": "요금제: 결제 기한 지남", + "profile.usage.plan.canceling": "요금제: 기간 종료 시 취소", + "profile.usage.plan.unknown": "요금제: 상태 알 수 없음", + "profile.usage.action.manage": "관리", + "profile.usage.action.addCredits": "크레딧 추가", + "profile.usage.action.manageBilling": "결제 관리", + "profile.usage.routing": "요금제 결제가 활성화되어 있습니다. Kilo Gateway 라우팅은 {{state}}입니다.", + "profile.usage.routingState.disabled": "비활성화 상태", + "profile.usage.routingState.missing": "누락된 상태", + "profile.usage.routingState.replaced": "대체된 상태", + "profile.usage.routingState.unknown": "알 수 없는 상태", + "profile.usage.topups.title": "개인 충전", + "profile.usage.topups.auto": "자동 충전", + "profile.usage.topups.on": "켜짐", + "profile.usage.topups.off": "꺼짐", + "profile.usage.topups.rule": "잔액이 ${{threshold}}에 도달하면 ${{amount}} 추가", + "profile.usage.topups.payment": "끝 번호 {{last4}}인 {{brand}}", + "profile.usage.balance.breakdown": "지급 {{granted}} | 충전 {{toppedUp}}", + "profile.usage.window.used": "{{value}} 사용됨", + "profile.usage.window.remaining": "{{value}} 남음", + "profile.usage.window.remainingOf": "{{limit}} 중 {{value}} 남음", + "profile.usage.window.usedOf": "{{limit}} 중 {{value}} 사용됨", + "profile.usage.balance.unavailable": "사용 불가", + "profile.usage.credits.resets": "{{count}}회 초기화", + "profile.usage.reset": "{{date}}에 초기화", + "profile.usage.status.unknown": "알 수 없음", + "profile.usage.status.unlimited": "무제한", + "profile.usage.status.notInPlan": "요금제에 포함되지 않음", + "profile.usage.status.exhausted": "소진됨", "profile.action.dashboard": "대시보드", "profile.action.logout": "로그아웃", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 42f413e7f75..763010bcefe 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -1098,6 +1098,44 @@ export const dict = { "profile.action.login": "Inloggen met Kilo Code", "profile.balance.title": "Saldo", "profile.balance.refresh": "Saldo vernieuwen", + "profile.usage.title": "Abonnementen en gebruik", + "profile.usage.description": "Quota en saldi van het huidige abonnement", + "profile.usage.refresh": "Providergebruik vernieuwen", + "profile.usage.empty": "Geen bronnen voor providergebruik gedetecteerd.", + "profile.usage.source.viaKilo": "via Kilo", + "profile.usage.source.direct": "Direct", + "profile.usage.source.chatgpt": "ChatGPT", + "profile.usage.state.stale": "De laatst bijgewerkte gebruiksgegevens worden weergegeven.", + "profile.usage.state.unavailable": "Gebruiksgegevens niet beschikbaar.", + "profile.usage.plan.pastDue": "Abonnement: Betaling achterstallig", + "profile.usage.plan.canceling": "Abonnement: Wordt aan het einde van de periode opgezegd", + "profile.usage.plan.unknown": "Abonnement: Status onbekend", + "profile.usage.action.manage": "Beheren", + "profile.usage.action.addCredits": "Tegoed toevoegen", + "profile.usage.action.manageBilling": "Facturering beheren", + "profile.usage.routing": "De abonnementsfacturering is actief. Kilo Gateway-routering is {{state}}.", + "profile.usage.routingState.disabled": "uitgeschakeld", + "profile.usage.routingState.missing": "afwezig", + "profile.usage.routingState.replaced": "vervangen", + "profile.usage.routingState.unknown": "onbekend", + "profile.usage.topups.title": "Persoonlijke opwaarderingen", + "profile.usage.topups.auto": "Automatisch opwaarderen", + "profile.usage.topups.on": "Aan", + "profile.usage.topups.off": "Uit", + "profile.usage.topups.rule": "Voeg ${{amount}} toe wanneer het saldo ${{threshold}} bereikt", + "profile.usage.topups.payment": "{{brand}} eindigend op {{last4}}", + "profile.usage.balance.breakdown": "Toegekend {{granted}} | Opgewaardeerd {{toppedUp}}", + "profile.usage.window.used": "{{value}} gebruikt", + "profile.usage.window.remaining": "{{value}} resterend", + "profile.usage.window.remainingOf": "{{value}} van {{limit}} resterend", + "profile.usage.window.usedOf": "{{value}} van {{limit}} gebruikt", + "profile.usage.balance.unavailable": "niet beschikbaar", + "profile.usage.credits.resets": "{{count}} resets", + "profile.usage.reset": "Wordt op {{date}} gereset", + "profile.usage.status.unknown": "Onbekend", + "profile.usage.status.unlimited": "Onbeperkt", + "profile.usage.status.notInPlan": "Niet in abonnement", + "profile.usage.status.exhausted": "Opgebruikt", "profile.action.dashboard": "Dashboard", "profile.action.logout": "Uitloggen", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 8c5db2c95c9..aee550e6a8d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -1100,6 +1100,44 @@ export const dict = { "profile.action.login": "Logg inn med Kilo Code", "profile.balance.title": "Saldo", "profile.balance.refresh": "Oppdater saldo", + "profile.usage.title": "Abonnementer og forbruk", + "profile.usage.description": "Kvote og saldoer for gjeldende abonnement", + "profile.usage.refresh": "Oppdater leverandørforbruk", + "profile.usage.empty": "Ingen kilder til leverandørforbruk oppdaget.", + "profile.usage.source.viaKilo": "via Kilo", + "profile.usage.source.direct": "Direkte", + "profile.usage.source.chatgpt": "ChatGPT", + "profile.usage.state.stale": "Viser sist oppdaterte forbruksdata.", + "profile.usage.state.unavailable": "Forbruksdata er utilgjengelige.", + "profile.usage.plan.pastDue": "Abonnement: Betaling forfalt", + "profile.usage.plan.canceling": "Abonnement: Avsluttes ved periodens slutt", + "profile.usage.plan.unknown": "Abonnement: Status ukjent", + "profile.usage.action.manage": "Administrer", + "profile.usage.action.addCredits": "Legg til kreditt", + "profile.usage.action.manageBilling": "Administrer fakturering", + "profile.usage.routing": "Abonnementsfakturering er aktiv. Kilo Gateway-ruting er {{state}}.", + "profile.usage.routingState.disabled": "deaktivert", + "profile.usage.routingState.missing": "fraværende", + "profile.usage.routingState.replaced": "erstattet", + "profile.usage.routingState.unknown": "ukjent", + "profile.usage.topups.title": "Personlige påfyll", + "profile.usage.topups.auto": "Automatisk påfyll", + "profile.usage.topups.on": "På", + "profile.usage.topups.off": "Av", + "profile.usage.topups.rule": "Legg til ${{amount}} når saldoen når ${{threshold}}", + "profile.usage.topups.payment": "{{brand}} som slutter på {{last4}}", + "profile.usage.balance.breakdown": "Tildelt {{granted}} | Fylt på {{toppedUp}}", + "profile.usage.window.used": "{{value}} brukt", + "profile.usage.window.remaining": "{{value}} gjenstår", + "profile.usage.window.remainingOf": "{{value}} av {{limit}} gjenstår", + "profile.usage.window.usedOf": "{{value}} av {{limit}} brukt", + "profile.usage.balance.unavailable": "utilgjengelig", + "profile.usage.credits.resets": "{{count}} tilbakestillinger", + "profile.usage.reset": "Tilbakestilles {{date}}", + "profile.usage.status.unknown": "Ukjent", + "profile.usage.status.unlimited": "Ubegrenset", + "profile.usage.status.notInPlan": "Ikke i abonnement", + "profile.usage.status.exhausted": "Oppbrukt", "profile.action.dashboard": "Kontrollpanel", "profile.action.logout": "Logg ut", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 367b7e5dbd2..31737e84986 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -1100,6 +1100,44 @@ export const dict = { "profile.action.login": "Zaloguj się przez Kilo Code", "profile.balance.title": "Saldo", "profile.balance.refresh": "Odśwież saldo", + "profile.usage.title": "Plany i wykorzystanie", + "profile.usage.description": "Limit i salda bieżącego planu", + "profile.usage.refresh": "Odśwież wykorzystanie dostawców", + "profile.usage.empty": "Nie wykryto źródeł wykorzystania dostawców.", + "profile.usage.source.viaKilo": "przez Kilo", + "profile.usage.source.direct": "Bezpośrednio", + "profile.usage.source.chatgpt": "ChatGPT", + "profile.usage.state.stale": "Wyświetlane są ostatnio zaktualizowane dane o wykorzystaniu.", + "profile.usage.state.unavailable": "Dane o wykorzystaniu są niedostępne.", + "profile.usage.plan.pastDue": "Plan: Zaległa płatność", + "profile.usage.plan.canceling": "Plan: Zostanie anulowany z końcem okresu", + "profile.usage.plan.unknown": "Plan: Status nieznany", + "profile.usage.action.manage": "Zarządzaj", + "profile.usage.action.addCredits": "Dodaj środki", + "profile.usage.action.manageBilling": "Zarządzaj rozliczeniami", + "profile.usage.routing": "Rozliczanie planu jest aktywne. Routing przez Kilo Gateway jest {{state}}.", + "profile.usage.routingState.disabled": "wyłączony", + "profile.usage.routingState.missing": "brakujący", + "profile.usage.routingState.replaced": "zastąpiony", + "profile.usage.routingState.unknown": "nieznany", + "profile.usage.topups.title": "Doładowania osobiste", + "profile.usage.topups.auto": "Automatyczne doładowanie", + "profile.usage.topups.on": "Wł.", + "profile.usage.topups.off": "Wył.", + "profile.usage.topups.rule": "Dodaj ${{amount}}, gdy saldo osiągnie ${{threshold}}", + "profile.usage.topups.payment": "{{brand}} z końcówką {{last4}}", + "profile.usage.balance.breakdown": "Przyznano {{granted}} | Doładowano {{toppedUp}}", + "profile.usage.window.used": "Wykorzystano {{value}}", + "profile.usage.window.remaining": "Pozostało {{value}}", + "profile.usage.window.remainingOf": "Pozostało {{value}} z {{limit}}", + "profile.usage.window.usedOf": "Wykorzystano {{value}} z {{limit}}", + "profile.usage.balance.unavailable": "niedostępne", + "profile.usage.credits.resets": "{{count}} resetów", + "profile.usage.reset": "Resetuje się {{date}}", + "profile.usage.status.unknown": "Nieznany", + "profile.usage.status.unlimited": "Bez limitu", + "profile.usage.status.notInPlan": "Poza planem", + "profile.usage.status.exhausted": "Wyczerpano", "profile.action.dashboard": "Panel", "profile.action.logout": "Wyloguj się", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index c84a94b0783..9d97b6c9aac 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -1140,6 +1140,44 @@ export const dict = { "profile.action.login": "Войти через Kilo Code", "profile.balance.title": "Баланс", "profile.balance.refresh": "Обновить баланс", + "profile.usage.title": "Тарифы и использование", + "profile.usage.description": "Квота и балансы текущего тарифа", + "profile.usage.refresh": "Обновить данные об использовании провайдеров", + "profile.usage.empty": "Источники данных об использовании провайдеров не обнаружены.", + "profile.usage.source.viaKilo": "через Kilo", + "profile.usage.source.direct": "Напрямую", + "profile.usage.source.chatgpt": "ChatGPT", + "profile.usage.state.stale": "Показаны последние обновлённые данные об использовании.", + "profile.usage.state.unavailable": "Данные об использовании недоступны.", + "profile.usage.plan.pastDue": "Тариф: Платёж просрочен", + "profile.usage.plan.canceling": "Тариф: Отмена в конце периода", + "profile.usage.plan.unknown": "Тариф: Статус неизвестен", + "profile.usage.action.manage": "Управлять", + "profile.usage.action.addCredits": "Пополнить баланс", + "profile.usage.action.manageBilling": "Управлять оплатой", + "profile.usage.routing": "Оплата тарифа активна. Маршрутизация через Kilo Gateway {{state}}.", + "profile.usage.routingState.disabled": "отключена", + "profile.usage.routingState.missing": "отсутствует", + "profile.usage.routingState.replaced": "заменена", + "profile.usage.routingState.unknown": "неизвестна", + "profile.usage.topups.title": "Личные пополнения", + "profile.usage.topups.auto": "Автопополнение", + "profile.usage.topups.on": "Включено", + "profile.usage.topups.off": "Выключено", + "profile.usage.topups.rule": "Пополнять на ${{amount}}, когда баланс достигает ${{threshold}}", + "profile.usage.topups.payment": "{{brand}}, последние цифры: {{last4}}", + "profile.usage.balance.breakdown": "Выдано {{granted}} | Пополнено {{toppedUp}}", + "profile.usage.window.used": "Использовано {{value}}", + "profile.usage.window.remaining": "Осталось {{value}}", + "profile.usage.window.remainingOf": "Осталось {{value}} из {{limit}}", + "profile.usage.window.usedOf": "Использовано {{value}} из {{limit}}", + "profile.usage.balance.unavailable": "недоступно", + "profile.usage.credits.resets": "{{count}} сбросов", + "profile.usage.reset": "Сбрасывается {{date}}", + "profile.usage.status.unknown": "Неизвестно", + "profile.usage.status.unlimited": "Без ограничений", + "profile.usage.status.notInPlan": "Не входит в тариф", + "profile.usage.status.exhausted": "Исчерпано", "profile.action.dashboard": "Панель управления", "profile.action.logout": "Выйти", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 2646e88a69a..73ffc61da0f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -1124,6 +1124,44 @@ export const dict = { "profile.action.login": "เข้าสู่ระบบด้วย Kilo Code", "profile.balance.title": "ยอดคงเหลือ", "profile.balance.refresh": "รีเฟรชยอดคงเหลือ", + "profile.usage.title": "แผนและการใช้งาน", + "profile.usage.description": "โควตาและยอดคงเหลือของแผนปัจจุบัน", + "profile.usage.refresh": "รีเฟรชการใช้งานของผู้ให้บริการ", + "profile.usage.empty": "ไม่พบแหล่งข้อมูลการใช้งานของผู้ให้บริการ", + "profile.usage.source.viaKilo": "ผ่าน Kilo", + "profile.usage.source.direct": "โดยตรง", + "profile.usage.source.chatgpt": "ChatGPT", + "profile.usage.state.stale": "กำลังแสดงข้อมูลการใช้งานที่อัปเดตล่าสุด", + "profile.usage.state.unavailable": "ไม่มีข้อมูลการใช้งาน", + "profile.usage.plan.pastDue": "แผน: ค้างชำระ", + "profile.usage.plan.canceling": "แผน: ยกเลิกเมื่อสิ้นสุดรอบ", + "profile.usage.plan.unknown": "แผน: ไม่ทราบสถานะ", + "profile.usage.action.manage": "จัดการ", + "profile.usage.action.addCredits": "เพิ่มเครดิต", + "profile.usage.action.manageBilling": "จัดการการเรียกเก็บเงิน", + "profile.usage.routing": "การเรียกเก็บเงินตามแผนเปิดใช้งานอยู่ การกำหนดเส้นทาง Kilo Gateway {{state}}", + "profile.usage.routingState.disabled": "ปิดใช้งาน", + "profile.usage.routingState.missing": "ขาดหาย", + "profile.usage.routingState.replaced": "ถูกแทนที่", + "profile.usage.routingState.unknown": "ไม่ทราบ", + "profile.usage.topups.title": "การเติมเงินส่วนบุคคล", + "profile.usage.topups.auto": "เติมเงินอัตโนมัติ", + "profile.usage.topups.on": "เปิด", + "profile.usage.topups.off": "ปิด", + "profile.usage.topups.rule": "เพิ่ม ${{amount}} เมื่อยอดคงเหลือถึง ${{threshold}}", + "profile.usage.topups.payment": "{{brand}} ลงท้ายด้วย {{last4}}", + "profile.usage.balance.breakdown": "ได้รับ {{granted}} | เติมเงินแล้ว {{toppedUp}}", + "profile.usage.window.used": "ใช้ไป {{value}}", + "profile.usage.window.remaining": "เหลือ {{value}}", + "profile.usage.window.remainingOf": "เหลือ {{value}} จาก {{limit}}", + "profile.usage.window.usedOf": "ใช้ไป {{value}} จาก {{limit}}", + "profile.usage.balance.unavailable": "ไม่พร้อมใช้งาน", + "profile.usage.credits.resets": "รีเซ็ต {{count}} ครั้ง", + "profile.usage.reset": "รีเซ็ตในวันที่ {{date}}", + "profile.usage.status.unknown": "ไม่ทราบ", + "profile.usage.status.unlimited": "ไม่จำกัด", + "profile.usage.status.notInPlan": "ไม่รวมอยู่ในแผน", + "profile.usage.status.exhausted": "ใช้หมดแล้ว", "profile.action.dashboard": "แดชบอร์ด", "profile.action.logout": "ออกจากระบบ", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index b4331e0522d..e8c7f11400a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -1097,6 +1097,44 @@ export const dict = { "profile.action.login": "Kilo Code ile giriş yap", "profile.balance.title": "Bakiye", "profile.balance.refresh": "Bakiyeyi yenile", + "profile.usage.title": "Planlar ve kullanım", + "profile.usage.description": "Mevcut plan kotası ve bakiyeleri", + "profile.usage.refresh": "Sağlayıcı kullanımını yenile", + "profile.usage.empty": "Hiçbir sağlayıcı kullanım kaynağı algılanmadı.", + "profile.usage.source.viaKilo": "Kilo üzerinden", + "profile.usage.source.direct": "Doğrudan", + "profile.usage.source.chatgpt": "ChatGPT", + "profile.usage.state.stale": "Son güncellenen kullanım verileri gösteriliyor.", + "profile.usage.state.unavailable": "Kullanım verileri kullanılamıyor.", + "profile.usage.plan.pastDue": "Plan: Ödeme gecikmiş", + "profile.usage.plan.canceling": "Plan: Dönem sonunda iptal edilecek", + "profile.usage.plan.unknown": "Plan: Durum bilinmiyor", + "profile.usage.action.manage": "Yönet", + "profile.usage.action.addCredits": "Kredi ekle", + "profile.usage.action.manageBilling": "Faturalandırmayı yönet", + "profile.usage.routing": "Plan faturalandırması etkin. Kilo Gateway yönlendirmesi {{state}}.", + "profile.usage.routingState.disabled": "devre dışı", + "profile.usage.routingState.missing": "eksik", + "profile.usage.routingState.replaced": "değiştirildi", + "profile.usage.routingState.unknown": "bilinmiyor", + "profile.usage.topups.title": "Kişisel yüklemeler", + "profile.usage.topups.auto": "Otomatik yükleme", + "profile.usage.topups.on": "Açık", + "profile.usage.topups.off": "Kapalı", + "profile.usage.topups.rule": "Bakiye ${{threshold}} seviyesine geldiğinde ${{amount}} ekle", + "profile.usage.topups.payment": "Sonu {{last4}} ile biten {{brand}}", + "profile.usage.balance.breakdown": "Tanımlanan {{granted}} | Yüklenen {{toppedUp}}", + "profile.usage.window.used": "{{value}} kullanıldı", + "profile.usage.window.remaining": "{{value}} kaldı", + "profile.usage.window.remainingOf": "{{limit}} içinden {{value}} kaldı", + "profile.usage.window.usedOf": "{{limit}} içinden {{value}} kullanıldı", + "profile.usage.balance.unavailable": "kullanılamıyor", + "profile.usage.credits.resets": "{{count}} sıfırlama", + "profile.usage.reset": "{{date}} tarihinde sıfırlanır", + "profile.usage.status.unknown": "Bilinmiyor", + "profile.usage.status.unlimited": "Sınırsız", + "profile.usage.status.notInPlan": "Plana dahil değil", + "profile.usage.status.exhausted": "Tükendi", "profile.action.dashboard": "Kontrol Paneli", "profile.action.logout": "Çıkış Yap", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 31bd1c5a611..dbd0d60255e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -1095,6 +1095,44 @@ export const dict = { "profile.action.login": "Увійти через Kilo Code", "profile.balance.title": "Баланс", "profile.balance.refresh": "Оновити баланс", + "profile.usage.title": "Плани та використання", + "profile.usage.description": "Квота й баланси поточного плану", + "profile.usage.refresh": "Оновити дані про використання провайдерів", + "profile.usage.empty": "Джерел даних про використання провайдерів не виявлено.", + "profile.usage.source.viaKilo": "через Kilo", + "profile.usage.source.direct": "Напряму", + "profile.usage.source.chatgpt": "ChatGPT", + "profile.usage.state.stale": "Показано останні оновлені дані про використання.", + "profile.usage.state.unavailable": "Дані про використання недоступні.", + "profile.usage.plan.pastDue": "План: Платіж прострочено", + "profile.usage.plan.canceling": "План: Скасування наприкінці періоду", + "profile.usage.plan.unknown": "План: Статус невідомий", + "profile.usage.action.manage": "Керувати", + "profile.usage.action.addCredits": "Поповнити баланс", + "profile.usage.action.manageBilling": "Керувати оплатою", + "profile.usage.routing": "Оплата плану активна. Маршрутизація через Kilo Gateway {{state}}.", + "profile.usage.routingState.disabled": "вимкнена", + "profile.usage.routingState.missing": "відсутня", + "profile.usage.routingState.replaced": "замінена", + "profile.usage.routingState.unknown": "невідома", + "profile.usage.topups.title": "Особисті поповнення", + "profile.usage.topups.auto": "Автопоповнення", + "profile.usage.topups.on": "Увімкнено", + "profile.usage.topups.off": "Вимкнено", + "profile.usage.topups.rule": "Поповнювати на ${{amount}}, коли баланс досягає ${{threshold}}", + "profile.usage.topups.payment": "{{brand}}, останні цифри: {{last4}}", + "profile.usage.balance.breakdown": "Надано {{granted}} | Поповнено {{toppedUp}}", + "profile.usage.window.used": "Використано {{value}}", + "profile.usage.window.remaining": "Залишилося {{value}}", + "profile.usage.window.remainingOf": "Залишилося {{value}} з {{limit}}", + "profile.usage.window.usedOf": "Використано {{value}} з {{limit}}", + "profile.usage.balance.unavailable": "недоступно", + "profile.usage.credits.resets": "{{count}} скидань", + "profile.usage.reset": "Скидається {{date}}", + "profile.usage.status.unknown": "Невідомо", + "profile.usage.status.unlimited": "Без обмежень", + "profile.usage.status.notInPlan": "Не входить до плану", + "profile.usage.status.exhausted": "Вичерпано", "profile.action.dashboard": "Панель керування", "profile.action.logout": "Вийти", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index c186582fd05..7092d5a99e6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -1108,6 +1108,44 @@ export const dict = { "profile.action.login": "使用 Kilo Code 登录", "profile.balance.title": "余额", "profile.balance.refresh": "刷新余额", + "profile.usage.title": "套餐与用量", + "profile.usage.description": "当前套餐额度和余额", + "profile.usage.refresh": "刷新提供商用量", + "profile.usage.empty": "未检测到提供商用量来源。", + "profile.usage.source.viaKilo": "通过 Kilo", + "profile.usage.source.direct": "直接", + "profile.usage.source.chatgpt": "ChatGPT", + "profile.usage.state.stale": "正在显示上次更新的用量。", + "profile.usage.state.unavailable": "用量数据不可用。", + "profile.usage.plan.pastDue": "套餐:付款逾期", + "profile.usage.plan.canceling": "套餐:将在周期结束时取消", + "profile.usage.plan.unknown": "套餐:状态未知", + "profile.usage.action.manage": "管理", + "profile.usage.action.addCredits": "添加额度", + "profile.usage.action.manageBilling": "管理账单", + "profile.usage.routing": "套餐账单处于有效状态。Kilo Gateway 路由状态为 {{state}}。", + "profile.usage.routingState.disabled": "已禁用", + "profile.usage.routingState.missing": "缺失", + "profile.usage.routingState.replaced": "已替换", + "profile.usage.routingState.unknown": "未知", + "profile.usage.topups.title": "个人充值", + "profile.usage.topups.auto": "自动充值", + "profile.usage.topups.on": "开启", + "profile.usage.topups.off": "关闭", + "profile.usage.topups.rule": "余额达到 ${{threshold}} 时充值 ${{amount}}", + "profile.usage.topups.payment": "{{brand}} 尾号 {{last4}}", + "profile.usage.balance.breakdown": "赠送 {{granted}} | 充值 {{toppedUp}}", + "profile.usage.window.used": "已使用 {{value}}", + "profile.usage.window.remaining": "剩余 {{value}}", + "profile.usage.window.remainingOf": "共 {{limit}},剩余 {{value}}", + "profile.usage.window.usedOf": "共 {{limit}},已使用 {{value}}", + "profile.usage.balance.unavailable": "不可用", + "profile.usage.credits.resets": "{{count}} 次重置", + "profile.usage.reset": "{{date}} 重置", + "profile.usage.status.unknown": "未知", + "profile.usage.status.unlimited": "无限制", + "profile.usage.status.notInPlan": "不在套餐内", + "profile.usage.status.exhausted": "已用尽", "profile.action.dashboard": "控制面板", "profile.action.logout": "退出登录", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index e9d977f7c0c..182c577015b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -1076,6 +1076,44 @@ export const dict = { "profile.action.login": "使用 Kilo Code 登入", "profile.balance.title": "餘額", "profile.balance.refresh": "重新整理餘額", + "profile.usage.title": "方案與用量", + "profile.usage.description": "目前方案配額和餘額", + "profile.usage.refresh": "重新整理供應商用量", + "profile.usage.empty": "未偵測到供應商用量來源。", + "profile.usage.source.viaKilo": "透過 Kilo", + "profile.usage.source.direct": "直接", + "profile.usage.source.chatgpt": "ChatGPT", + "profile.usage.state.stale": "正在顯示上次更新的用量。", + "profile.usage.state.unavailable": "無法取得用量資料。", + "profile.usage.plan.pastDue": "方案:付款逾期", + "profile.usage.plan.canceling": "方案:將於週期結束時取消", + "profile.usage.plan.unknown": "方案:狀態未知", + "profile.usage.action.manage": "管理", + "profile.usage.action.addCredits": "新增額度", + "profile.usage.action.manageBilling": "管理帳單", + "profile.usage.routing": "方案帳單目前有效。Kilo Gateway 路由狀態為 {{state}}。", + "profile.usage.routingState.disabled": "已停用", + "profile.usage.routingState.missing": "缺失", + "profile.usage.routingState.replaced": "已取代", + "profile.usage.routingState.unknown": "未知", + "profile.usage.topups.title": "個人加值", + "profile.usage.topups.auto": "自動加值", + "profile.usage.topups.on": "開啟", + "profile.usage.topups.off": "關閉", + "profile.usage.topups.rule": "餘額達到 ${{threshold}} 時加值 ${{amount}}", + "profile.usage.topups.payment": "{{brand}} 尾號 {{last4}}", + "profile.usage.balance.breakdown": "贈送 {{granted}} | 加值 {{toppedUp}}", + "profile.usage.window.used": "已使用 {{value}}", + "profile.usage.window.remaining": "剩餘 {{value}}", + "profile.usage.window.remainingOf": "共 {{limit}},剩餘 {{value}}", + "profile.usage.window.usedOf": "共 {{limit}},已使用 {{value}}", + "profile.usage.balance.unavailable": "無法使用", + "profile.usage.credits.resets": "{{count}} 次重設", + "profile.usage.reset": "{{date}} 重設", + "profile.usage.status.unknown": "未知", + "profile.usage.status.unlimited": "無限制", + "profile.usage.status.notInPlan": "不在方案內", + "profile.usage.status.exhausted": "已用盡", "profile.action.dashboard": "控制面板", "profile.action.logout": "登出", diff --git a/packages/kilo-vscode/webview-ui/src/stories/profile.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/profile.stories.tsx index fd9226c7888..07a2343fd9e 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/profile.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/profile.stories.tsx @@ -6,7 +6,7 @@ import type { Meta, StoryObj } from "storybook-solidjs-vite" import { StoryProviders } from "./StoryProviders" import ProfileView from "../components/profile/ProfileView" -import type { ProfileData, DeviceAuthState } from "../types/messages" +import type { ProfileData, ProviderUsageData, DeviceAuthState } from "../types/messages" const meta: Meta = { title: "Profile", @@ -39,14 +39,113 @@ const personalProfile: ProfileData = { const idleAuth: DeviceAuthState = { status: "idle" } +const usage: ProviderUsageData = { + generatedAt: "2026-06-19T12:00:00.000Z", + items: [ + { + id: "kilo-pass", + providerID: "kilo", + sourceKind: "kilo_pass", + providerLabel: "Kilo", + planLabel: "Kilo Pass $49", + sourceLabel: "via Kilo", + fetchState: "ready", + planState: "active", + routingState: "not_applicable", + availabilityState: "available", + fetchedAt: "2026-06-19T12:00:00.000Z", + confidence: "high", + source: "cloud", + managementUrl: "https://app.kilo.ai/subscriptions/kilo-pass", + windows: [ + { + id: "current-period", + label: "Current period", + resource: "Kilo Credits", + kind: "quota", + unit: "USD", + orientation: "amount", + used: 12, + remaining: 42, + limit: 54, + resetAt: "2026-07-01T00:00:00.000Z", + state: "active", + }, + ], + balances: [], + credits: [{ id: "bonus", label: "Bonus credits", balance: "5", unit: "USD" }], + }, + { + id: "kilo-managed-minimax:plan", + providerID: "minimax", + sourceKind: "kilo_managed", + providerLabel: "MiniMax", + planLabel: "Token Plan Plus", + sourceLabel: "via Kilo", + fetchState: "ready", + planState: "active", + routingState: "missing", + availabilityState: "available", + fetchedAt: "2026-06-19T12:00:00.000Z", + confidence: "high", + source: "cloud", + managementUrl: "https://app.kilo.ai/subscriptions/coding-plans/plan", + windows: [ + { + id: "general-interval", + label: "Shared quota 5-hour", + resource: "general", + kind: "quota", + unit: "percent", + orientation: "remaining_percent", + used: 24, + remaining: 76, + limit: 100, + state: "active", + }, + ], + balances: [], + credits: [], + }, + ], + kiloBilling: { + topUpUrl: "https://app.kilo.ai/credits", + manageUrl: "https://app.kilo.ai/subscriptions", + autoTopUp: { + enabled: true, + amountCents: 5000, + thresholdCents: 500, + paymentType: "card", + paymentBrand: "Visa", + paymentLast4: "4242", + }, + }, +} + +const directUsage: ProviderUsageData = { + generatedAt: usage.generatedAt, + items: [ + { + ...usage.items[1], + id: "minimax-direct-global", + providerID: "minimax-coding-plan", + sourceKind: "direct", + sourceLabel: "MiniMax Global", + routingState: "not_applicable", + source: "provider_api", + managementUrl: "https://platform.minimax.io/subscribe/token-plan", + }, + ], +} + const noop = () => {} export const LoggedIn: Story = { name: "ProfileView — logged in with orgs", render: () => ( -
- +
+
), @@ -56,8 +155,8 @@ export const LoggedInPersonal: Story = { name: "ProfileView — personal account", render: () => ( -
- +
+
), @@ -67,8 +166,113 @@ export const NotLoggedIn: Story = { name: "ProfileView — not logged in", render: () => ( -
- +
+ +
+ + ), +} + +export const OrganizationContext: Story = { + name: "ProfileView — organization context", + render: () => ( + +
+ +
+
+ ), +} + +export const StaleAndUnavailable: Story = { + name: "ProfileView — stale and unavailable usage", + render: () => ( + +
+ +
+
+ ), +} + +export const BalanceAndCredits: Story = { + name: "ProfileView — balance and credits contract", + render: () => ( + +
+ +
+
+ ), +} + +export const EmptyUsage: Story = { + name: "ProfileView — no usage sources", + render: () => ( + +
+
), diff --git a/packages/kilo-vscode/webview-ui/src/styles/chat.css b/packages/kilo-vscode/webview-ui/src/styles/chat.css index cfc2bd853dc..f8f6e18ad60 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat.css @@ -23,4 +23,5 @@ @import "./plan-exit.css"; @import "./suggest-bar.css"; @import "./settings.css"; +@import "./provider-usage.css"; @import "./high-contrast.css"; diff --git a/packages/kilo-vscode/webview-ui/src/styles/provider-usage.css b/packages/kilo-vscode/webview-ui/src/styles/provider-usage.css new file mode 100644 index 00000000000..861ce405dfa --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/styles/provider-usage.css @@ -0,0 +1,91 @@ +/* Provider Usage Cards */ +.provider-usage-section { + margin-top: 24px; + padding-top: 18px; + border-top: 1px solid var(--border-weak-base); +} + +.provider-usage-section-heading, +.provider-usage-heading, +.provider-usage-row-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.provider-usage-section-heading { + margin-bottom: 12px; +} + +.provider-usage-section-heading h3 { + margin: 0; + font-size: var(--kilo-font-size-14); + font-weight: 650; + color: var(--vscode-foreground); +} + +.provider-usage-section-heading p, +.provider-usage-meta, +.provider-usage-notice { + margin: 3px 0 0; + color: var(--vscode-descriptionForeground); + font-size: var(--kilo-font-size-11); + line-height: 1.45; +} + +.provider-usage-list, +.provider-usage-resources { + display: flex; + flex-direction: column; + gap: 10px; +} + +.provider-usage-card { + position: relative; + overflow: hidden; +} + +.provider-usage-heading [data-slot="card-title"] { + font-size: var(--kilo-font-size-13); +} + +.provider-usage-row { + display: grid; + gap: 5px; +} + +.provider-usage-row-heading { + align-items: baseline; + color: var(--vscode-foreground); + font-size: var(--kilo-font-size-12); +} + +.provider-usage-row-heading strong { + text-align: right; + font-weight: 600; +} + +.provider-usage-notice { + padding-left: 8px; + border-left: 2px solid var(--vscode-editorWarning-foreground); +} + +.provider-usage-loading { + display: grid; + min-height: 92px; + place-items: center; +} + +.provider-usage-loading [data-component="spinner"] { + width: 20px; + height: 20px; +} + +@media (max-width: 360px) { + .provider-usage-section-heading, + .provider-usage-heading { + align-items: stretch; + flex-direction: column; + } +} diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index aeb7a2464f0..3078ca81af6 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -20,6 +20,7 @@ import type { AgentInfo, SkillInfo, SlashCommandInfo } from "./agents" import type { BrowserSettings, Config, FeatureFlags, IndexingStatus, KiloEmbeddingModelCatalog } from "./config" import type { WorkStyle, WorkStyleState } from "../../../../src/shared/work-style-presets" import type { KilocodeNotification, ProfileData } from "./profile" +import type { ProviderUsageLoadedMessage } from "./provider-usage" import type { AgentManagerApplyWorktreeDiffConflict, AgentManagerApplyWorktreeDiffStatus, @@ -990,6 +991,7 @@ export type ExtensionMessage = | GitRemoteUrlLoadedMessage | ActionMessage | ProfileDataMessage + | ProviderUsageLoadedMessage | DeviceAuthStartedMessage | DeviceAuthCompleteMessage | DeviceAuthFailedMessage diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/index.ts b/packages/kilo-vscode/webview-ui/src/types/messages/index.ts index f74b20db685..e18a36b0ece 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/index.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/index.ts @@ -11,6 +11,7 @@ export * from "./providers" export * from "./agents" export * from "./config" export * from "./profile" +export * from "./provider-usage" export * from "./agent-manager" export * from "./migration" export * from "./extension-messages" diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/provider-usage.ts b/packages/kilo-vscode/webview-ui/src/types/messages/provider-usage.ts new file mode 100644 index 00000000000..2eebbda16a2 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/types/messages/provider-usage.ts @@ -0,0 +1,18 @@ +import type { ProviderUsage } from "@kilocode/sdk/v2/client" + +export type ProviderUsageData = ProviderUsage + +export interface ProviderUsageLoadedMessage { + type: "providerUsageLoaded" + data?: ProviderUsageData + error?: string + reset?: boolean +} + +export interface RequestProviderUsageMessage { + type: "requestProviderUsage" +} + +export interface RefreshProviderUsageMessage { + type: "refreshProviderUsage" +} diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 680cc412f1b..e231a88c732 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -6,6 +6,7 @@ import type { ModelSelection, ProviderConfig } from "./providers" import type { Config } from "./config" import type { ModelAllocation, ReviewComment } from "./agent-manager" import type { WorkStyle, WorkStyleState } from "../../../../src/shared/work-style-presets" +import type { RefreshProviderUsageMessage, RequestProviderUsageMessage } from "./provider-usage" import type { ClearLegacyDataMessage, FinalizeLegacyMigrationMessage, @@ -1096,6 +1097,8 @@ export type WebviewMessage = | LoginRequest | LogoutRequest | RefreshProfileRequest + | RequestProviderUsageMessage + | RefreshProviderUsageMessage | OpenExternalRequest | OpenSettingsPanelRequest | OpenVSCodeSettingsRequest diff --git a/packages/opencode/src/kilocode/components/dialog-provider-usage.tsx b/packages/opencode/src/kilocode/components/dialog-provider-usage.tsx new file mode 100644 index 00000000000..4918534ba7e --- /dev/null +++ b/packages/opencode/src/kilocode/components/dialog-provider-usage.tsx @@ -0,0 +1,203 @@ +import { TextAttributes } from "@opentui/core" +import { useKeyboard } from "@opentui/solid" +import type { ProviderUsage, ProviderUsageSnapshot, ProviderUsageWindow } from "@kilocode/sdk/v2" +import { useTheme } from "@tui/context/theme" +import { useDialog } from "@tui/ui/dialog" +import { Link } from "@tui/ui/link" +import { Spinner } from "@tui/component/spinner" +import { For, Show, createSignal, onMount } from "solid-js" + +interface DialogProviderUsageProps { + useSDK: () => { client: { kilocode: { providerUsage: { get(): Promise; refresh(): Promise } } } } +} + +type Response = { data?: ProviderUsage; error?: unknown } + +function amount(value: number, unit: string) { + if (unit === "USD") return `$${value.toFixed(2)}` + if (unit === "percent") return `${value.toFixed(value % 1 ? 1 : 0)}%` + return `${value.toLocaleString()} ${unit === "count" ? "" : unit}`.trim() +} + +export function formatWindow(window: ProviderUsageWindow) { + if (window.state === "unlimited") return "Unlimited" + if (window.state === "not_in_plan") return "Not in plan" + if (window.state === "unknown") return "Unknown" + if (window.orientation === "used_percent" && window.used !== undefined) + return `${amount(window.used, "percent")} used` + if (window.orientation === "remaining_percent" && window.remaining !== undefined) + return `${amount(window.remaining, "percent")} remaining` + if (window.remaining !== undefined && window.limit !== undefined) + return `${amount(window.remaining, window.unit)} remaining of ${amount(window.limit, window.unit)}` + if (window.used !== undefined && window.limit !== undefined) + return `${amount(window.used, window.unit)} used of ${amount(window.limit, window.unit)}` + return window.state === "exhausted" ? "Exhausted" : "Unknown" +} + +function Item(props: { item: ProviderUsageSnapshot }) { + const { theme } = useTheme() + return ( + + + + {props.item.providerLabel} - {props.item.planLabel} + + {props.item.sourceLabel} + + + {props.item.fetchState === "ready" ? props.item.planState : props.item.fetchState} + + + {(window) => ( + + + {window.label}: {formatWindow(window)} + + + {(reset) => Resets {new Date(reset()).toLocaleString()}} + + + )} + + + {(balance) => ( + + + {balance.label}: {balance.total} {balance.currency} + {balance.available === false ? " (unavailable)" : ""} + + + + Granted {balance.granted ?? "unknown"} | Topped up {balance.toppedUp ?? "unknown"} + + + + )} + + + {(credit) => ( + + {credit.label}:{" "} + {credit.unlimited + ? "Unlimited" + : credit.balance !== undefined + ? `${credit.balance} ${credit.unit ?? ""}` + : credit.availableResets !== undefined + ? `${credit.availableResets} resets` + : "Unknown"} + + )} + + + Routing: {props.item.routingState} + + {(error) => {error().message}} + + {(url) => ( + + Manage: + + {url()} + + + )} + + + ) +} + +export function ProviderUsageBody(props: { data: ProviderUsage }) { + const { theme } = useTheme() + return ( + + 0} + fallback={No provider usage sources detected.} + > + {(item) => } + + + {(billing) => ( + + + Personal top-ups + + + {(auto) => ( + + Auto-top-up: {auto().enabled ? "On" : "Off"} - ${(auto().amountCents / 100).toFixed(2)} at $ + {(auto().thresholdCents / 100).toFixed(2)} + {auto().paymentLast4 + ? ` - ${auto().paymentBrand ?? auto().paymentType ?? "payment method"} ending ${auto().paymentLast4}` + : ""} + + )} + + {(error) => {error().message}} + + + Add credits + + + Manage billing + + + + )} + + + ) +} + +export function DialogProviderUsage(props: DialogProviderUsageProps) { + const dialog = useDialog() + const { theme } = useTheme() + const sdk = props.useSDK() + const [data, setData] = createSignal() + const [loading, setLoading] = createSignal(true) + const [failure, setFailure] = createSignal() + + async function load(force: boolean) { + if (loading() && data()) return + setLoading(true) + setFailure(undefined) + const response = (await (force + ? sdk.client.kilocode.providerUsage.refresh().catch(() => undefined) + : sdk.client.kilocode.providerUsage.get().catch(() => undefined))) as Response | undefined + if (response?.data) setData(response.data) + if (response?.error || !response?.data) setFailure("Provider usage could not be loaded.") + setLoading(false) + } + + onMount(() => { + dialog.setSize("xlarge") + void load(false) + }) + + useKeyboard((event) => { + if (event.ctrl && event.name === "r") void load(true) + }) + + return ( + + + + Plans & usage + + esc + + + {(value) => } + + + + {(message) => {message()}} + + + !loading() && void load(true)}> + refresh ctrl+r + + + + ) +} diff --git a/packages/opencode/src/kilocode/kilo-commands.tsx b/packages/opencode/src/kilocode/kilo-commands.tsx index 8ac8289a9ce..7837c1ba5ff 100644 --- a/packages/opencode/src/kilocode/kilo-commands.tsx +++ b/packages/opencode/src/kilocode/kilo-commands.tsx @@ -18,6 +18,7 @@ import { DialogKiloProfile } from "./components/dialog-kilo-profile.js" import { DialogClawSetup } from "./components/dialog-claw-setup.js" import { DialogClawUpgrade } from "./components/dialog-claw-upgrade.js" import { DialogIndexing } from "./components/dialog-indexing.js" +import { DialogProviderUsage } from "./components/dialog-provider-usage.js" import { indexingEnabled } from "./indexing-feature" // These types are OpenCode-internal and imported at runtime @@ -124,6 +125,18 @@ export function registerKiloCommands(useSDK: () => UseSDK) { }, }, + { + name: "kilo.usage", + title: "Plans & usage", + desc: "View provider plans, quota, and balances", + category: "Kilo", + slashName: "usage", + slashAliases: ["plans", "quota"], + run: () => { + dialog.replace(() => ) + }, + }, + // /profile command { name: "kilo.profile", diff --git a/packages/opencode/src/kilocode/provider-usage/cloud.ts b/packages/opencode/src/kilocode/provider-usage/cloud.ts new file mode 100644 index 00000000000..2d8d4ba77dd --- /dev/null +++ b/packages/opencode/src/kilocode/provider-usage/cloud.ts @@ -0,0 +1,214 @@ +import { + getAutoTopUpState, + getCodingPlanUsage, + getKiloPassState, + listByokEntries, + listCodingPlanSubscriptions, + type AutoTopUpState, + type ByokEntry, + type CodingPlanSubscription, + type KiloPassState, +} from "@kilocode/kilo-gateway" +import type { KiloBilling, UsageSnapshot } from "./schema" +import { decode } from "@/kilocode/provider/minimax/native" +import { normalize } from "@/kilocode/provider/minimax/usage" + +export interface CloudState { + pass: Result + topup: Result + 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): Promise { + const [pass, topup, plans, byok] = await Promise.all([ + safe(getKiloPassState(token)), + safe(getAutoTopUpState(token)), + safe(listCodingPlanSubscriptions(token)), + safe(listByokEntries(token)), + ]) + return { pass, topup, 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 }) + +export function billing(state: CloudState): KiloBilling { + const url = base() + return { + topUpUrl: `${url}/credits`, + manageUrl: `${url}/subscriptions`, + ...(state.topup.ok + ? { + autoTopUp: { + enabled: state.topup.value.enabled, + amountCents: state.topup.value.amountCents, + thresholdCents: state.topup.value.thresholdCents, + ...(state.topup.value.paymentMethod?.type && { paymentType: state.topup.value.paymentMethod.type }), + ...(state.topup.value.paymentMethod?.brand && { paymentBrand: state.topup.value.paymentMethod.brand }), + ...(state.topup.value.paymentMethod?.last4 && { paymentLast4: state.topup.value.paymentMethod.last4 }), + }, + } + : { error: error("cloud_auto_top_up_unavailable", "Auto-top-up status is unavailable.") }), + } +} + +export function pass(state: CloudState): UsageSnapshot[] { + if (!state.pass.ok || !state.pass.value.subscription) return [] + const subscription = state.pass.value.subscription + if (subscription.status === "canceled" || subscription.status === "incomplete_expired") return [] + + const bonus = subscription.currentPeriodBonusCreditsUsd ?? 0 + const limit = subscription.currentPeriodBaseCreditsUsd + bonus + const used = subscription.currentPeriodUsageUsd + const planState = subscription.cancelAtPeriodEnd + ? "canceling" + : subscription.status === "past_due" || subscription.status === "unpaid" + ? "past_due" + : subscription.status === "active" || subscription.status === "trialing" + ? "active" + : "unknown" + return [ + { + id: "kilo-pass", + providerID: "kilo", + sourceKind: "kilo_pass", + providerLabel: "Kilo", + planLabel: `Kilo Pass $${subscription.tier.replace("tier_", "")}`, + sourceLabel: "via Kilo", + fetchState: "ready", + planState, + routingState: "not_applicable", + availabilityState: limit - used <= 0 ? "exhausted" : "available", + fetchedAt: new Date().toISOString(), + confidence: "high", + source: "cloud", + managementUrl: `${base()}/subscriptions/kilo-pass`, + windows: [ + { + id: "current-period", + label: "Current period", + resource: "Kilo Credits", + kind: "quota", + unit: "USD", + orientation: "amount", + used, + remaining: Math.max(0, limit - used), + limit, + ...(subscription.refillAt && { resetAt: subscription.refillAt }), + state: limit - used <= 0 ? "exhausted" : "active", + }, + ], + balances: [], + credits: [ + { + id: "base-credits", + label: "Base credits", + balance: String(subscription.currentPeriodBaseCreditsUsd), + unit: "USD", + }, + ...(subscription.currentPeriodBonusCreditsUsd !== null + ? [ + { + id: "bonus-credits", + label: subscription.isBonusUnlocked ? "Bonus credits" : "Pending bonus credits", + balance: String(subscription.currentPeriodBonusCreditsUsd), + unit: "USD", + }, + ] + : []), + ], + }, + ] +} + +function route(subscription: CodingPlanSubscription, state: Result): UsageSnapshot["routingState"] { + if (!state.ok) return "unknown" + const entry = state.value.find((item) => item.provider_id === subscription.providerId) + if (!subscription.hasInstalledByokKey) return entry ? "replaced" : "missing" + if (!entry || entry.management_source !== "coding_plan") return "missing" + return entry.is_enabled ? "active" : "disabled" +} + +export function plans(state: CloudState) { + if (!state.plans.ok) return [] + return state.plans.value + .filter( + (item) => + item.planId === "minimax-token-plan-plus" && + item.providerId === "minimax" && + (item.status === "active" || item.status === "past_due"), + ) + .sort((a, b) => a.id.localeCompare(b.id)) +} + +export async function managed( + token: string, + subscription: CodingPlanSubscription, + state: Result, +): Promise { + const routingState = route(subscription, state) + const fetchedAt = new Date().toISOString() + const planState = subscription.cancelAtPeriodEnd + ? "canceling" + : subscription.status === "past_due" + ? "past_due" + : "active" + const id = `kilo-managed-minimax:${subscription.id}` + const managementUrl = `${base()}/subscriptions/coding-plans/${subscription.id}` + + return getCodingPlanUsage(token, subscription.id) + .then((usage) => { + const native = decode(usage.native) + if (native.base_resp.status_code !== 0) throw new Error("MiniMax application error") + return normalize(native, { + id, + providerID: "minimax", + sourceKind: "kilo_managed", + providerLabel: subscription.providerName, + planLabel: subscription.planName, + sourceLabel: "via Kilo", + managementUrl, + fetchedAt: usage.fetchedAt, + planID: subscription.planId, + routingState, + planState, + }) + }) + .catch(() => ({ + id, + providerID: "minimax", + sourceKind: "kilo_managed", + providerLabel: subscription.providerName, + planLabel: subscription.planName, + sourceLabel: "via Kilo", + fetchState: "unavailable", + planState, + routingState, + availabilityState: "unavailable", + fetchedAt, + confidence: "high", + source: "cloud", + managementUrl, + windows: [], + balances: [], + credits: [], + error: error("managed_minimax_unavailable", "Usage unavailable."), + })) +} diff --git a/packages/opencode/src/kilocode/provider-usage/index.ts b/packages/opencode/src/kilocode/provider-usage/index.ts new file mode 100644 index 00000000000..b849f8cac29 --- /dev/null +++ b/packages/opencode/src/kilocode/provider-usage/index.ts @@ -0,0 +1,261 @@ +import { Context, Effect, Layer, Schema } from "effect" +import * as Auth from "@/auth" +import { InstanceState } from "@/effect/instance-state" +import * as Provider from "@/provider/provider" +import * as Cloud from "./cloud" +import { direct } from "@/kilocode/provider/minimax/usage" +import type { Info, KiloBilling, UsageSnapshot } from "./schema" + +const successTtl = 60_000 +const errorTtl = 10_000 + +export interface AdapterContext { + providers: Record + auth: Auth.Info | undefined + cloud: (() => Promise) | undefined + token: string | undefined + fetch: typeof fetch + source(id: string, load: () => Promise): Promise + preserve(prefix: string): UsageSnapshot[] + prune(prefix: string, keep: string[]): void +} + +interface AdapterResult { + items: ReadonlyArray + kiloBilling?: KiloBilling +} + +export interface ProviderUsageAdapter { + id: string + providerIDs: readonly string[] + cachePrefixes: readonly string[] + run(ctx: AdapterContext): Promise +} + +const pass: ProviderUsageAdapter = { + id: "kilo-pass", + providerIDs: ["kilo"], + cachePrefixes: ["kilo-pass"], + async run(ctx) { + if (!ctx.cloud) return { items: [] } + const state = await ctx.cloud() + if (!state.pass.ok) return { items: ctx.preserve("kilo-pass"), kiloBilling: Cloud.billing(state) } + const detected = Cloud.pass(state) + ctx.prune( + "kilo-pass", + detected.map((item) => item.id), + ) + return { + items: await Promise.all(detected.map((item) => ctx.source(item.id, async () => item))), + kiloBilling: Cloud.billing(state), + } + }, +} + +const managed: ProviderUsageAdapter = { + id: "kilo-managed-minimax", + providerIDs: ["kilo", "minimax"], + cachePrefixes: ["kilo-managed-minimax:"], + async run(ctx) { + if (!ctx.cloud || !ctx.token) return { items: [] } + const state = await ctx.cloud() + if (!state.plans.ok) return { items: ctx.preserve("kilo-managed-minimax:") } + const token = ctx.token + const detected = Cloud.plans(state) + const ids = detected.map((subscription) => `kilo-managed-minimax:${subscription.id}`) + ctx.prune("kilo-managed-minimax:", ids) + return { + items: await Promise.all( + detected.map((subscription) => + ctx.source(`kilo-managed-minimax:${subscription.id}`, () => Cloud.managed(token, subscription, state.byok)), + ), + ), + } + }, +} + +const minimax: ProviderUsageAdapter = { + id: "direct-minimax", + providerIDs: ["minimax-coding-plan", "minimax-cn-coding-plan"], + cachePrefixes: ["minimax-direct-"], + async run(ctx) { + const items = await direct(ctx.providers, ctx.fetch, ctx.source) + ctx.prune( + "minimax-direct-", + items.map((item) => item.id), + ) + return { items } + }, +} + +export const registry: readonly ProviderUsageAdapter[] = [pass, managed, minimax] + +export class ServiceError extends Schema.TaggedErrorClass()("ProviderUsageServiceError", { + message: Schema.String, +}) {} + +interface SourceCell { + value?: UsageSnapshot + expires: number + updatedAt?: string + inflight?: Promise +} + +interface CloudCell { + value?: Cloud.CloudState + expires: number + updatedAt?: string + inflight?: Promise +} + +interface State { + sources: Map + cloud: CloudCell +} + +function stale(next: UsageSnapshot, previous: 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) { + const cell = state.sources.get(id) ?? { expires: 0 } + 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) + 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) { + const items: UsageSnapshot[] = [] + for (const [id, cell] of state.sources) { + if (!id.startsWith(prefix) || !cell.value) continue + const value = { + ...cell.value, + fetchState: "stale" as const, + error: { + code: "source_refresh_unavailable", + message: "The latest usage could not be loaded.", + retryable: true, + }, + } + 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, force: boolean) { + 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) + .then((value) => { + const failed = Object.values(value).some((result) => !result.ok) + 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 const layer = Layer.effect( + Service, + Effect.gen(function* () { + const auth = yield* Auth.Service + const provider = yield* Provider.Service + const state = yield* InstanceState.make(() => Effect.succeed({ sources: new Map(), cloud: { expires: 0 } })) + + const evaluate = Effect.fn("ProviderUsage.evaluate")(function* (current: State, force: boolean) { + const info = yield* auth + .get("kilo") + .pipe(Effect.mapError(() => new ServiceError({ message: "Unable to read provider authentication." }))) + const providers = yield* provider.list() + const token = info?.type === "oauth" && !info.accountId && info.access ? info.access : undefined + const ctx: AdapterContext = { + providers, + auth: info, + cloud: token ? () => cloud(current, token, force) : undefined, + token, + fetch, + source: (id, load) => source(current, id, force, load), + preserve: (prefix) => preserve(current, prefix), + prune: (prefix, keep) => prune(current, prefix, keep), + } + const results = yield* Effect.promise(() => + Promise.all( + registry.map((adapter) => + adapter + .run(ctx) + .catch((): AdapterResult => ({ items: adapter.cachePrefixes.flatMap((prefix) => ctx.preserve(prefix)) })), + ), + ), + ) + const kiloBilling = results.find((result) => result.kiloBilling)?.kiloBilling + const stamps = [current.cloud.updatedAt, ...[...current.sources.values()].map((cell) => cell.updatedAt)].filter( + (value): value is string => value !== undefined, + ) + return { + items: results.flatMap((result) => result.items), + ...(kiloBilling ? { kiloBilling } : {}), + generatedAt: stamps.toSorted().at(-1) ?? new Date().toISOString(), + } satisfies Info + }) + + const run = (force: boolean) => InstanceState.useEffect(state, (current) => evaluate(current, force)) + + return Service.of({ + get: () => run(false), + refresh: () => run(true), + }) + }), +) + +export const defaultLayer = layer + +export * from "./schema" +export * as ProviderUsage from "." diff --git a/packages/opencode/src/kilocode/provider-usage/schema.ts b/packages/opencode/src/kilocode/provider-usage/schema.ts new file mode 100644 index 00000000000..f168f191979 --- /dev/null +++ b/packages/opencode/src/kilocode/provider-usage/schema.ts @@ -0,0 +1,94 @@ +import { Schema } from "effect" + +export const UsageError = Schema.Struct({ + code: Schema.String, + message: Schema.String, + retryable: Schema.Boolean, +}).annotate({ identifier: "ProviderUsageError" }) +export type UsageError = typeof UsageError.Type + +export const UsageWindow = Schema.Struct({ + id: Schema.String, + label: Schema.String, + resource: Schema.String, + kind: Schema.Literals(["quota", "spend_control"]), + unit: Schema.String, + orientation: Schema.Literals(["used_percent", "remaining_percent", "amount", "count"]), + used: Schema.optional(Schema.Finite), + remaining: Schema.optional(Schema.Finite), + limit: Schema.optional(Schema.Finite), + durationMs: Schema.optional(Schema.Finite), + resetAt: Schema.optional(Schema.String), + state: Schema.Literals(["active", "exhausted", "unlimited", "not_in_plan", "unknown"]), +}).annotate({ identifier: "ProviderUsageWindow" }) +export type UsageWindow = typeof UsageWindow.Type + +export const UsageBalance = Schema.Struct({ + id: Schema.String, + label: Schema.String, + currency: Schema.String, + unit: Schema.String, + total: Schema.String, + granted: Schema.optional(Schema.String), + toppedUp: Schema.optional(Schema.String), + available: Schema.optional(Schema.Boolean), +}).annotate({ identifier: "ProviderUsageBalance" }) +export type UsageBalance = typeof UsageBalance.Type + +export const UsageCredit = Schema.Struct({ + id: Schema.String, + label: Schema.String, + balance: Schema.optional(Schema.String), + unit: Schema.optional(Schema.String), + unlimited: Schema.optional(Schema.Boolean), + availableResets: Schema.optional(Schema.Finite), +}).annotate({ identifier: "ProviderUsageCredit" }) +export type UsageCredit = typeof UsageCredit.Type + +export const UsageSnapshot = Schema.Struct({ + id: Schema.String, + providerID: Schema.String, + sourceKind: Schema.Literals(["kilo_pass", "kilo_managed", "direct", "codex"]), + providerLabel: Schema.String, + planLabel: Schema.String, + sourceLabel: Schema.String, + accountLabel: Schema.optional(Schema.String), + fetchState: Schema.Literals(["ready", "stale", "unavailable", "error"]), + planState: Schema.Literals(["active", "past_due", "canceling", "unknown"]), + routingState: Schema.Literals(["active", "disabled", "missing", "replaced", "not_applicable", "unknown"]), + availabilityState: Schema.Literals(["available", "exhausted", "unavailable", "unlimited", "unknown"]), + fetchedAt: Schema.optional(Schema.String), + confidence: Schema.Literals(["high", "medium", "low"]), + source: Schema.Literals(["cloud", "provider_api", "provider_backend"]), + managementUrl: Schema.optional(Schema.String), + windows: Schema.Array(UsageWindow), + balances: Schema.Array(UsageBalance), + credits: Schema.Array(UsageCredit), + error: Schema.optional(UsageError), +}).annotate({ identifier: "ProviderUsageSnapshot" }) +export type UsageSnapshot = typeof UsageSnapshot.Type + +export const AutoTopUp = Schema.Struct({ + enabled: Schema.Boolean, + amountCents: Schema.Finite, + thresholdCents: Schema.Finite, + paymentType: Schema.optional(Schema.String), + paymentBrand: Schema.optional(Schema.String), + paymentLast4: Schema.optional(Schema.String), +}).annotate({ identifier: "ProviderUsageAutoTopUp" }) +export type AutoTopUp = typeof AutoTopUp.Type + +export const KiloBilling = Schema.Struct({ + topUpUrl: Schema.String, + manageUrl: Schema.String, + autoTopUp: Schema.optional(AutoTopUp), + error: Schema.optional(UsageError), +}).annotate({ identifier: "ProviderUsageKiloBilling" }) +export type KiloBilling = typeof KiloBilling.Type + +export const Info = Schema.Struct({ + items: Schema.Array(UsageSnapshot), + kiloBilling: Schema.optional(KiloBilling), + generatedAt: Schema.String, +}).annotate({ identifier: "ProviderUsage" }) +export type Info = typeof Info.Type diff --git a/packages/opencode/src/kilocode/provider/minimax/native.ts b/packages/opencode/src/kilocode/provider/minimax/native.ts new file mode 100644 index 00000000000..8193adf54cd --- /dev/null +++ b/packages/opencode/src/kilocode/provider/minimax/native.ts @@ -0,0 +1,35 @@ +import { Schema } from "effect" + +const NumberField = Schema.Finite +const IntegerField = Schema.Int + +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_permill: Schema.optional(IntegerField), + interval_boost_permille: Schema.optional(IntegerField), + current_interval_remaining_percent: Schema.optional(NumberField), + 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_permill: Schema.optional(IntegerField), + weekly_boost_permille: Schema.optional(IntegerField), + current_weekly_remaining_percent: Schema.optional(NumberField), + 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/opencode/src/kilocode/provider/minimax/usage.ts b/packages/opencode/src/kilocode/provider/minimax/usage.ts new file mode 100644 index 00000000000..e16beb31599 --- /dev/null +++ b/packages/opencode/src/kilocode/provider/minimax/usage.ts @@ -0,0 +1,324 @@ +import { decode, type ModelRemains, type Native } from "./native" +import type { UsageSnapshot, UsageWindow } from "@/kilocode/provider-usage/schema" +import type { Info as ProviderInfo } from "@/provider/provider" + +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 + +export type ProviderID = keyof typeof bindings + +const timeout = 5_000 +const limit = 64 * 1024 + +export 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 +} + +function label(resource: string, kind: "interval" | "weekly", value: number | undefined) { + const prefix = resource === "general" ? "Shared quota" : resource === "video" ? "Video" : resource + if (value === 300 * 60 * 1000) return `${prefix} 5-hour` + if (value === 10_080 * 60 * 1000) return `${prefix} weekly` + return kind === "weekly" ? `${prefix} weekly` : `${prefix} interval` +} + +function window( + row: ModelRemains, + kind: "interval" | "weekly", + fetchedAt: string, +): { value: UsageWindow; confidence: "high" | "medium" | "low" } | 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.weekly_boost_permill) + : (row.interval_boost_permille ?? row.interval_boost_permill) + const span = duration(start, end) + const base = { + id: `${row.model_name}-${kind}`, + label: label(row.model_name, kind, span), + resource: row.model_name, + kind: "quota" as const, + durationMs: span, + resetAt: reset(end, remains, fetchedAt), + } + + if (status === 3) { + return { + value: { ...base, unit: "unknown", orientation: "amount", state: "not_in_plan" }, + confidence: "high", + } + } + + if (percent !== undefined) { + const factor = boost !== undefined && boost > 0 ? boost / 1000 : 1 + const cap = 100 * factor + const remaining = percent * factor + return { + value: { + ...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", + }, + confidence: "high", + } + } + + if (total !== undefined && total > 0 && count !== undefined && count >= 0) { + return { + value: { + ...base, + unit: "count", + orientation: "count", + used: Math.max(0, total - count), + remaining: count, + limit: total, + state: status === 2 || count === 0 ? "exhausted" : "active", + }, + confidence: "medium", + } + } + + if (status === undefined && total === undefined && count === undefined) return undefined + return { + value: { + ...base, + unit: "unknown", + orientation: "amount", + state: status === 2 ? "exhausted" : "unknown", + }, + confidence: "low", + } +} + +export function normalize( + native: Native, + input: { + id: string + providerID: string + sourceKind: "kilo_managed" | "direct" + providerLabel: string + planLabel: string + sourceLabel: string + managementUrl: string + fetchedAt: string + planID?: string + routingState?: UsageSnapshot["routingState"] + planState?: UsageSnapshot["planState"] + }, +): UsageSnapshot { + const rows = native.model_remains.flatMap((row) => + (["interval", "weekly"] as const).flatMap((kind) => { + const value = window(row, kind, input.fetchedAt) + return value ? [value] : [] + }), + ) + const windows = rows.map((row) => row.value) + const availabilityState = windows.some((item) => item.state === "active" || item.state === "unlimited") + ? "available" + : windows.some((item) => item.state === "exhausted") + ? "exhausted" + : "unknown" + const confidence = rows.some((row) => row.confidence === "low") + ? "low" + : rows.some((row) => row.confidence === "medium") + ? "medium" + : "high" + + return { + id: input.id, + providerID: input.providerID, + sourceKind: input.sourceKind, + providerLabel: input.providerLabel, + planLabel: input.planLabel, + sourceLabel: input.sourceLabel, + fetchState: "ready", + planState: input.planState ?? "active", + routingState: input.routingState ?? "not_applicable", + availabilityState, + fetchedAt: input.fetchedAt, + confidence, + source: input.sourceKind === "kilo_managed" ? "cloud" : "provider_api", + managementUrl: input.managementUrl, + windows, + balances: [], + credits: [], + } +} + +const unavailable = (id: string, providerID: string, label: string, managementUrl: string): UsageSnapshot => ({ + id, + providerID, + sourceKind: "direct", + providerLabel: "MiniMax", + planLabel: "MiniMax Token Plan", + sourceLabel: label, + fetchState: "unavailable", + planState: "unknown", + routingState: "not_applicable", + availabilityState: "unavailable", + confidence: "high", + source: "provider_api", + managementUrl, + windows: [], + balances: [], + credits: [], + error: { code: "direct_minimax_unavailable", message: "Usage unavailable.", retryable: true }, +}) + +export async function direct( + providers: Record, + fetcher: typeof fetch = fetch, + cached: (id: string, load: () => Promise) => Promise = (_id, load) => load(), +) { + const candidates = (Object.keys(bindings) as ProviderID[]).flatMap((providerID) => { + const provider = providers[providerID] + if (!provider) return [] + const value = provider.options.apiKey !== undefined ? provider.options.apiKey : provider.key + if (typeof value !== "string" || !value.trim().startsWith("sk-cp")) return [] + return [{ providerID, provider, key: value.trim() }] + }) + const groups = new Map() + for (const candidate of candidates) { + const group = groups.get(candidate.key) ?? [] + group.push(candidate) + groups.set(candidate.key, group) + } + + return Promise.all( + [...groups.values()].map(async (group) => { + const shared = group.length > 1 + const first = group[0] + const id = shared ? "minimax-direct-shared" : `minimax-direct-${bindings[first.providerID].region}` + return cached(id, async () => { + const responses = await Promise.allSettled( + group.map((candidate) => query(candidate.providerID, candidate.key, fetcher)), + ) + const index = responses.findIndex((response) => response.status === "fulfilled") + if (index === -1) { + return unavailable( + id, + first.providerID, + shared ? "Direct MiniMax" : first.provider.name, + bindings[first.providerID].manage, + ) + } + + const candidate = group[index] + const response = responses[index] + if (response.status !== "fulfilled") { + return unavailable(id, candidate.providerID, "Direct MiniMax", bindings[candidate.providerID].manage) + } + return normalize(response.value, { + id, + providerID: candidate.providerID, + sourceKind: "direct", + providerLabel: "MiniMax", + planLabel: "MiniMax Token Plan", + sourceLabel: candidate.provider.name, + managementUrl: bindings[candidate.providerID].manage, + fetchedAt: new Date().toISOString(), + }) + }) + }), + ) +} diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts index 20c13ae84b9..03255bd3014 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts @@ -7,6 +7,7 @@ import { WorkspaceRoutingQuery, } from "@/server/routes/instance/httpapi/middleware/workspace-routing" import { described } from "@/server/routes/instance/httpapi/groups/metadata" +import { Info as ProviderUsageInfo } from "@/kilocode/provider-usage/schema" const root = "/kilocode" @@ -22,6 +23,8 @@ export const KilocodePaths = { heapSnapshot: `${root}/heap/snapshot`, removeSkill: `${root}/skill/remove`, removeAgent: `${root}/agent/remove`, + providerUsage: `${root}/provider-usage`, + providerUsageRefresh: `${root}/provider-usage/refresh`, } as const export const KilocodeApi = HttpApi.make("kilocode") @@ -64,6 +67,28 @@ export const KilocodeApi = HttpApi.make("kilocode") "Remove a custom (non-native) agent by deleting its markdown file from disk and refreshing state.", }), ), + HttpApiEndpoint.get("providerUsage", KilocodePaths.providerUsage, { + query: WorkspaceRoutingQuery, + success: described(ProviderUsageInfo, "Current provider usage"), + error: HttpApiError.ServiceUnavailable, + }).annotateMerge( + OpenApi.annotations({ + identifier: "kilocode.providerUsage.get", + summary: "Get provider usage", + description: "Get cache-aware, secret-free provider plan usage and personal billing status.", + }), + ), + HttpApiEndpoint.post("providerUsageRefresh", KilocodePaths.providerUsageRefresh, { + query: WorkspaceRoutingQuery, + success: described(ProviderUsageInfo, "Refreshed provider usage"), + error: HttpApiError.ServiceUnavailable, + }).annotateMerge( + OpenApi.annotations({ + identifier: "kilocode.providerUsage.refresh", + summary: "Refresh provider usage", + description: "Refresh provider plan usage while coalescing concurrent source requests.", + }), + ), ) .annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts index 3eda47145b7..8c7111e0812 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts @@ -1,14 +1,16 @@ import { Effect } from "effect" -import { HttpApiBuilder } from "effect/unstable/httpapi" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import * as KiloAgent from "@/kilocode/agent" import { EffectBridge } from "@/effect/bridge" import { HeapSnapshot } from "@/kilocode/cli/heap-snapshot" import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api" import { Skill } from "@/skill" import { RemoveAgentPayload, RemoveSkillPayload } from "../groups/kilocode" +import { ProviderUsage } from "@/kilocode/provider-usage" export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode", (handlers) => Effect.gen(function* () { + const usage = yield* ProviderUsage.Service const heapSnapshot = Effect.fn("KilocodeHttpApi.heapSnapshot")(function* () { return yield* Effect.sync(() => HeapSnapshot.write()) }) @@ -27,9 +29,19 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" return true }) + const providerUsage = Effect.fn("KilocodeHttpApi.providerUsage")(function* () { + return yield* usage.get().pipe(Effect.mapError(() => new HttpApiError.ServiceUnavailable({}))) + }) + + const providerUsageRefresh = Effect.fn("KilocodeHttpApi.providerUsageRefresh")(function* () { + return yield* usage.refresh().pipe(Effect.mapError(() => new HttpApiError.ServiceUnavailable({}))) + }) + return handlers .handle("heapSnapshot", heapSnapshot) .handle("removeSkill", removeSkill) .handle("removeAgent", removeAgent) + .handle("providerUsage", providerUsage) + .handle("providerUsageRefresh", providerUsageRefresh) }), ) diff --git a/packages/opencode/src/kilocode/server/httpapi/server.ts b/packages/opencode/src/kilocode/server/httpapi/server.ts index e8e1e97879c..acec8a90b39 100644 --- a/packages/opencode/src/kilocode/server/httpapi/server.ts +++ b/packages/opencode/src/kilocode/server/httpapi/server.ts @@ -8,6 +8,7 @@ import { enhancePromptHandlers } from "./handlers/enhance-prompt" import { indexingHandlers } from "./handlers/indexing" import { kiloGatewayHandlers } from "./handlers/kilo-gateway" import { kilocodeHandlers } from "./handlers/kilocode" +import { ProviderUsage } from "@/kilocode/provider-usage" import { networkHandlers } from "./handlers/network" import { remoteHandlers } from "./handlers/remote" import { sessionImportHandlers } from "./handlers/session-import" @@ -22,7 +23,7 @@ export const provide = Layer.provide([ enhancePromptHandlers, indexingHandlers, kiloGatewayHandlers, - kilocodeHandlers, + kilocodeHandlers.pipe(Layer.provide(ProviderUsage.defaultLayer)), networkHandlers, remoteHandlers, sessionImportHandlers, diff --git a/packages/opencode/test/kilocode/provider-usage/service.test.ts b/packages/opencode/test/kilocode/provider-usage/service.test.ts new file mode 100644 index 00000000000..5aae6770b8e --- /dev/null +++ b/packages/opencode/test/kilocode/provider-usage/service.test.ts @@ -0,0 +1,356 @@ +import { expect } from "bun:test" +import { Deferred, Effect, Fiber, Layer } from "effect" +import { Auth } from "@/auth" +import { ProviderUsage } from "@/kilocode/provider-usage" +import { Provider } from "@/provider/provider" +import { ProviderID } from "@/provider/schema" +import { ProviderTest } from "../../fake/provider" +import { testEffect } from "../../lib/effect" + +const info = (id: "minimax-coding-plan" | "minimax-cn-coding-plan", key: string) => { + const providerID = ProviderID.make(id) + return ProviderTest.info( + { + id: providerID, + name: id === "minimax-coding-plan" ? "MiniMax Global" : "MiniMax China", + key, + options: {}, + }, + ProviderTest.model({ providerID }), + ) +} + +function layer(auth: Auth.Info | undefined, providers: Record) { + const access = Layer.mock(Auth.Service)({ get: () => Effect.succeed(auth) }) + const catalog = Layer.mock(Provider.Service)({ list: () => Effect.succeed(providers) }) + return Layer.fresh(ProviderUsage.defaultLayer).pipe(Layer.provide(access), Layer.provide(catalog)) +} + +const it = testEffect(Layer.empty) + +const native = (remaining = 80) => + Response.json({ + base_resp: { status_code: 0 }, + model_remains: [ + { + model_name: "general", + current_interval_remaining_percent: remaining, + current_interval_status: 1, + }, + ], + }) + +it.instance("returns empty usage when no source is connected", () => + Effect.gen(function* () { + const usage = yield* ProviderUsage.Service + const result = yield* usage.get() + expect(result.items).toEqual([]) + expect(result.kiloBilling).toBeUndefined() + }).pipe(Effect.provide(layer(undefined, {}))), +) + +it.instance("caches normal reads and forces an explicit refresh", () => + Effect.gen(function* () { + const original = global.fetch + let calls = 0 + global.fetch = (() => { + calls++ + return Promise.resolve(native(100 - calls)) + }) as unknown as typeof fetch + + const result = yield* Effect.gen(function* () { + const usage = yield* ProviderUsage.Service + const first = yield* usage.get() + const cached = yield* usage.get() + const refreshed = yield* usage.refresh() + return { first, cached, refreshed } + }).pipe(Effect.provide(layer(undefined, { "minimax-coding-plan": info("minimax-coding-plan", "sk-cp-one") }))) + global.fetch = original + + expect(calls).toBe(2) + expect(result.cached).toEqual(result.first) + expect(result.refreshed.items[0]?.windows[0]?.remaining).toBe(98) + }), +) + +it.instance("coalesces a forced refresh with an in-flight read", () => + Effect.gen(function* () { + const original = global.fetch + const started = yield* Deferred.make() + const release = yield* Deferred.make() + let calls = 0 + global.fetch = (() => { + calls++ + return Effect.runPromise( + Effect.gen(function* () { + yield* Deferred.succeed(started, undefined) + yield* Deferred.await(release) + return native() + }), + ) + }) as unknown as typeof fetch + + const output = yield* Effect.gen(function* () { + const usage = yield* 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 + yield* Deferred.succeed(release, undefined) + return [yield* Fiber.join(first), yield* Fiber.join(second)] + }).pipe(Effect.provide(layer(undefined, { "minimax-coding-plan": info("minimax-coding-plan", "sk-cp-one") }))) + global.fetch = original + + expect(calls).toBe(1) + expect(output[1]).toEqual(output[0]) + }), +) + +it.instance("preserves the last success as stale after a provider failure", () => + Effect.gen(function* () { + const original = global.fetch + let calls = 0 + global.fetch = (() => + Promise.resolve( + ++calls === 1 ? native() : new Response("private body", { status: 500 }), + )) as unknown as typeof fetch + + const output = yield* Effect.gen(function* () { + const usage = yield* ProviderUsage.Service + const first = yield* usage.get() + const stale = yield* usage.refresh() + return { first, stale } + }).pipe(Effect.provide(layer(undefined, { "minimax-coding-plan": info("minimax-coding-plan", "sk-cp-one") }))) + global.fetch = original + + expect(output.first.items[0]?.fetchState).toBe("ready") + expect(output.stale.items[0]).toMatchObject({ + fetchState: "stale", + error: { code: "direct_minimax_unavailable" }, + }) + expect(output.stale.items[0]?.windows).toEqual(output.first.items[0]?.windows) + expect(JSON.stringify(output.stale)).not.toContain("private body") + }), +) + +it.instance("removes sources that disappear successfully instead of resurrecting them as stale", () => + Effect.gen(function* () { + const original = global.fetch + global.fetch = (() => Promise.resolve(native())) as unknown as typeof fetch + let enabled = true + const access = Layer.mock(Auth.Service)({ get: () => Effect.succeed(undefined) }) + const catalog = Layer.mock(Provider.Service)({ + list: () => Effect.succeed(enabled ? { "minimax-coding-plan": info("minimax-coding-plan", "sk-cp-one") } : {}), + }) + const usageLayer = Layer.fresh(ProviderUsage.defaultLayer).pipe(Layer.provide(access), Layer.provide(catalog)) + + const output = yield* Effect.gen(function* () { + const usage = yield* ProviderUsage.Service + const first = yield* usage.get() + enabled = false + const refreshed = yield* usage.refresh() + return { first, refreshed } + }).pipe(Effect.provide(usageLayer)) + global.fetch = original + + expect(output.first.items).toHaveLength(1) + expect(output.refreshed.items).toEqual([]) + }), +) + +it.instance("keeps direct provider failures independent", () => + Effect.gen(function* () { + const original = global.fetch + global.fetch = ((url: string | URL | Request) => + Promise.resolve( + String(url).includes("api.minimax.io") ? new Response("failed", { status: 500 }) : native(), + )) as unknown as typeof fetch + + const result = yield* ProviderUsage.Service.use((usage) => usage.get()).pipe( + Effect.provide( + layer(undefined, { + "minimax-coding-plan": info("minimax-coding-plan", "sk-cp-global"), + "minimax-cn-coding-plan": info("minimax-cn-coding-plan", "sk-cp-china"), + }), + ), + ) + global.fetch = original + + expect(result.items.map((item) => item.fetchState)).toEqual(["unavailable", "ready"]) + }), +) + +it.instance("loads each personal Cloud procedure once and isolates managed enrichment", () => + Effect.gen(function* () { + const original = global.fetch + const calls: string[] = [] + const ok = (value: unknown) => Response.json({ result: { data: { json: value } } }) + global.fetch = ((input: string | URL | Request) => { + const procedure = new URL(String(input)).pathname.split("/").at(-1) ?? "" + calls.push(procedure) + const values: Record = { + "kiloPass.getState": { + subscription: { + subscriptionId: "pass", + tier: "tier_49", + cadence: "monthly", + status: "active", + cancelAtPeriodEnd: false, + currentStreakMonths: 2, + nextYearlyIssueAt: null, + startedAt: "2026-06-01T00:00:00.000Z", + resumesAt: null, + nextBonusCreditsUsd: 5, + nextBillingAt: "2026-07-01T00:00:00.000Z", + currentPeriodBaseCreditsUsd: 49, + currentPeriodUsageUsd: 12, + currentPeriodHostingCostUsd: 2, + currentPeriodBonusCreditsUsd: 5, + isBonusUnlocked: true, + refillAt: "2026-07-01T00:00:00.000Z", + }, + }, + "user.getAutoTopUpPaymentMethod": { + enabled: true, + amountCents: 5000, + thresholdCents: 500, + paymentMethod: { + type: "card", + brand: "visa", + last4: "4242", + stripePaymentMethodId: "pm_private", + }, + }, + "codingPlans.listSubscriptions": [ + { + id: "plan", + planId: "minimax-token-plan-plus", + planName: "Token Plan Plus", + providerName: "MiniMax", + providerId: "minimax", + routeLabel: "MiniMax via Kilo Gateway", + hasInstalledByokKey: false, + status: "active", + billingPeriodDays: 30, + currentPeriodStart: "2026-06-01T00:00:00.000Z", + currentPeriodEnd: "2026-07-01T00:00:00.000Z", + creditRenewalAt: "2026-07-01T00:00:00.000Z", + cancelAtPeriodEnd: false, + paymentGraceExpiresAt: null, + canceledAt: null, + cancellationReason: null, + createdAt: "2026-06-01T00:00:00.000Z", + costKiloCredits: 20, + }, + ], + "byok.list": [], + "codingPlans.getUsage": { + subscriptionId: "plan", + providerId: "minimax", + region: "global", + fetchedAt: "2026-06-19T00:00:00.000Z", + native: { + base_resp: { status_code: 0 }, + model_remains: [{ model_name: "general", current_interval_remaining_percent: 80 }], + }, + }, + } + return Promise.resolve(ok(values[procedure])) + }) as unknown as typeof fetch + + const result = yield* ProviderUsage.Service.use((usage) => usage.get()).pipe( + Effect.provide( + layer( + { + type: "oauth", + access: "kilo-private-token", + refresh: "refresh-private-token", + expires: Date.now() + 60_000, + }, + {}, + ), + ), + ) + global.fetch = original + + expect(calls).toEqual([ + "kiloPass.getState", + "user.getAutoTopUpPaymentMethod", + "codingPlans.listSubscriptions", + "byok.list", + "codingPlans.getUsage", + ]) + expect(result.items.map((item) => item.id)).toEqual(["kilo-pass", "kilo-managed-minimax:plan"]) + expect(result.items[1]).toMatchObject({ routingState: "missing", fetchState: "ready" }) + expect(result.kiloBilling?.autoTopUp).toMatchObject({ paymentBrand: "visa", paymentLast4: "4242" }) + expect(JSON.stringify(result)).not.toContain("pm_private") + expect(JSON.stringify(result)).not.toContain("kilo-private-token") + }), +) + +it.instance("retries failed sources without re-querying successful siblings", () => + Effect.gen(function* () { + const original = global.fetch + const originalNow = Date.now + const calls = { global: 0, china: 0 } + let now = 1_000_000 + Date.now = () => now + global.fetch = ((url: string | URL | Request) => { + if (String(url).includes("api.minimax.io")) { + calls.global++ + return Promise.resolve(new Response("failed", { status: 500 })) + } + calls.china++ + return Promise.resolve(native()) + }) as unknown as typeof fetch + + yield* Effect.gen(function* () { + const usage = yield* ProviderUsage.Service + yield* usage.get() + now += 11_000 + yield* usage.get() + }).pipe( + Effect.provide( + layer(undefined, { + "minimax-coding-plan": info("minimax-coding-plan", "sk-cp-global"), + "minimax-cn-coding-plan": info("minimax-cn-coding-plan", "sk-cp-china"), + }), + ), + ) + global.fetch = original + Date.now = originalNow + + expect(calls).toEqual({ global: 2, china: 1 }) + }), +) + +it.instance("skips every personal Cloud procedure in organization context", () => + Effect.gen(function* () { + const original = global.fetch + let calls = 0 + global.fetch = (() => { + calls++ + return Promise.resolve(Response.json({ error: {} })) + }) as unknown as typeof fetch + + const result = yield* ProviderUsage.Service.use((usage) => usage.get()).pipe( + Effect.provide( + layer( + { + type: "oauth", + access: "kilo-token", + refresh: "refresh", + expires: Date.now() + 60_000, + accountId: "organization", + }, + {}, + ), + ), + ) + global.fetch = original + + expect(calls).toBe(0) + expect(result.items).toEqual([]) + expect(result.kiloBilling).toBeUndefined() + }), +) diff --git a/packages/opencode/test/kilocode/provider/minimax/usage.test.ts b/packages/opencode/test/kilocode/provider/minimax/usage.test.ts new file mode 100644 index 00000000000..b22b7dbf4bb --- /dev/null +++ b/packages/opencode/test/kilocode/provider/minimax/usage.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, mock, test } from "bun:test" +import { decode } from "@/kilocode/provider/minimax/native" +import { direct, normalize, query } from "@/kilocode/provider/minimax/usage" +import type { Info as ProviderInfo } from "@/provider/provider" + +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", + sourceKind: "direct" as const, + providerLabel: "MiniMax", + planLabel: "MiniMax Token Plan", + sourceLabel: "Direct", + managementUrl: "https://platform.minimax.io/subscribe/token-plan", + fetchedAt: "2026-06-19T00:00:00.000Z", +} + +const provider = (id: string, key: string): ProviderInfo => + ({ + id, + name: id.endsWith("cn-coding-plan") ? "MiniMax China" : "MiniMax Global", + source: "env", + env: ["MINIMAX_API_KEY"], + key, + options: { baseURL: "https://attacker.invalid" }, + models: { model: {} }, + }) as unknown as ProviderInfo + +const response = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) + +describe("MiniMax usage normalization", () => { + test("managed and direct native payloads normalize identically", () => { + 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) + const managed = normalize(payload, { ...options, sourceKind: "kilo_managed", sourceLabel: "via Kilo" }) + + expect(managed.windows).toEqual(direct.windows) + expect(direct.windows[0]).toMatchObject({ + orientation: "remaining_percent", + remaining: 80, + used: 20, + limit: 100, + resetAt: "2026-06-19T05:00:00.000Z", + }) + expect(direct.windows[0]?.remaining).not.toBe(1) + }) + + test("treats status 3 as out of plan for direct and managed usage", () => { + const value = decode({ + base_resp: { status_code: 0 }, + model_remains: [ + { + model_name: "video", + current_interval_total_count: 0, + current_interval_usage_count: 0, + current_interval_status: 3, + }, + { + model_name: "general", + current_interval_total_count: 0, + current_interval_usage_count: 0, + current_interval_status: 1, + }, + ], + }) + const direct = normalize(value, options) + const managed = normalize(value, { ...options, sourceKind: "kilo_managed", planID: "minimax-token-plan-plus" }) + + expect(direct.windows.find((window) => window.id === "video-interval")?.state).toBe("not_in_plan") + expect(managed.windows.find((window) => window.id === "video-interval")?.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 with medium confidence", () => { + const item = normalize( + native({ + current_interval_total_count: 1500, + current_interval_usage_count: 1200, + current_interval_status: 1, + }), + options, + ) + + expect(item.confidence).toBe("medium") + 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_permill: 1500, + }), + options, + ) + + expect(item.windows[0]).toMatchObject({ + unit: "standard_units", + orientation: "amount", + remaining: 150, + limit: 150, + }) + }) + + 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 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( + { + "minimax-coding-plan": provider("minimax-coding-plan", "sk-api-payg"), + minimax: provider("minimax", "sk-cp-not-a-coding-plan-provider"), + }, + fn as unknown as typeof fetch, + ) + + expect(items).toEqual([]) + expect(fn).not.toHaveBeenCalled() + }) + + test("deduplicates a shared credential while probing fixed regions", 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( + { + "minimax-coding-plan": provider("minimax-coding-plan", "sk-cp-shared"), + "minimax-cn-coding-plan": provider("minimax-cn-coding-plan", "sk-cp-shared"), + }, + fn as unknown as typeof fetch, + ) + + expect(fn).toHaveBeenCalledTimes(2) + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ id: "minimax-direct-shared", providerID: "minimax-cn-coding-plan" }) + expect(JSON.stringify(items)).not.toContain("sk-cp-shared") + }) + + test("returns one unavailable item when an ambiguous key fails everywhere", async () => { + const fn = mock(() => Promise.resolve(response({ message: "raw failure" }, 500))) + const items = await direct( + { + "minimax-coding-plan": provider("minimax-coding-plan", "sk-cp-shared"), + "minimax-cn-coding-plan": provider("minimax-cn-coding-plan", "sk-cp-shared"), + }, + fn as unknown as typeof fetch, + ) + + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ id: "minimax-direct-shared", fetchState: "unavailable" }) + expect(JSON.stringify(items)).not.toContain("raw failure") + }) +}) diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts index 02cde56c670..a4a4479be46 100644 --- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts +++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts @@ -257,6 +257,14 @@ export const kiloScenarios: Scenario[] = [ check(!(yield* Effect.promise(() => Bun.file(ctx.state).exists())), "removed agent should not remain on disk") }), ), + http.protected.get("/kilocode/provider-usage", "kilocode.providerUsage.get").json(200, (body) => { + object(body) + array(body.items) + }), + http.protected.post("/kilocode/provider-usage/refresh", "kilocode.providerUsage.refresh").json(200, (body) => { + object(body) + array(body.items) + }), http.protected .post("/kilocode/session-import/project", "kilocode.sessionImport.project") .mutating() diff --git a/packages/opencode/test/kilocode/server/httpapi-public.test.ts b/packages/opencode/test/kilocode/server/httpapi-public.test.ts index 64ffdc1de5b..e6d3b86987e 100644 --- a/packages/opencode/test/kilocode/server/httpapi-public.test.ts +++ b/packages/opencode/test/kilocode/server/httpapi-public.test.ts @@ -6,6 +6,7 @@ import { BackgroundProcessPaths } from "../../../src/kilocode/server/httpapi/gro import { ConfigConsolePaths } from "../../../src/kilocode/server/httpapi/groups/config-console" import { IndexingPaths, KiloEmbeddingModel } from "../../../src/kilocode/server/httpapi/groups/indexing" import { KiloGatewayPaths } from "../../../src/kilocode/server/httpapi/groups/kilo-gateway" +import { KilocodePaths } from "../../../src/kilocode/server/httpapi/groups/kilocode" import { NetworkPaths } from "../../../src/kilocode/server/httpapi/groups/network" import { TelemetryPaths } from "../../../src/kilocode/server/httpapi/groups/telemetry" import { ExperimentalPaths } from "../../../src/server/routes/instance/httpapi/groups/experimental" @@ -135,6 +136,8 @@ describe("Kilo PublicApi OpenAPI contract", () => { { method: "get", path: ConfigConsolePaths.tuiConfig }, { method: "get", path: ConfigConsolePaths.tuiKeybinds }, { method: "patch", path: ConfigConsolePaths.tuiConfig }, + { method: "get", path: KilocodePaths.providerUsage }, + { method: "post", path: KilocodePaths.providerUsageRefresh }, ] satisfies Array<{ method: Method; path: string }> for (const route of routes) { @@ -188,4 +191,35 @@ describe("Kilo PublicApi OpenAPI contract", () => { const schema = body?.content?.["application/json"]?.schema expect(schema?.properties?.prompt).toEqual({ type: "string" }) }) + + test("keeps provider usage flat and credential-free", () => { + const spec = OpenApi.fromApi(PublicApi) + const schemas = (spec.components?.schemas ?? {}) as Record + const usage = Object.fromEntries(Object.entries(schemas).filter(([name]) => name.startsWith("ProviderUsage"))) + const keys = (value: unknown): string[] => { + if (Array.isArray(value)) return value.flatMap(keys) + if (!value || typeof value !== "object") return [] + return Object.entries(value).flatMap(([key, item]) => [key, ...keys(item)]) + } + const fields = keys(usage).map((key) => key.toLowerCase()) + + expect(spec.paths[KilocodePaths.providerUsage]?.get?.responses?.["200"]).toBeDefined() + expect(spec.paths[KilocodePaths.providerUsageRefresh]?.post?.responses?.["200"]).toBeDefined() + expect(schemas.ProviderUsageBalance?.properties?.total).toEqual({ type: "string" }) + expect(schemas.ProviderUsageCredit?.properties?.balance).toEqual({ type: "string" }) + for (const forbidden of [ + "key", + "token", + "authorization", + "raw", + "endpoint", + "stripepaymentmethodid", + "inventoryid", + "upstreamplanid", + "fingerprint", + "ciphertext", + ]) { + expect(fields, forbidden).not.toContain(forbidden) + } + }) }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 3e000b1d990..3735c0ab293 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -102,6 +102,10 @@ import type { KiloCloudSessionsResponses, KilocodeHeapSnapshotErrors, KilocodeHeapSnapshotResponses, + KilocodeProviderUsageGetErrors, + KilocodeProviderUsageGetResponses, + KilocodeProviderUsageRefreshErrors, + KilocodeProviderUsageRefreshResponses, KilocodeRemoveAgentErrors, KilocodeRemoveAgentResponses, KilocodeRemoveSkillErrors, @@ -6781,6 +6785,76 @@ export class Heap extends HeyApiClient { } } +export class ProviderUsage extends HeyApiClient { + /** + * Get provider usage + * + * Get cache-aware, secret-free provider plan usage and personal billing status. + */ + public get( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + KilocodeProviderUsageGetResponses, + KilocodeProviderUsageGetErrors, + ThrowOnError + >({ + url: "/kilocode/provider-usage", + ...options, + ...params, + }) + } + + /** + * Refresh provider usage + * + * Refresh provider plan usage while coalescing concurrent source requests. + */ + public refresh( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + KilocodeProviderUsageRefreshResponses, + KilocodeProviderUsageRefreshErrors, + ThrowOnError + >({ + url: "/kilocode/provider-usage/refresh", + ...options, + ...params, + }) + } +} + export class SessionImport extends HeyApiClient { /** * Insert project for session import @@ -7249,6 +7323,11 @@ export class Kilocode extends HeyApiClient { return (this._heap ??= new Heap({ client: this.client })) } + private _providerUsage?: ProviderUsage + get providerUsage(): ProviderUsage { + return (this._providerUsage ??= new ProviderUsage({ client: this.client })) + } + private _sessionImport?: SessionImport get sessionImport(): SessionImport { return (this._sessionImport ??= new SessionImport({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index af264d90b77..581516883f2 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2213,6 +2213,91 @@ export type EffectHttpApiErrorServiceUnavailable = { _tag: "ServiceUnavailable" } +export type ProviderUsageWindow = { + id: string + label: string + resource: string + kind: "quota" | "spend_control" + unit: string + orientation: "used_percent" | "remaining_percent" | "amount" | "count" + used?: number + remaining?: number + limit?: number + durationMs?: number + resetAt?: string + state: "active" | "exhausted" | "unlimited" | "not_in_plan" | "unknown" +} + +export type ProviderUsageBalance = { + id: string + label: string + currency: string + unit: string + total: string + granted?: string + toppedUp?: string + available?: boolean +} + +export type ProviderUsageCredit = { + id: string + label: string + balance?: string + unit?: string + unlimited?: boolean + availableResets?: number +} + +export type ProviderUsageError = { + code: string + message: string + retryable: boolean +} + +export type ProviderUsageSnapshot = { + id: string + providerID: string + sourceKind: "kilo_pass" | "kilo_managed" | "direct" | "codex" + providerLabel: string + planLabel: string + sourceLabel: string + accountLabel?: string + fetchState: "ready" | "stale" | "unavailable" | "error" + planState: "active" | "past_due" | "canceling" | "unknown" + routingState: "active" | "disabled" | "missing" | "replaced" | "not_applicable" | "unknown" + availabilityState: "available" | "exhausted" | "unavailable" | "unlimited" | "unknown" + fetchedAt?: string + confidence: "high" | "medium" | "low" + source: "cloud" | "provider_api" | "provider_backend" + managementUrl?: string + windows: Array + balances: Array + credits: Array + error?: ProviderUsageError +} + +export type ProviderUsageAutoTopUp = { + enabled: boolean + amountCents: number + thresholdCents: number + paymentType?: string + paymentBrand?: string + paymentLast4?: string +} + +export type ProviderUsageKiloBilling = { + topUpUrl: string + manageUrl: string + autoTopUp?: ProviderUsageAutoTopUp + error?: ProviderUsageError +} + +export type ProviderUsage = { + items: Array + kiloBilling?: ProviderUsageKiloBilling + generatedAt: string +} + export type KilocodeSessionImportResult = { ok: boolean id: string @@ -8817,6 +8902,65 @@ export type KilocodeRemoveAgentResponses = { export type KilocodeRemoveAgentResponse = KilocodeRemoveAgentResponses[keyof KilocodeRemoveAgentResponses] +export type KilocodeProviderUsageGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/kilocode/provider-usage" +} + +export type KilocodeProviderUsageGetErrors = { + /** + * ServiceUnavailable + */ + 503: EffectHttpApiErrorServiceUnavailable +} + +export type KilocodeProviderUsageGetError = KilocodeProviderUsageGetErrors[keyof KilocodeProviderUsageGetErrors] + +export type KilocodeProviderUsageGetResponses = { + /** + * Current provider usage + */ + 200: ProviderUsage +} + +export type KilocodeProviderUsageGetResponse = + KilocodeProviderUsageGetResponses[keyof KilocodeProviderUsageGetResponses] + +export type KilocodeProviderUsageRefreshData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/kilocode/provider-usage/refresh" +} + +export type KilocodeProviderUsageRefreshErrors = { + /** + * ServiceUnavailable + */ + 503: EffectHttpApiErrorServiceUnavailable +} + +export type KilocodeProviderUsageRefreshError = + KilocodeProviderUsageRefreshErrors[keyof KilocodeProviderUsageRefreshErrors] + +export type KilocodeProviderUsageRefreshResponses = { + /** + * Refreshed provider usage + */ + 200: ProviderUsage +} + +export type KilocodeProviderUsageRefreshResponse = + KilocodeProviderUsageRefreshResponses[keyof KilocodeProviderUsageRefreshResponses] + export type NetworkListData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 4d66ec8d8ba..ae732c6f998 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -12853,6 +12853,114 @@ ] } }, + "/kilocode/provider-usage": { + "get": { + "tags": ["kilocode"], + "operationId": "kilocode.providerUsage.get", + "parameters": [ + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "Current provider usage", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderUsage" + } + } + } + }, + "503": { + "description": "ServiceUnavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiError_ServiceUnavailable" + } + } + } + } + }, + "description": "Get cache-aware, secret-free provider plan usage and personal billing status.", + "summary": "Get provider usage", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.kilocode.providerUsage.get({\n ...\n})" + } + ] + } + }, + "/kilocode/provider-usage/refresh": { + "post": { + "tags": ["kilocode"], + "operationId": "kilocode.providerUsage.refresh", + "parameters": [ + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "Refreshed provider usage", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderUsage" + } + } + } + }, + "503": { + "description": "ServiceUnavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiError_ServiceUnavailable" + } + } + } + } + }, + "description": "Refresh provider plan usage while coalescing concurrent source requests.", + "summary": "Refresh provider usage", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.kilocode.providerUsage.refresh({\n ...\n})" + } + ] + } + }, "/network": { "get": { "tags": ["network"], @@ -20938,6 +21046,283 @@ "required": ["_tag"], "additionalProperties": false }, + "ProviderUsageWindow": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": ["quota", "spend_control"] + }, + "unit": { + "type": "string" + }, + "orientation": { + "type": "string", + "enum": ["used_percent", "remaining_percent", "amount", "count"] + }, + "used": { + "type": "number" + }, + "remaining": { + "type": "number" + }, + "limit": { + "type": "number" + }, + "durationMs": { + "type": "number" + }, + "resetAt": { + "type": "string" + }, + "state": { + "type": "string", + "enum": ["active", "exhausted", "unlimited", "not_in_plan", "unknown"] + } + }, + "required": ["id", "label", "resource", "kind", "unit", "orientation", "state"], + "additionalProperties": false + }, + "ProviderUsageBalance": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "unit": { + "type": "string" + }, + "total": { + "type": "string" + }, + "granted": { + "type": "string" + }, + "toppedUp": { + "type": "string" + }, + "available": { + "type": "boolean" + } + }, + "required": ["id", "label", "currency", "unit", "total"], + "additionalProperties": false + }, + "ProviderUsageCredit": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "balance": { + "type": "string" + }, + "unit": { + "type": "string" + }, + "unlimited": { + "type": "boolean" + }, + "availableResets": { + "type": "number" + } + }, + "required": ["id", "label"], + "additionalProperties": false + }, + "ProviderUsageError": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "retryable": { + "type": "boolean" + } + }, + "required": ["code", "message", "retryable"], + "additionalProperties": false + }, + "ProviderUsageSnapshot": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "sourceKind": { + "type": "string", + "enum": ["kilo_pass", "kilo_managed", "direct", "codex"] + }, + "providerLabel": { + "type": "string" + }, + "planLabel": { + "type": "string" + }, + "sourceLabel": { + "type": "string" + }, + "accountLabel": { + "type": "string" + }, + "fetchState": { + "type": "string", + "enum": ["ready", "stale", "unavailable", "error"] + }, + "planState": { + "type": "string", + "enum": ["active", "past_due", "canceling", "unknown"] + }, + "routingState": { + "type": "string", + "enum": ["active", "disabled", "missing", "replaced", "not_applicable", "unknown"] + }, + "availabilityState": { + "type": "string", + "enum": ["available", "exhausted", "unavailable", "unlimited", "unknown"] + }, + "fetchedAt": { + "type": "string" + }, + "confidence": { + "type": "string", + "enum": ["high", "medium", "low"] + }, + "source": { + "type": "string", + "enum": ["cloud", "provider_api", "provider_backend"] + }, + "managementUrl": { + "type": "string" + }, + "windows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderUsageWindow" + } + }, + "balances": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderUsageBalance" + } + }, + "credits": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderUsageCredit" + } + }, + "error": { + "$ref": "#/components/schemas/ProviderUsageError" + } + }, + "required": [ + "id", + "providerID", + "sourceKind", + "providerLabel", + "planLabel", + "sourceLabel", + "fetchState", + "planState", + "routingState", + "availabilityState", + "confidence", + "source", + "windows", + "balances", + "credits" + ], + "additionalProperties": false + }, + "ProviderUsageAutoTopUp": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "amountCents": { + "type": "number" + }, + "thresholdCents": { + "type": "number" + }, + "paymentType": { + "type": "string" + }, + "paymentBrand": { + "type": "string" + }, + "paymentLast4": { + "type": "string" + } + }, + "required": ["enabled", "amountCents", "thresholdCents"], + "additionalProperties": false + }, + "ProviderUsageKiloBilling": { + "type": "object", + "properties": { + "topUpUrl": { + "type": "string" + }, + "manageUrl": { + "type": "string" + }, + "autoTopUp": { + "$ref": "#/components/schemas/ProviderUsageAutoTopUp" + }, + "error": { + "$ref": "#/components/schemas/ProviderUsageError" + } + }, + "required": ["topUpUrl", "manageUrl"], + "additionalProperties": false + }, + "ProviderUsage": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderUsageSnapshot" + } + }, + "kiloBilling": { + "$ref": "#/components/schemas/ProviderUsageKiloBilling" + }, + "generatedAt": { + "type": "string" + } + }, + "required": ["items", "generatedAt"], + "additionalProperties": false + }, "KilocodeSessionImportResult": { "type": "object", "properties": {