mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge pull request #11611 from Kilo-Org/feat/provider-usage-center
feat: add provider usage center
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
"@kilocode/kilo-ui": minor
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
View current provider plan usage and quota windows in the CLI and VS Code profile.
|
||||
@@ -357,7 +357,7 @@
|
||||
},
|
||||
"packages/kilo-jetbrains": {
|
||||
"name": "@kilocode/kilo-jetbrains",
|
||||
"version": "7.4.22",
|
||||
"version": "7.4.23",
|
||||
},
|
||||
"packages/kilo-memory": {
|
||||
"name": "@kilocode/kilo-memory",
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
export * as ProviderUsage from "./provider-usage"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { createHash } from "node:crypto"
|
||||
import { ProviderUsage as Contract } from "@opencode-ai/schema/kilocode/provider-usage"
|
||||
import { Catalog } from "../catalog"
|
||||
import { makeGlobalNode, makeLocationNode } from "../effect/app-node"
|
||||
import { Integration } from "../integration"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { ProviderV2 } from "../provider"
|
||||
import * as Cloud from "./provider-usage/cloud"
|
||||
import { bindings, direct, type Candidate } from "./provider-usage/minimax/usage"
|
||||
|
||||
const successTtl = 60_000
|
||||
const errorTtl = 10_000
|
||||
const readyPlugin = PluginV2.ID.make("config-provider")
|
||||
|
||||
interface AdapterContext {
|
||||
candidates: readonly Candidate[]
|
||||
failedCandidates: readonly Candidate["providerID"][]
|
||||
cloud: (() => Promise<Cloud.CloudState>) | undefined
|
||||
token: string | undefined
|
||||
cloudIdentity: string | undefined
|
||||
cloudReliable: boolean
|
||||
fetch: typeof fetch
|
||||
usage: typeof Cloud.fetchCodingPlanUsage
|
||||
identityCurrent(identity: string): boolean
|
||||
source(id: string, load: () => Promise<Contract.UsageSnapshot>, identity?: string): Promise<Contract.UsageSnapshot>
|
||||
preserve(prefix: string, identity?: string): Contract.UsageSnapshot[]
|
||||
prune(prefix: string, keep: string[]): void
|
||||
}
|
||||
|
||||
interface AdapterResult {
|
||||
items: ReadonlyArray<Contract.UsageSnapshot>
|
||||
}
|
||||
|
||||
interface Adapter {
|
||||
cachePrefixes: readonly string[]
|
||||
cloudScoped?: boolean
|
||||
run(ctx: AdapterContext): Promise<AdapterResult>
|
||||
}
|
||||
|
||||
const managed: Adapter = {
|
||||
cachePrefixes: ["kilo-managed:"],
|
||||
cloudScoped: true,
|
||||
async run(ctx) {
|
||||
if (!ctx.cloud || !ctx.token || !ctx.cloudIdentity) {
|
||||
return { items: ctx.cloudReliable ? [] : ctx.preserve("kilo-managed:") }
|
||||
}
|
||||
const state = await ctx.cloud()
|
||||
if (!ctx.identityCurrent(ctx.cloudIdentity)) return { items: [] }
|
||||
if (!state.plans.ok || !state.byok.ok) return { items: ctx.preserve("kilo-managed:", ctx.cloudIdentity) }
|
||||
const token = ctx.token
|
||||
const identity = ctx.cloudIdentity
|
||||
const detected = Cloud.plans(state)
|
||||
const ids = detected.map((subscription) => `kilo-managed:${subscription.id}`)
|
||||
ctx.prune("kilo-managed:", ids)
|
||||
return {
|
||||
items: await Promise.all(
|
||||
detected.map((subscription) =>
|
||||
ctx.source(`kilo-managed:${subscription.id}`, () => Cloud.managed(token, subscription, ctx.usage), identity),
|
||||
),
|
||||
),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const minimax: Adapter = {
|
||||
cachePrefixes: ["minimax-direct-"],
|
||||
async run(ctx) {
|
||||
const items = await direct(ctx.candidates, ctx.fetch, ctx.source)
|
||||
// A candidate is either live or failed, never both, so the id sets are disjoint.
|
||||
const stale = ctx.failedCandidates.flatMap((id) => ctx.preserve(`minimax-direct-${bindings[id].region}`))
|
||||
const merged = [...items, ...stale]
|
||||
ctx.prune(
|
||||
"minimax-direct-",
|
||||
merged.map((item) => item.id),
|
||||
)
|
||||
return { items: merged }
|
||||
},
|
||||
}
|
||||
|
||||
const registry: readonly Adapter[] = [managed, minimax]
|
||||
|
||||
export class ServiceError extends Schema.TaggedErrorClass<ServiceError>()("ProviderUsageServiceError", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
interface SourceCell {
|
||||
identity?: string
|
||||
value?: Contract.UsageSnapshot
|
||||
expires: number
|
||||
updatedAt?: string
|
||||
inflight?: Promise<Contract.UsageSnapshot>
|
||||
}
|
||||
|
||||
interface CloudCell {
|
||||
value?: Cloud.CloudState
|
||||
expires: number
|
||||
updatedAt?: string
|
||||
inflight?: Promise<Cloud.CloudState>
|
||||
}
|
||||
|
||||
interface State {
|
||||
sources: Map<string, SourceCell>
|
||||
cloud: CloudCell
|
||||
cloudIdentity?: string
|
||||
}
|
||||
|
||||
function fingerprint(value: string) {
|
||||
return createHash("sha256").update(value).digest("hex")
|
||||
}
|
||||
|
||||
function scopeCloudCache(state: State, token: string | undefined) {
|
||||
const identity = token ? fingerprint(token) : undefined
|
||||
if (state.cloudIdentity === identity) return identity
|
||||
state.cloudIdentity = identity
|
||||
state.cloud = { expires: 0 }
|
||||
prune(state, "kilo-managed:", [])
|
||||
return identity
|
||||
}
|
||||
|
||||
function stale(next: Contract.UsageSnapshot, previous: Contract.UsageSnapshot | undefined) {
|
||||
if (next.fetchState !== "unavailable" && next.fetchState !== "error") return next
|
||||
if (!previous || (previous.fetchState !== "ready" && previous.fetchState !== "stale")) return next
|
||||
return {
|
||||
...previous,
|
||||
fetchState: "stale" as const,
|
||||
planState: next.planState,
|
||||
routingState: next.routingState,
|
||||
managementUrl: next.managementUrl,
|
||||
error: next.error,
|
||||
}
|
||||
}
|
||||
|
||||
function source(
|
||||
state: State,
|
||||
id: string,
|
||||
force: boolean,
|
||||
load: () => Promise<Contract.UsageSnapshot>,
|
||||
identity?: string,
|
||||
) {
|
||||
const existing = state.sources.get(id)
|
||||
const cell: SourceCell = existing && existing.identity === identity ? existing : { expires: 0, identity }
|
||||
state.sources.set(id, cell)
|
||||
if (!force && cell.value && cell.expires > Date.now()) return Promise.resolve(cell.value)
|
||||
if (cell.inflight) return cell.inflight
|
||||
|
||||
const task = load()
|
||||
.then((item) => {
|
||||
const value = stale(item, cell.value)
|
||||
if (state.sources.get(id) !== cell) return value
|
||||
cell.value = value
|
||||
cell.updatedAt = new Date().toISOString()
|
||||
cell.expires = Date.now() + (value.fetchState === "ready" ? successTtl : errorTtl)
|
||||
return value
|
||||
})
|
||||
.finally(() => {
|
||||
cell.inflight = undefined
|
||||
})
|
||||
cell.inflight = task
|
||||
return task
|
||||
}
|
||||
|
||||
function preserve(state: State, prefix: string, identity?: string) {
|
||||
const items: Contract.UsageSnapshot[] = []
|
||||
for (const [id, cell] of state.sources) {
|
||||
if (!id.startsWith(prefix) || (identity !== undefined && cell.identity !== identity) || !cell.value) continue
|
||||
const loaded = cell.value.fetchState === "ready" || cell.value.fetchState === "stale"
|
||||
const value = loaded
|
||||
? {
|
||||
...cell.value,
|
||||
fetchState: "stale" as const,
|
||||
error: {
|
||||
code: "source_refresh_unavailable",
|
||||
message: "The latest usage could not be loaded.",
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
: cell.value
|
||||
cell.value = value
|
||||
cell.updatedAt = new Date().toISOString()
|
||||
cell.expires = Date.now() + errorTtl
|
||||
items.push(value)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
function prune(state: State, prefix: string, keep: string[]) {
|
||||
const ids = new Set(keep)
|
||||
for (const id of state.sources.keys()) {
|
||||
if (!id.startsWith(prefix) || ids.has(id)) continue
|
||||
state.sources.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
function cloud(state: State, token: string, identity: string, force: boolean, transport: TransportInterface) {
|
||||
if (state.cloudIdentity !== identity) return Cloud.load(token, transport)
|
||||
const cell = state.cloud
|
||||
if (!force && cell.value && cell.expires > Date.now()) return Promise.resolve(cell.value)
|
||||
if (cell.inflight) return cell.inflight
|
||||
|
||||
const task = Cloud.load(token, transport)
|
||||
.then((value) => {
|
||||
if (state.cloudIdentity !== identity) return value
|
||||
const failed = Object.values(value).some((result) => !result.ok)
|
||||
const previous = cell.value
|
||||
if (failed && previous) {
|
||||
cell.expires = Date.now() + errorTtl
|
||||
return previous
|
||||
}
|
||||
cell.value = value
|
||||
cell.updatedAt = new Date().toISOString()
|
||||
cell.expires = Date.now() + (failed ? errorTtl : successTtl)
|
||||
return value
|
||||
})
|
||||
.finally(() => {
|
||||
cell.inflight = undefined
|
||||
})
|
||||
cell.inflight = task
|
||||
return task
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: () => Effect.Effect<Contract.Info, ServiceError>
|
||||
readonly refresh: () => Effect.Effect<Contract.Info, ServiceError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@kilocode/ProviderUsage") {}
|
||||
|
||||
export interface TransportInterface {
|
||||
readonly fetch: typeof fetch
|
||||
readonly plans: typeof Cloud.fetchCodingPlanSubscriptions
|
||||
readonly byok: typeof Cloud.fetchByokEntries
|
||||
readonly usage: typeof Cloud.fetchCodingPlanUsage
|
||||
}
|
||||
|
||||
export class Transport extends Context.Service<Transport, TransportInterface>()("@kilocode/ProviderUsageTransport") {}
|
||||
|
||||
const transportLayer = Layer.succeed(Transport, {
|
||||
fetch,
|
||||
plans: Cloud.fetchCodingPlanSubscriptions,
|
||||
byok: Cloud.fetchByokEntries,
|
||||
usage: Cloud.fetchCodingPlanUsage,
|
||||
})
|
||||
|
||||
export const transportNode = makeGlobalNode({ service: Transport, layer: transportLayer, deps: [] })
|
||||
|
||||
const credential = Effect.fn("ProviderUsage.credential")(function* (
|
||||
integrations: Integration.Interface,
|
||||
id: Integration.ID,
|
||||
) {
|
||||
const connection = yield* integrations.connection.active(id)
|
||||
if (!connection) return undefined
|
||||
return yield* integrations.connection
|
||||
.resolve(connection)
|
||||
.pipe(Effect.mapError(() => new ServiceError({ message: `Unable to resolve provider credential: ${id}` })))
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("ProviderUsage.resolveCredential")(function* (
|
||||
integrations: Integration.Interface,
|
||||
id: Integration.ID,
|
||||
) {
|
||||
return yield* credential(integrations, id).pipe(
|
||||
Effect.map((value) => ({ ok: true as const, value })),
|
||||
Effect.catch(() => Effect.succeed({ ok: false as const })),
|
||||
)
|
||||
})
|
||||
|
||||
function configuredKey(provider: ProviderV2.Info) {
|
||||
const header = Object.entries(provider.request.headers).find(([key]) => key.toLowerCase() === "x-api-key")?.[1]
|
||||
const value = provider.request.body.apiKey ?? provider.api.settings?.apiKey ?? header
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
function nonempty(value: unknown) {
|
||||
if (typeof value !== "string") return undefined
|
||||
const text = value.trim()
|
||||
return text || undefined
|
||||
}
|
||||
|
||||
const inputs = Effect.fn("ProviderUsage.inputs")(function* (
|
||||
catalog: Catalog.Interface,
|
||||
integrations: Integration.Interface,
|
||||
) {
|
||||
const providers = yield* catalog.provider.all()
|
||||
const byID = new Map(providers.map((provider) => [provider.id, provider]))
|
||||
const failedCandidates: Candidate["providerID"][] = []
|
||||
const candidates = yield* Effect.forEach(Object.keys(bindings) as (keyof typeof bindings)[], (providerID) =>
|
||||
Effect.gen(function* () {
|
||||
const provider = byID.get(ProviderV2.ID.make(providerID))
|
||||
if (!provider || provider.disabled) return undefined
|
||||
const resolved = yield* resolve(integrations, provider.integrationID ?? Integration.ID.make(provider.id))
|
||||
if (!resolved.ok) failedCandidates.push(providerID)
|
||||
const value = resolved.ok
|
||||
? resolved.value?.type === "key"
|
||||
? resolved.value.key
|
||||
: configuredKey(provider)
|
||||
: undefined
|
||||
if (typeof value !== "string" || !value.trim().startsWith("sk-cp")) return undefined
|
||||
return { providerID, label: provider.name, key: value.trim() } satisfies Candidate
|
||||
}),
|
||||
)
|
||||
const kilo = yield* resolve(integrations, Integration.ID.make("kilo"))
|
||||
const kiloProvider = byID.get(ProviderV2.ID.kilo)
|
||||
const configuredOrg = nonempty(process.env.KILO_ORG_ID) ?? nonempty(kiloProvider?.request.body.kilocodeOrganizationId)
|
||||
const organization =
|
||||
configuredOrg !== undefined ||
|
||||
(kilo.ok && kilo.value?.type === "oauth" && nonempty(kilo.value.metadata?.accountID) !== undefined)
|
||||
const cloudReliable = organization || kilo.ok
|
||||
const token =
|
||||
kilo.ok && kilo.value?.type === "oauth" && !organization && kilo.value.access ? kilo.value.access : undefined
|
||||
return {
|
||||
candidates: candidates.filter((item): item is Candidate => item !== undefined),
|
||||
failedCandidates,
|
||||
token,
|
||||
cloudReliable,
|
||||
}
|
||||
})
|
||||
|
||||
function makeService(
|
||||
catalog: Catalog.Interface,
|
||||
integrations: Integration.Interface,
|
||||
transport: TransportInterface,
|
||||
ready: Effect.Effect<void>,
|
||||
) {
|
||||
const state: State = { sources: new Map(), cloud: { expires: 0 } }
|
||||
|
||||
const evaluate = Effect.fn("ProviderUsage.evaluate")(function* (force: boolean) {
|
||||
yield* ready
|
||||
const current = yield* inputs(catalog, integrations)
|
||||
const cloudIdentity = current.cloudReliable ? scopeCloudCache(state, current.token) : state.cloudIdentity
|
||||
const ctx: AdapterContext = {
|
||||
candidates: current.candidates,
|
||||
failedCandidates: current.failedCandidates,
|
||||
cloud:
|
||||
current.token && cloudIdentity
|
||||
? () => cloud(state, current.token!, cloudIdentity, force, transport)
|
||||
: undefined,
|
||||
token: current.token,
|
||||
cloudIdentity,
|
||||
cloudReliable: current.cloudReliable,
|
||||
fetch: transport.fetch,
|
||||
usage: transport.usage,
|
||||
identityCurrent: (identity) => state.cloudIdentity === identity,
|
||||
source: (id, load, identity) => source(state, id, force, load, identity),
|
||||
preserve: (prefix, identity) => preserve(state, prefix, identity),
|
||||
prune: (prefix, keep) => prune(state, prefix, keep),
|
||||
}
|
||||
const results = yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
registry.map((adapter) =>
|
||||
// Adapters are expected to be total (they absorb their own failures into
|
||||
// unavailable/stale snapshots). This catch is the containment boundary so a
|
||||
// faulty future adapter degrades to stale output instead of failing the endpoint.
|
||||
adapter.run(ctx).catch(
|
||||
(): AdapterResult => ({
|
||||
items: adapter.cachePrefixes.flatMap((prefix) =>
|
||||
ctx.preserve(prefix, adapter.cloudScoped ? ctx.cloudIdentity : undefined),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const stamps = [state.cloud.updatedAt, ...state.sources.values().map((cell) => cell.updatedAt)].filter(
|
||||
(value): value is string => value !== undefined,
|
||||
)
|
||||
return {
|
||||
items: results.flatMap((result) => result.items),
|
||||
generatedAt: stamps.toSorted().at(-1) ?? new Date().toISOString(),
|
||||
} satisfies Contract.Info
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
get: () => evaluate(false),
|
||||
refresh: () => evaluate(true),
|
||||
})
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
return makeService(yield* Catalog.Service, yield* Integration.Service, yield* Transport, plugins.wait(readyPlugin))
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Catalog.node, Integration.node, PluginV2.node, transportNode],
|
||||
})
|
||||
|
||||
export { Contract as Schema }
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
fetchByokEntries,
|
||||
fetchCodingPlanSubscriptions,
|
||||
fetchCodingPlanUsage,
|
||||
type ByokEntry,
|
||||
type CodingPlanQuotaWindow,
|
||||
type CodingPlanSubscription,
|
||||
} from "@kilocode/kilo-gateway"
|
||||
import type { ProviderUsage } from "@opencode-ai/schema/kilocode/provider-usage"
|
||||
|
||||
export { fetchByokEntries, fetchCodingPlanSubscriptions, fetchCodingPlanUsage }
|
||||
|
||||
export interface CloudState {
|
||||
plans: Result<CodingPlanSubscription[]>
|
||||
byok: Result<ByokEntry[]>
|
||||
}
|
||||
|
||||
type Result<T> = { ok: true; value: T } | { ok: false }
|
||||
|
||||
const safe = async <T>(promise: Promise<T>): Promise<Result<T>> =>
|
||||
promise.then(
|
||||
(value) => ({ ok: true, value }),
|
||||
() => ({ ok: false }),
|
||||
)
|
||||
|
||||
export async function load(
|
||||
token: string,
|
||||
transport: {
|
||||
plans: typeof fetchCodingPlanSubscriptions
|
||||
byok: typeof fetchByokEntries
|
||||
} = { plans: fetchCodingPlanSubscriptions, byok: fetchByokEntries },
|
||||
): Promise<CloudState> {
|
||||
const [plans, byok] = await Promise.all([safe(transport.plans(token)), safe(transport.byok(token))])
|
||||
return { plans, byok }
|
||||
}
|
||||
|
||||
function base() {
|
||||
if (!process.env.KILO_API_URL) return "https://app.kilo.ai"
|
||||
try {
|
||||
return new URL(process.env.KILO_API_URL).origin
|
||||
} catch {
|
||||
return "https://app.kilo.ai"
|
||||
}
|
||||
}
|
||||
|
||||
const error = (code: string, message: string) => ({ code, message, retryable: true })
|
||||
|
||||
function installed(subscription: CodingPlanSubscription, state: Result<ByokEntry[]>) {
|
||||
if (!state.ok || !subscription.canQueryUsage || !subscription.hasInstalledByokKey) return false
|
||||
return state.value.some(
|
||||
(item) =>
|
||||
item.provider_id === subscription.providerId && item.management_source === "coding_plan" && item.is_enabled,
|
||||
)
|
||||
}
|
||||
|
||||
export function plans(state: CloudState) {
|
||||
if (!state.plans.ok) return []
|
||||
return state.plans.value
|
||||
.filter((item) => (item.status === "active" || item.status === "past_due") && installed(item, state.byok))
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
|
||||
function durationMs(period: CodingPlanQuotaWindow["period"]) {
|
||||
const multipliers = {
|
||||
hour: 60 * 60 * 1000,
|
||||
day: 24 * 60 * 60 * 1000,
|
||||
week: 7 * 24 * 60 * 60 * 1000,
|
||||
} as const
|
||||
if (period.unit === "month") return undefined
|
||||
return period.value * multipliers[period.unit]
|
||||
}
|
||||
|
||||
function window(subscriptionId: string, value: CodingPlanQuotaWindow): ProviderUsage.UsageWindow {
|
||||
const remaining = value.remainingPercent
|
||||
const duration = durationMs(value.period)
|
||||
return {
|
||||
id: `${subscriptionId}:${value.id}`,
|
||||
resource: "subscription",
|
||||
unit: "percent",
|
||||
orientation: "remaining_percent",
|
||||
used: Math.max(0, 100 - remaining),
|
||||
remaining,
|
||||
limit: 100,
|
||||
period: value.period,
|
||||
...(duration !== undefined ? { durationMs: duration } : {}),
|
||||
resetAt: value.resetsAt,
|
||||
state: remaining <= 0 ? "exhausted" : "active",
|
||||
}
|
||||
}
|
||||
|
||||
export async function managed(
|
||||
token: string,
|
||||
subscription: CodingPlanSubscription,
|
||||
usage: typeof fetchCodingPlanUsage = fetchCodingPlanUsage,
|
||||
): Promise<ProviderUsage.UsageSnapshot> {
|
||||
const fetchedAt = new Date().toISOString()
|
||||
const planState = subscription.cancelAtPeriodEnd
|
||||
? "canceling"
|
||||
: subscription.status === "past_due"
|
||||
? "past_due"
|
||||
: "active"
|
||||
const id = `kilo-managed:${subscription.id}`
|
||||
const managementUrl = `${base()}/subscriptions/coding-plans/${subscription.id}`
|
||||
|
||||
return usage(token, subscription.id)
|
||||
.then((usage) => {
|
||||
const windows = usage.subscription.windows.map((item) => window(usage.subscription.id, item))
|
||||
return {
|
||||
id,
|
||||
providerID: usage.subscription.providerId,
|
||||
sourceKind: "kilo_managed",
|
||||
providerLabel: usage.subscription.providerName,
|
||||
planLabel: usage.subscription.planName,
|
||||
sourceLabel: "via Kilo",
|
||||
fetchState: "ready",
|
||||
planState,
|
||||
routingState: "active",
|
||||
fetchedAt: usage.fetchedAt,
|
||||
managementUrl,
|
||||
windows,
|
||||
} satisfies ProviderUsage.UsageSnapshot
|
||||
})
|
||||
.catch(() => ({
|
||||
id,
|
||||
providerID: subscription.providerId,
|
||||
sourceKind: "kilo_managed",
|
||||
providerLabel: subscription.providerName,
|
||||
planLabel: subscription.planName,
|
||||
sourceLabel: "via Kilo",
|
||||
fetchState: "unavailable",
|
||||
planState,
|
||||
routingState: "active",
|
||||
fetchedAt,
|
||||
managementUrl,
|
||||
windows: [],
|
||||
error: error("managed_subscription_unavailable", "Usage unavailable."),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
const IntegerField = Schema.Int
|
||||
// Matches the cloud schema: remaining percent is a 0-100 share of the base quota; boosts scale it separately.
|
||||
const PercentField = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)).check(Schema.isLessThanOrEqualTo(100))
|
||||
|
||||
export const ModelRemains = Schema.Struct({
|
||||
model_name: Schema.String,
|
||||
current_interval_total_count: Schema.optional(IntegerField),
|
||||
current_interval_usage_count: Schema.optional(IntegerField),
|
||||
start_time: Schema.optional(IntegerField),
|
||||
end_time: Schema.optional(IntegerField),
|
||||
remains_time: Schema.optional(IntegerField),
|
||||
interval_boost_permille: Schema.optional(IntegerField),
|
||||
current_interval_remaining_percent: Schema.optional(PercentField),
|
||||
current_interval_status: Schema.optional(IntegerField),
|
||||
current_weekly_total_count: Schema.optional(IntegerField),
|
||||
current_weekly_usage_count: Schema.optional(IntegerField),
|
||||
weekly_start_time: Schema.optional(IntegerField),
|
||||
weekly_end_time: Schema.optional(IntegerField),
|
||||
weekly_remains_time: Schema.optional(IntegerField),
|
||||
weekly_boost_permille: Schema.optional(IntegerField),
|
||||
current_weekly_remaining_percent: Schema.optional(PercentField),
|
||||
current_weekly_status: Schema.optional(IntegerField),
|
||||
}).annotate({ identifier: "MiniMaxModelRemains" })
|
||||
export type ModelRemains = typeof ModelRemains.Type
|
||||
|
||||
export const Native = Schema.Struct({
|
||||
base_resp: Schema.Struct({ status_code: IntegerField }),
|
||||
model_remains: Schema.Array(ModelRemains),
|
||||
}).annotate({ identifier: "MiniMaxNativeUsage" })
|
||||
export type Native = typeof Native.Type
|
||||
|
||||
export const decode = Schema.decodeUnknownSync(Native)
|
||||
@@ -0,0 +1,284 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import { decode, type ModelRemains, type Native } from "./native"
|
||||
import type { ProviderUsage } from "@opencode-ai/schema/kilocode/provider-usage"
|
||||
|
||||
export const bindings = {
|
||||
"minimax-coding-plan": {
|
||||
region: "global",
|
||||
url: "https://api.minimax.io/v1/token_plan/remains",
|
||||
manage: "https://platform.minimax.io/subscribe/token-plan",
|
||||
},
|
||||
"minimax-cn-coding-plan": {
|
||||
region: "china",
|
||||
url: "https://api.minimaxi.com/v1/token_plan/remains",
|
||||
manage: "https://platform.minimaxi.com/subscribe/token-plan",
|
||||
},
|
||||
} as const
|
||||
|
||||
type ProviderID = keyof typeof bindings
|
||||
|
||||
const timeout = 5_000
|
||||
const limit = 64 * 1024
|
||||
|
||||
class MiniMaxUsageError extends Error {
|
||||
constructor(readonly code: "network" | "http" | "too_large" | "invalid" | "application") {
|
||||
super("MiniMax usage is temporarily unavailable.")
|
||||
this.name = "MiniMaxUsageError"
|
||||
}
|
||||
}
|
||||
|
||||
async function text(response: Response) {
|
||||
const declared = Number(response.headers.get("content-length"))
|
||||
if (Number.isFinite(declared) && declared > limit) {
|
||||
response.body?.cancel().catch(() => undefined)
|
||||
throw new MiniMaxUsageError("too_large")
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
const value = await response.arrayBuffer()
|
||||
if (value.byteLength > limit) throw new MiniMaxUsageError("too_large")
|
||||
return new TextDecoder().decode(value)
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let size = 0
|
||||
while (true) {
|
||||
const chunk = await reader.read()
|
||||
if (chunk.done) break
|
||||
if (!chunk.value) continue
|
||||
size += chunk.value.byteLength
|
||||
if (size > limit) {
|
||||
await reader.cancel().catch(() => undefined)
|
||||
throw new MiniMaxUsageError("too_large")
|
||||
}
|
||||
chunks.push(chunk.value)
|
||||
}
|
||||
|
||||
const value = new Uint8Array(size)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
value.set(chunk, offset)
|
||||
offset += chunk.byteLength
|
||||
}
|
||||
return new TextDecoder().decode(value)
|
||||
}
|
||||
|
||||
export async function query(providerID: ProviderID, key: string, fetcher: typeof fetch = fetch): Promise<Native> {
|
||||
const response = await fetcher(bindings[providerID].url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
cache: "no-store",
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
}).catch(() => {
|
||||
throw new MiniMaxUsageError("network")
|
||||
})
|
||||
if (!response.ok) {
|
||||
response.body?.cancel().catch(() => undefined)
|
||||
throw new MiniMaxUsageError("http")
|
||||
}
|
||||
|
||||
const body = await text(response)
|
||||
const native = (() => {
|
||||
try {
|
||||
return decode(JSON.parse(body))
|
||||
} catch {
|
||||
throw new MiniMaxUsageError("invalid")
|
||||
}
|
||||
})()
|
||||
if (native.base_resp.status_code !== 0) throw new MiniMaxUsageError("application")
|
||||
return native
|
||||
}
|
||||
|
||||
function reset(end: number | undefined, remains: number | undefined, fetchedAt: string) {
|
||||
if (end !== undefined && end > 0) return new Date(end).toISOString()
|
||||
if (remains !== undefined && remains > 0) return new Date(Date.parse(fetchedAt) + remains).toISOString()
|
||||
return undefined
|
||||
}
|
||||
|
||||
function duration(start: number | undefined, end: number | undefined) {
|
||||
if (start === undefined || end === undefined || end <= start) return undefined
|
||||
return end - start
|
||||
}
|
||||
|
||||
const hourMs = 60 * 60 * 1000
|
||||
const dayMs = 24 * hourMs
|
||||
const weekMs = 7 * dayMs
|
||||
|
||||
function cadence(kind: "interval" | "weekly", span: number | undefined): ProviderUsage.UsagePeriod | undefined {
|
||||
if (kind === "weekly") return { unit: "week", value: 1 }
|
||||
if (span === undefined) return undefined
|
||||
if (span % weekMs === 0) return { unit: "week", value: span / weekMs }
|
||||
if (span % dayMs === 0) return { unit: "day", value: span / dayMs }
|
||||
if (span % hourMs === 0) return { unit: "hour", value: span / hourMs }
|
||||
return undefined
|
||||
}
|
||||
|
||||
function window(
|
||||
row: ModelRemains,
|
||||
kind: "interval" | "weekly",
|
||||
fetchedAt: string,
|
||||
): ProviderUsage.UsageWindow | undefined {
|
||||
const weekly = kind === "weekly"
|
||||
const percent = weekly ? row.current_weekly_remaining_percent : row.current_interval_remaining_percent
|
||||
const status = weekly ? row.current_weekly_status : row.current_interval_status
|
||||
const total = weekly ? row.current_weekly_total_count : row.current_interval_total_count
|
||||
const count = weekly ? row.current_weekly_usage_count : row.current_interval_usage_count
|
||||
const start = weekly ? row.weekly_start_time : row.start_time
|
||||
const end = weekly ? row.weekly_end_time : row.end_time
|
||||
const remains = weekly ? row.weekly_remains_time : row.remains_time
|
||||
const boost = weekly ? row.weekly_boost_permille : row.interval_boost_permille
|
||||
const span = duration(start, end)
|
||||
const base = {
|
||||
id: `${row.model_name}-${kind}`,
|
||||
resource: row.model_name,
|
||||
period: cadence(kind, span),
|
||||
durationMs: span,
|
||||
resetAt: reset(end, remains, fetchedAt),
|
||||
}
|
||||
|
||||
if (status === 3) return { ...base, unit: "unknown", orientation: "amount", state: "not_in_plan" }
|
||||
|
||||
if (percent !== undefined) {
|
||||
const factor = boost !== undefined && boost > 0 ? boost / 1000 : 1
|
||||
const cap = 100 * factor
|
||||
// The status flag is authoritative: an exhausted window has zero remaining even when the percent field lags.
|
||||
const remaining = status === 2 ? 0 : percent * factor
|
||||
return {
|
||||
...base,
|
||||
unit: factor === 1 ? "percent" : "standard_units",
|
||||
orientation: factor === 1 ? "remaining_percent" : "amount",
|
||||
used: Math.max(0, cap - remaining),
|
||||
remaining,
|
||||
limit: cap,
|
||||
state: status === 2 || remaining <= 0 ? "exhausted" : "active",
|
||||
}
|
||||
}
|
||||
|
||||
if (total !== undefined && total > 0 && count !== undefined && count >= 0) {
|
||||
// Despite the name, MiniMax's *_usage_count fields report the remaining quota, not the consumed amount.
|
||||
const remaining = status === 2 ? 0 : count
|
||||
return {
|
||||
...base,
|
||||
unit: "count",
|
||||
orientation: "count",
|
||||
used: Math.max(0, total - remaining),
|
||||
remaining,
|
||||
limit: total,
|
||||
state: status === 2 || remaining === 0 ? "exhausted" : "active",
|
||||
}
|
||||
}
|
||||
|
||||
if (status === undefined && total === undefined && count === undefined) return undefined
|
||||
return {
|
||||
...base,
|
||||
unit: "unknown",
|
||||
orientation: "amount",
|
||||
state: status === 2 ? "exhausted" : "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
export function normalize(
|
||||
native: Native,
|
||||
input: {
|
||||
id: string
|
||||
providerID: string
|
||||
sourceLabel: string
|
||||
managementUrl: string
|
||||
fetchedAt: string
|
||||
},
|
||||
): ProviderUsage.UsageSnapshot {
|
||||
const windows = native.model_remains
|
||||
.filter((row) => row.model_name !== "video")
|
||||
.flatMap((row) =>
|
||||
(["interval", "weekly"] as const).flatMap((kind) => {
|
||||
const value = window(row, kind, input.fetchedAt)
|
||||
return value ? [value] : []
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
id: input.id,
|
||||
providerID: input.providerID,
|
||||
sourceKind: "direct",
|
||||
providerLabel: "MiniMax",
|
||||
planLabel: "MiniMax Token Plan",
|
||||
sourceLabel: input.sourceLabel,
|
||||
fetchState: "ready",
|
||||
planState: "active",
|
||||
routingState: "not_applicable",
|
||||
fetchedAt: input.fetchedAt,
|
||||
managementUrl: input.managementUrl,
|
||||
windows,
|
||||
}
|
||||
}
|
||||
|
||||
const unavailable = (
|
||||
id: string,
|
||||
providerID: string,
|
||||
label: string,
|
||||
managementUrl: string,
|
||||
): ProviderUsage.UsageSnapshot => ({
|
||||
id,
|
||||
providerID,
|
||||
sourceKind: "direct",
|
||||
providerLabel: "MiniMax",
|
||||
planLabel: "MiniMax Token Plan",
|
||||
sourceLabel: label,
|
||||
fetchState: "unavailable",
|
||||
planState: "unknown",
|
||||
routingState: "not_applicable",
|
||||
managementUrl,
|
||||
windows: [],
|
||||
error: { code: "direct_minimax_unavailable", message: "Usage unavailable.", retryable: true },
|
||||
})
|
||||
|
||||
export interface Candidate {
|
||||
providerID: ProviderID
|
||||
label: string
|
||||
key: string
|
||||
}
|
||||
|
||||
export async function direct(
|
||||
candidates: readonly Candidate[],
|
||||
fetcher: typeof fetch = fetch,
|
||||
cached: (
|
||||
id: string,
|
||||
load: () => Promise<ProviderUsage.UsageSnapshot>,
|
||||
identity?: string,
|
||||
) => Promise<ProviderUsage.UsageSnapshot> = (_id, load) => load(),
|
||||
) {
|
||||
// Each configured provider is an independent plan with a stable per-region
|
||||
// cache cell, even when providers share a credential.
|
||||
return Promise.all(
|
||||
candidates
|
||||
.filter((candidate) => candidate.key.startsWith("sk-cp"))
|
||||
.map((candidate) => {
|
||||
const binding = bindings[candidate.providerID]
|
||||
const id = `minimax-direct-${binding.region}`
|
||||
// The key fingerprint scopes the cache cell to the configured credential, so
|
||||
// swapping keys never reuses the previous account's quota via TTL or stale fallback.
|
||||
const identity = createHash("sha256").update(candidate.key).digest("hex")
|
||||
return cached(
|
||||
id,
|
||||
() =>
|
||||
query(candidate.providerID, candidate.key, fetcher)
|
||||
.then((native) =>
|
||||
normalize(native, {
|
||||
id,
|
||||
providerID: candidate.providerID,
|
||||
sourceLabel: candidate.label,
|
||||
managementUrl: binding.manage,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
}),
|
||||
)
|
||||
.catch(() => unavailable(id, candidate.providerID, candidate.label, binding.manage)),
|
||||
identity,
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { FileSystemSearch } from "./filesystem/search"
|
||||
import { Watcher } from "./filesystem/watcher"
|
||||
import { Image } from "./image"
|
||||
import { Integration } from "./integration"
|
||||
import { ProviderUsage } from "./kilocode/provider-usage" // kilocode_change
|
||||
import { Location } from "./location"
|
||||
import { LocationMutation } from "./location-mutation"
|
||||
import { LocationServiceMap } from "./location-service-map"
|
||||
@@ -48,6 +49,7 @@ export const locationServices = LayerNode.group([
|
||||
Reference.node,
|
||||
Integration.node,
|
||||
Catalog.node,
|
||||
ProviderUsage.node, // kilocode_change
|
||||
AISDK.node,
|
||||
PluginV2.node,
|
||||
PluginInternal.node,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import * as Cloud from "../src/kilocode/provider-usage/cloud"
|
||||
|
||||
const subscription = {
|
||||
id: "byteplus-plan",
|
||||
planId: "byteplus-coding-plan-team-lite",
|
||||
planName: "BytePlus Coding Plan Lite",
|
||||
providerName: "BytePlus",
|
||||
providerId: "byteplus-coding",
|
||||
canQueryUsage: true,
|
||||
hasInstalledByokKey: true,
|
||||
status: "active" as const,
|
||||
cancelAtPeriodEnd: false,
|
||||
}
|
||||
|
||||
const state = (enabled = true) => ({
|
||||
plans: { ok: true as const, value: [subscription] },
|
||||
byok: {
|
||||
ok: true as const,
|
||||
value: [
|
||||
{
|
||||
id: "managed-byteplus",
|
||||
provider_id: "byteplus-coding",
|
||||
management_source: "coding_plan" as const,
|
||||
is_enabled: enabled,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
describe("managed provider usage", () => {
|
||||
test("requires Cloud usage readiness and a matching enabled managed key", () => {
|
||||
expect(Cloud.plans(state())).toEqual([subscription])
|
||||
expect(Cloud.plans(state(false))).toEqual([])
|
||||
expect(
|
||||
Cloud.plans({ ...state(), plans: { ok: true, value: [{ ...subscription, canQueryUsage: false }] } }),
|
||||
).toEqual([])
|
||||
expect(
|
||||
Cloud.plans({ ...state(), plans: { ok: true, value: [{ ...subscription, hasInstalledByokKey: false }] } }),
|
||||
).toEqual([])
|
||||
expect(
|
||||
Cloud.plans({
|
||||
...state(),
|
||||
byok: { ok: true, value: [{ ...state().byok.value[0]!, management_source: "user" as const }] },
|
||||
}),
|
||||
).toEqual([])
|
||||
expect(
|
||||
Cloud.plans({
|
||||
...state(),
|
||||
byok: { ok: true, value: [{ ...state().byok.value[0]!, provider_id: "minimax" }] },
|
||||
}),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("normalizes BytePlus windows through the generic managed path", async () => {
|
||||
const result = await Cloud.managed("token", subscription, async () => ({
|
||||
schemaVersion: 1,
|
||||
fetchedAt: "2026-08-07T12:00:00.000Z",
|
||||
subscription: {
|
||||
id: subscription.id,
|
||||
planName: subscription.planName,
|
||||
providerId: subscription.providerId,
|
||||
providerName: subscription.providerName,
|
||||
windows: [
|
||||
{
|
||||
id: "monthly",
|
||||
remainingPercent: 75,
|
||||
resetsAt: "2026-09-01T00:00:00.000Z",
|
||||
period: { unit: "month", value: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
}))
|
||||
|
||||
expect(result).toMatchObject({
|
||||
providerID: "byteplus-coding",
|
||||
providerLabel: "BytePlus",
|
||||
planLabel: "BytePlus Coding Plan Lite",
|
||||
sourceKind: "kilo_managed",
|
||||
windows: [
|
||||
{
|
||||
id: "byteplus-plan:monthly",
|
||||
resource: "subscription",
|
||||
period: { unit: "month", value: 1 },
|
||||
remaining: 75,
|
||||
used: 25,
|
||||
limit: 100,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(result.windows[0]).not.toHaveProperty("durationMs")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Effect } from "effect"
|
||||
import { AppNodeBuilder } from "../src/effect/app-node-builder"
|
||||
import { ProviderUsage } from "../src/kilocode/provider-usage"
|
||||
import { Location } from "../src/location"
|
||||
import { LocationServiceMap } from "../src/location-services"
|
||||
import { AbsolutePath } from "../src/schema"
|
||||
import { WorkspaceV2 } from "../src/workspace"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LocationServiceMap.node))
|
||||
|
||||
describe("ProviderUsage location lifecycle", () => {
|
||||
it.live("reuses the same location and isolates workspace-qualified locations", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const directory = AbsolutePath.make(dir.path)
|
||||
const first = Location.Ref.make({ directory, workspaceID: WorkspaceV2.ID.make("wrk_workspace_a") })
|
||||
const same = Location.Ref.make({ directory, workspaceID: WorkspaceV2.ID.make("wrk_workspace_a") })
|
||||
const second = Location.Ref.make({ directory, workspaceID: WorkspaceV2.ID.make("wrk_workspace_b") })
|
||||
const firstService = Context.get(yield* locations.contextEffect(first), ProviderUsage.Service)
|
||||
|
||||
expect(Context.get(yield* locations.contextEffect(same), ProviderUsage.Service)).toBe(firstService)
|
||||
expect(Context.get(yield* locations.contextEffect(second), ProviderUsage.Service)).not.toBe(firstService)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,396 @@
|
||||
import { describe, expect, mock, test } from "bun:test"
|
||||
import { decode } from "../src/kilocode/provider-usage/minimax/native"
|
||||
import { direct, normalize, query, type Candidate } from "../src/kilocode/provider-usage/minimax/usage"
|
||||
import type { ProviderUsage } from "@opencode-ai/schema/kilocode/provider-usage"
|
||||
|
||||
const native = (row: Record<string, unknown>) =>
|
||||
decode({
|
||||
base_resp: { status_code: 0, status_msg: "stripped" },
|
||||
model_remains: [{ model_name: "general", ...row }],
|
||||
unknown: "stripped",
|
||||
})
|
||||
|
||||
const options = {
|
||||
id: "usage",
|
||||
providerID: "minimax-coding-plan",
|
||||
sourceLabel: "Direct",
|
||||
managementUrl: "https://platform.minimax.io/subscribe/token-plan",
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
}
|
||||
|
||||
const candidate = (providerID: Candidate["providerID"], key: string): Candidate => ({
|
||||
providerID,
|
||||
label: providerID === "minimax-cn-coding-plan" ? "MiniMax China" : "MiniMax Global",
|
||||
key,
|
||||
})
|
||||
|
||||
const response = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } })
|
||||
|
||||
describe("MiniMax usage normalization", () => {
|
||||
test("normalizes direct native percentage payloads", () => {
|
||||
const payload = native({
|
||||
current_interval_total_count: 1500,
|
||||
current_interval_usage_count: 1,
|
||||
current_interval_remaining_percent: 80,
|
||||
current_interval_status: 1,
|
||||
start_time: 1_781_827_200_000,
|
||||
end_time: 1_781_845_200_000,
|
||||
})
|
||||
|
||||
const direct = normalize(payload, options)
|
||||
expect(direct.windows[0]).toMatchObject({
|
||||
orientation: "remaining_percent",
|
||||
remaining: 80,
|
||||
used: 20,
|
||||
limit: 100,
|
||||
period: { unit: "hour", value: 5 },
|
||||
resetAt: "2026-06-19T05:00:00.000Z",
|
||||
})
|
||||
expect(direct.windows[0]?.remaining).not.toBe(1)
|
||||
})
|
||||
|
||||
test("omits video quotas while preserving image quota state", () => {
|
||||
const value = decode({
|
||||
base_resp: { status_code: 0 },
|
||||
model_remains: [
|
||||
{
|
||||
model_name: "video",
|
||||
current_interval_remaining_percent: 100,
|
||||
current_interval_status: 1,
|
||||
},
|
||||
{
|
||||
model_name: "image",
|
||||
current_interval_remaining_percent: 70,
|
||||
current_interval_status: 1,
|
||||
current_weekly_status: 3,
|
||||
},
|
||||
{
|
||||
model_name: "general",
|
||||
current_interval_total_count: 0,
|
||||
current_interval_usage_count: 0,
|
||||
current_interval_status: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
const direct = normalize(value, options)
|
||||
|
||||
expect(direct.windows.some((window) => window.resource === "video")).toBe(false)
|
||||
expect(direct.windows.find((window) => window.id === "image-interval")).toMatchObject({
|
||||
resource: "image",
|
||||
remaining: 70,
|
||||
state: "active",
|
||||
})
|
||||
expect(direct.windows.find((window) => window.id === "image-weekly")?.state).toBe("not_in_plan")
|
||||
expect(direct.windows.find((window) => window.id === "general-interval")?.state).toBe("unknown")
|
||||
})
|
||||
|
||||
test("uses positive count-only usage_count as remaining", () => {
|
||||
const item = normalize(
|
||||
native({
|
||||
current_interval_total_count: 1500,
|
||||
current_interval_usage_count: 1200,
|
||||
current_interval_status: 1,
|
||||
}),
|
||||
options,
|
||||
)
|
||||
|
||||
expect(item.windows[0]).toMatchObject({ orientation: "count", remaining: 1200, used: 300, limit: 1500 })
|
||||
})
|
||||
|
||||
test("applies weekly boosts as capacity without clamping to 100", () => {
|
||||
const item = normalize(
|
||||
native({
|
||||
current_weekly_remaining_percent: 100,
|
||||
current_weekly_status: 1,
|
||||
weekly_boost_permille: 1500,
|
||||
}),
|
||||
options,
|
||||
)
|
||||
|
||||
expect(item.windows[0]).toMatchObject({
|
||||
unit: "standard_units",
|
||||
orientation: "amount",
|
||||
remaining: 150,
|
||||
limit: 150,
|
||||
period: { unit: "week", value: 1 },
|
||||
})
|
||||
})
|
||||
|
||||
test("prefers absolute reset timestamps over remaining duration", () => {
|
||||
const item = normalize(
|
||||
native({
|
||||
current_interval_remaining_percent: 50,
|
||||
current_interval_status: 1,
|
||||
end_time: 1_781_845_200_000,
|
||||
remains_time: 60_000,
|
||||
}),
|
||||
options,
|
||||
)
|
||||
|
||||
expect(item.windows[0]?.resetAt).toBe("2026-06-19T05:00:00.000Z")
|
||||
})
|
||||
})
|
||||
|
||||
describe("MiniMax usage window calculations", () => {
|
||||
test("clamps used at zero when the remaining count exceeds the total", () => {
|
||||
const item = normalize(
|
||||
native({
|
||||
current_interval_total_count: 1500,
|
||||
current_interval_usage_count: 1600,
|
||||
current_interval_status: 1,
|
||||
}),
|
||||
options,
|
||||
)
|
||||
|
||||
expect(item.windows[0]).toMatchObject({
|
||||
orientation: "count",
|
||||
remaining: 1600,
|
||||
used: 0,
|
||||
limit: 1500,
|
||||
state: "active",
|
||||
})
|
||||
})
|
||||
|
||||
test("marks count windows exhausted when nothing remains or the status flags it", () => {
|
||||
const drained = normalize(
|
||||
native({
|
||||
current_interval_total_count: 1500,
|
||||
current_interval_usage_count: 0,
|
||||
current_interval_status: 1,
|
||||
}),
|
||||
options,
|
||||
)
|
||||
expect(drained.windows[0]).toMatchObject({ remaining: 0, used: 1500, state: "exhausted" })
|
||||
|
||||
const flagged = normalize(
|
||||
native({
|
||||
current_interval_total_count: 1500,
|
||||
current_interval_usage_count: 800,
|
||||
current_interval_status: 2,
|
||||
}),
|
||||
options,
|
||||
)
|
||||
expect(flagged.windows[0]).toMatchObject({ remaining: 0, used: 1500, state: "exhausted" })
|
||||
})
|
||||
|
||||
test("treats an exhausted status as authoritative over lagging percent fields", () => {
|
||||
const item = normalize(native({ current_interval_remaining_percent: 12, current_interval_status: 2 }), options)
|
||||
|
||||
expect(item.windows[0]).toMatchObject({ remaining: 0, used: 100, limit: 100, state: "exhausted" })
|
||||
})
|
||||
|
||||
test("reads weekly count windows from the weekly fields", () => {
|
||||
const item = normalize(
|
||||
native({
|
||||
current_weekly_total_count: 6000,
|
||||
current_weekly_usage_count: 4500,
|
||||
current_weekly_status: 1,
|
||||
}),
|
||||
options,
|
||||
)
|
||||
|
||||
expect(item.windows).toHaveLength(1)
|
||||
expect(item.windows[0]).toMatchObject({
|
||||
id: "general-weekly",
|
||||
orientation: "count",
|
||||
remaining: 4500,
|
||||
used: 1500,
|
||||
limit: 6000,
|
||||
period: { unit: "week", value: 1 },
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects out-of-range percent values like the cloud schema does", () => {
|
||||
expect(() => native({ current_interval_remaining_percent: 150 })).toThrow()
|
||||
expect(() => native({ current_weekly_remaining_percent: -1 })).toThrow()
|
||||
})
|
||||
|
||||
test("accepts the permille boost spelling and falls back to plain percent without a boost", () => {
|
||||
const boosted = normalize(
|
||||
native({
|
||||
current_interval_remaining_percent: 50,
|
||||
current_interval_status: 1,
|
||||
interval_boost_permille: 2000,
|
||||
}),
|
||||
options,
|
||||
)
|
||||
expect(boosted.windows[0]).toMatchObject({
|
||||
unit: "standard_units",
|
||||
orientation: "amount",
|
||||
remaining: 100,
|
||||
used: 100,
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
const plain = normalize(native({ current_interval_remaining_percent: 50, current_interval_status: 1 }), options)
|
||||
expect(plain.windows[0]).toMatchObject({
|
||||
unit: "percent",
|
||||
orientation: "remaining_percent",
|
||||
remaining: 50,
|
||||
used: 50,
|
||||
limit: 100,
|
||||
})
|
||||
})
|
||||
|
||||
test("marks percent windows exhausted when nothing remains", () => {
|
||||
const item = normalize(native({ current_interval_remaining_percent: 0, current_interval_status: 1 }), options)
|
||||
|
||||
expect(item.windows[0]).toMatchObject({ remaining: 0, used: 100, state: "exhausted" })
|
||||
})
|
||||
|
||||
test("derives the period from the window span when it is a round unit", () => {
|
||||
const start = 1_781_827_200_000
|
||||
const item = normalize(
|
||||
native({
|
||||
current_interval_remaining_percent: 80,
|
||||
current_interval_status: 1,
|
||||
start_time: start,
|
||||
end_time: start + 14 * 24 * 60 * 60 * 1000,
|
||||
}),
|
||||
options,
|
||||
)
|
||||
|
||||
expect(item.windows[0]?.period).toEqual({ unit: "week", value: 2 })
|
||||
expect(item.windows[0]?.durationMs).toBe(14 * 24 * 60 * 60 * 1000)
|
||||
})
|
||||
|
||||
test("omits the period when the span is not a round hour, day, or week", () => {
|
||||
const start = 1_781_827_200_000
|
||||
const item = normalize(
|
||||
native({
|
||||
current_interval_remaining_percent: 80,
|
||||
current_interval_status: 1,
|
||||
start_time: start,
|
||||
end_time: start + 90 * 60 * 1000,
|
||||
}),
|
||||
options,
|
||||
)
|
||||
|
||||
expect(item.windows[0]?.period).toBeUndefined()
|
||||
expect(item.windows[0]?.durationMs).toBe(90 * 60 * 1000)
|
||||
})
|
||||
|
||||
test("omits the duration and period when the window timestamps are inverted", () => {
|
||||
const start = 1_781_827_200_000
|
||||
const item = normalize(
|
||||
native({
|
||||
current_interval_remaining_percent: 80,
|
||||
current_interval_status: 1,
|
||||
start_time: start,
|
||||
end_time: start,
|
||||
}),
|
||||
options,
|
||||
)
|
||||
|
||||
expect(item.windows[0]?.durationMs).toBeUndefined()
|
||||
expect(item.windows[0]?.period).toBeUndefined()
|
||||
})
|
||||
|
||||
test("falls back to remains_time when end_time is missing or zero", () => {
|
||||
const item = normalize(
|
||||
native({
|
||||
current_interval_remaining_percent: 80,
|
||||
current_interval_status: 1,
|
||||
end_time: 0,
|
||||
remains_time: 3_600_000,
|
||||
}),
|
||||
options,
|
||||
)
|
||||
expect(item.windows[0]?.resetAt).toBe("2026-06-19T01:00:00.000Z")
|
||||
|
||||
const none = normalize(native({ current_interval_remaining_percent: 80, current_interval_status: 1 }), options)
|
||||
expect(none.windows[0]?.resetAt).toBeUndefined()
|
||||
})
|
||||
|
||||
test("omits windows with no usage signals and keeps status-only windows as unknown", () => {
|
||||
const empty = normalize(native({}), options)
|
||||
expect(empty.windows).toEqual([])
|
||||
|
||||
const item = normalize(native({ current_interval_status: 1 }), options)
|
||||
expect(item.windows).toHaveLength(1)
|
||||
expect(item.windows[0]).toMatchObject({ unit: "unknown", orientation: "amount", state: "unknown" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("MiniMax usage transport and detection", () => {
|
||||
test("uses fixed hosts and ignores configured base URLs", async () => {
|
||||
const fn = mock(() => Promise.resolve(response({ base_resp: { status_code: 0 }, model_remains: [] })))
|
||||
|
||||
await query("minimax-coding-plan", "sk-cp-secret", fn as unknown as typeof fetch)
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
const call = fn.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(call[0]).toBe("https://api.minimax.io/v1/token_plan/remains")
|
||||
expect(call[1]).toMatchObject({ method: "GET", cache: "no-store", redirect: "error" })
|
||||
expect(new Headers(call[1].headers).get("authorization")).toBe("Bearer sk-cp-secret")
|
||||
})
|
||||
|
||||
test("does not query PAYG keys or provider IDs", async () => {
|
||||
const fn = mock(() => Promise.resolve(response({})))
|
||||
const items = await direct([candidate("minimax-coding-plan", "sk-api-payg")], fn as unknown as typeof fetch)
|
||||
|
||||
expect(items).toEqual([])
|
||||
expect(fn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("keeps same-key providers as independent regional plans", async () => {
|
||||
const fn = mock((url: string | URL | Request) =>
|
||||
Promise.resolve(
|
||||
String(url).includes("api.minimax.io")
|
||||
? response({}, 401)
|
||||
: response({
|
||||
base_resp: { status_code: 0 },
|
||||
model_remains: [{ model_name: "general", current_interval_remaining_percent: 90 }],
|
||||
}),
|
||||
),
|
||||
)
|
||||
const items = await direct(
|
||||
[candidate("minimax-coding-plan", "sk-cp-shared"), candidate("minimax-cn-coding-plan", "sk-cp-shared")],
|
||||
fn as unknown as typeof fetch,
|
||||
)
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(2)
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items.find((item) => item.id === "minimax-direct-global")).toMatchObject({
|
||||
providerID: "minimax-coding-plan",
|
||||
sourceLabel: "MiniMax Global",
|
||||
fetchState: "unavailable",
|
||||
})
|
||||
expect(items.find((item) => item.id === "minimax-direct-china")).toMatchObject({
|
||||
providerID: "minimax-cn-coding-plan",
|
||||
sourceLabel: "MiniMax China",
|
||||
fetchState: "ready",
|
||||
})
|
||||
expect(JSON.stringify(items)).not.toContain("sk-cp-shared")
|
||||
})
|
||||
|
||||
test("scopes the cache cell identity to the credential fingerprint", async () => {
|
||||
const fn = mock(() => Promise.resolve(response({ base_resp: { status_code: 0 }, model_remains: [] })))
|
||||
const seen: Array<{ id: string; identity?: string }> = []
|
||||
const cached = (id: string, load: () => Promise<ProviderUsage.UsageSnapshot>, identity?: string) => {
|
||||
seen.push({ id, identity })
|
||||
return load()
|
||||
}
|
||||
|
||||
await direct([candidate("minimax-coding-plan", "sk-cp-key-a")], fn as unknown as typeof fetch, cached)
|
||||
await direct([candidate("minimax-coding-plan", "sk-cp-key-b")], fn as unknown as typeof fetch, cached)
|
||||
|
||||
expect(seen.map((item) => item.id)).toEqual(["minimax-direct-global", "minimax-direct-global"])
|
||||
expect(seen[0].identity).toHaveLength(64)
|
||||
expect(seen[0].identity).not.toBe(seen[1].identity)
|
||||
expect(JSON.stringify(seen)).not.toContain("sk-cp")
|
||||
})
|
||||
|
||||
test("returns per-provider unavailable items when every query fails", async () => {
|
||||
const fn = mock(() => Promise.resolve(response({ message: "raw failure" }, 500)))
|
||||
const items = await direct(
|
||||
[candidate("minimax-coding-plan", "sk-cp-shared"), candidate("minimax-cn-coding-plan", "sk-cp-shared")],
|
||||
fn as unknown as typeof fetch,
|
||||
)
|
||||
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items.every((item) => item.fetchState === "unavailable")).toBe(true)
|
||||
expect(JSON.stringify(items)).not.toContain("raw failure")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,694 @@
|
||||
import { describe, expect, mock, setSystemTime } from "bun:test"
|
||||
import { Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
|
||||
import { Catalog } from "../src/catalog"
|
||||
import { Credential } from "../src/credential"
|
||||
import { Integration } from "../src/integration"
|
||||
import { ProviderUsage } from "../src/kilocode/provider-usage"
|
||||
import { PluginV2 } from "../src/plugin"
|
||||
import { ProviderV2 } from "../src/provider"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const provider = ProviderV2.ID.make("minimax-coding-plan")
|
||||
const chinaProvider = ProviderV2.ID.make("minimax-cn-coding-plan")
|
||||
const integration = Integration.ID.make("minimax-coding-plan")
|
||||
const chinaIntegration = Integration.ID.make("minimax-cn-coding-plan")
|
||||
const kilo = Integration.ID.make("kilo")
|
||||
|
||||
type CatalogInput = {
|
||||
apiKey?: string
|
||||
setting?: string
|
||||
header?: string
|
||||
headerName?: string
|
||||
organization?: string | (() => string | undefined)
|
||||
china?: boolean
|
||||
}
|
||||
|
||||
const catalog = (input?: CatalogInput) => {
|
||||
const providers = () => {
|
||||
const organization = typeof input?.organization === "function" ? input.organization() : input?.organization
|
||||
const info = ProviderV2.Info.make({
|
||||
id: provider,
|
||||
name: "MiniMax Global",
|
||||
api: { type: "native", settings: input?.setting ? { apiKey: input.setting } : {} },
|
||||
request: {
|
||||
headers: input?.header ? { [input.headerName ?? "x-api-key"]: input.header } : {},
|
||||
body: input?.apiKey ? { apiKey: input.apiKey } : {},
|
||||
},
|
||||
})
|
||||
const kiloInfo = ProviderV2.Info.make({
|
||||
id: ProviderV2.ID.kilo,
|
||||
name: "Kilo",
|
||||
api: { type: "native", settings: {} },
|
||||
request: { headers: {}, body: organization ? { kilocodeOrganizationId: organization } : {} },
|
||||
})
|
||||
const china = ProviderV2.Info.make({
|
||||
id: chinaProvider,
|
||||
name: "MiniMax China",
|
||||
api: { type: "native", settings: {} },
|
||||
request: { headers: {}, body: {} },
|
||||
})
|
||||
return input?.china ? [info, china, kiloInfo] : [info, kiloInfo]
|
||||
}
|
||||
return Layer.mock(Catalog.Service)({
|
||||
provider: {
|
||||
get: () => Effect.succeed(undefined),
|
||||
all: () => Effect.sync(providers),
|
||||
available: () => Effect.sync(providers),
|
||||
},
|
||||
model: {
|
||||
get: () => Effect.succeed(undefined),
|
||||
all: () => Effect.succeed([]),
|
||||
available: () => Effect.succeed([]),
|
||||
default: () => Effect.succeed(undefined),
|
||||
small: () => Effect.succeed(undefined),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type DirectInput = string | ((id: Integration.ID) => string | undefined) | undefined
|
||||
|
||||
const directValue = (input: DirectInput, id: Integration.ID) => (typeof input === "function" ? input(id) : input)
|
||||
|
||||
const connections = (input: DirectInput, accountID?: string, failure?: () => "global" | "china" | "kilo" | undefined) =>
|
||||
Layer.mock(Integration.Service)({
|
||||
connection: {
|
||||
active: (id) =>
|
||||
Effect.sync(() => {
|
||||
const direct = directValue(input, id)
|
||||
return id === kilo || ((id === integration || id === chinaIntegration) && direct)
|
||||
? {
|
||||
type: "credential" as const,
|
||||
id: Credential.ID.make(
|
||||
id === kilo ? "cred_kilo" : id === chinaIntegration ? "cred_direct_cn" : "cred_direct",
|
||||
),
|
||||
label: "test",
|
||||
}
|
||||
: undefined
|
||||
}),
|
||||
resolve: (connection) =>
|
||||
Effect.suspend(() => {
|
||||
const target =
|
||||
connection.type === "credential" && connection.id === "cred_direct_cn" ? chinaIntegration : integration
|
||||
const direct = directValue(input, target)
|
||||
const kind =
|
||||
connection.type === "credential" && connection.id === "cred_kilo"
|
||||
? "kilo"
|
||||
: target === chinaIntegration
|
||||
? "china"
|
||||
: "global"
|
||||
if (failure?.() === kind)
|
||||
return Effect.fail(new Integration.AuthorizationError({ cause: `${kind} credential failure` }))
|
||||
return Effect.succeed(
|
||||
kind === "kilo"
|
||||
? Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("oauth"),
|
||||
access: "cloud-token",
|
||||
refresh: "cloud-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
metadata: accountID ? { accountID } : undefined,
|
||||
})
|
||||
: direct
|
||||
? Credential.Key.make({ type: "key", key: direct })
|
||||
: undefined,
|
||||
)
|
||||
}),
|
||||
key: () => Effect.void,
|
||||
oauth: () => Effect.die("unused"),
|
||||
update: () => Effect.void,
|
||||
remove: () => Effect.void,
|
||||
},
|
||||
attempt: {
|
||||
status: () => Effect.die("unused"),
|
||||
complete: () => Effect.void,
|
||||
cancel: () => Effect.void,
|
||||
},
|
||||
})
|
||||
|
||||
const native = (remaining: number) =>
|
||||
Response.json({
|
||||
base_resp: { status_code: 0 },
|
||||
model_remains: [
|
||||
{
|
||||
model_name: "general",
|
||||
current_interval_remaining_percent: remaining,
|
||||
current_interval_status: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const subscription = {
|
||||
id: "byteplus-plan",
|
||||
planId: "byteplus-coding-plan-team-lite",
|
||||
planName: "BytePlus Coding Plan Lite",
|
||||
providerName: "BytePlus",
|
||||
providerId: "byteplus-coding",
|
||||
canQueryUsage: true,
|
||||
hasInstalledByokKey: true,
|
||||
status: "active" as const,
|
||||
cancelAtPeriodEnd: false,
|
||||
}
|
||||
|
||||
const byok = {
|
||||
id: "managed-byteplus",
|
||||
provider_id: "byteplus-coding",
|
||||
management_source: "coding_plan" as const,
|
||||
is_enabled: true,
|
||||
}
|
||||
|
||||
const transport = (calls: { direct: number; cloud: number }, remaining = 80) =>
|
||||
Layer.succeed(ProviderUsage.Transport, {
|
||||
fetch: mock(() => {
|
||||
calls.direct++
|
||||
return Promise.resolve(native(remaining))
|
||||
}) as unknown as typeof fetch,
|
||||
plans: async () => {
|
||||
calls.cloud++
|
||||
return []
|
||||
},
|
||||
byok: async () => [],
|
||||
usage: async () => {
|
||||
throw new Error("unused")
|
||||
},
|
||||
})
|
||||
|
||||
const plugins = Layer.mock(PluginV2.Service)({
|
||||
add: () => Effect.void,
|
||||
remove: () => Effect.void,
|
||||
wait: () => Effect.void,
|
||||
})
|
||||
|
||||
const configuredLayer = (input: {
|
||||
calls: { direct: number; cloud: number }
|
||||
direct?: DirectInput
|
||||
accountID?: string
|
||||
config?: CatalogInput
|
||||
failure?: () => "global" | "china" | "kilo" | undefined
|
||||
transport?: ProviderUsage.TransportInterface
|
||||
}) =>
|
||||
Layer.fresh(ProviderUsage.layer).pipe(
|
||||
Layer.provide(catalog(input.config)),
|
||||
Layer.provide(connections(input.direct, input.accountID, input.failure)),
|
||||
Layer.provide(input.transport ? Layer.succeed(ProviderUsage.Transport, input.transport) : transport(input.calls)),
|
||||
Layer.provide(plugins),
|
||||
)
|
||||
|
||||
const layer = (
|
||||
calls: { direct: number; cloud: number },
|
||||
direct: DirectInput = "sk-cp-direct",
|
||||
accountID?: string,
|
||||
config?: CatalogInput,
|
||||
failure?: () => "global" | "china" | "kilo" | undefined,
|
||||
) => configuredLayer({ calls, direct, accountID, config, failure })
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
const service = Effect.fn("ProviderUsageTest.service")(function* (service: ProviderUsage.Interface) {
|
||||
const first = yield* service.get()
|
||||
const cached = yield* service.get()
|
||||
const refreshed = yield* service.refresh()
|
||||
return { first, cached, refreshed }
|
||||
})
|
||||
|
||||
describe("ProviderUsage location service", () => {
|
||||
it.live("caches one location and forces every source on refresh", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls = { direct: 0, cloud: 0 }
|
||||
const scope = yield* Scope.make()
|
||||
const usage = Context.get(yield* Layer.buildWithScope(layer(calls), scope), ProviderUsage.Service)
|
||||
|
||||
const result = yield* service(usage)
|
||||
|
||||
expect(result.first.items).toHaveLength(1)
|
||||
expect(result.cached).toEqual(result.first)
|
||||
expect(result.refreshed.items).toHaveLength(1)
|
||||
expect(calls).toEqual({ direct: 2, cloud: 2 })
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("isolates state between location-layer instances", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstCalls = { direct: 0, cloud: 0 }
|
||||
const secondCalls = { direct: 0, cloud: 0 }
|
||||
const firstScope = yield* Scope.make()
|
||||
const secondScope = yield* Scope.make()
|
||||
const first = Context.get(yield* Layer.buildWithScope(layer(firstCalls), firstScope), ProviderUsage.Service)
|
||||
const second = Context.get(yield* Layer.buildWithScope(layer(secondCalls), secondScope), ProviderUsage.Service)
|
||||
|
||||
expect((yield* first.get()).items[0]?.windows[0]?.remaining).toBe(80)
|
||||
expect((yield* second.get()).items[0]?.windows[0]?.remaining).toBe(80)
|
||||
expect(firstCalls.direct).toBe(1)
|
||||
expect(secondCalls.direct).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("suppresses personal Cloud calls for organization OAuth", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls = { direct: 0, cloud: 0 }
|
||||
const scope = yield* Scope.make()
|
||||
const usage = Context.get(
|
||||
yield* Layer.buildWithScope(layer(calls, "sk-cp-direct", "org"), scope),
|
||||
ProviderUsage.Service,
|
||||
)
|
||||
|
||||
expect((yield* usage.get()).items).toHaveLength(1)
|
||||
expect(calls).toEqual({ direct: 1, cloud: 0 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("uses every canonical config-defined coding-plan key location", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const config of [
|
||||
{ apiKey: "sk-cp-body" },
|
||||
{ setting: "sk-cp-setting" },
|
||||
{ header: "sk-cp-header", headerName: "X-API-Key" },
|
||||
]) {
|
||||
const calls = { direct: 0, cloud: 0 }
|
||||
const scope = yield* Scope.make()
|
||||
const usage = Context.get(
|
||||
yield* Layer.buildWithScope(layer(calls, undefined, "org", config), scope),
|
||||
ProviderUsage.Service,
|
||||
)
|
||||
expect((yield* usage.get()).items[0]?.providerID).toBe("minimax-coding-plan")
|
||||
expect(calls.direct).toBe(1)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("suppresses personal Cloud calls for configured organization routing", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls = { direct: 0, cloud: 0 }
|
||||
const scope = yield* Scope.make()
|
||||
const usage = Context.get(
|
||||
yield* Layer.buildWithScope(layer(calls, "sk-cp-direct", undefined, { organization: "org" }), scope),
|
||||
ProviderUsage.Service,
|
||||
)
|
||||
|
||||
expect((yield* usage.get()).items).toHaveLength(1)
|
||||
expect(calls).toEqual({ direct: 1, cloud: 0 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ignores empty configured organization routing", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls = { direct: 0, cloud: 0 }
|
||||
const scope = yield* Scope.make()
|
||||
const usage = Context.get(
|
||||
yield* Layer.buildWithScope(layer(calls, "sk-cp-direct", undefined, { organization: " " }), scope),
|
||||
ProviderUsage.Service,
|
||||
)
|
||||
|
||||
expect((yield* usage.get()).items).toHaveLength(1)
|
||||
expect(calls).toEqual({ direct: 1, cloud: 1 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("replaces cached direct usage when the credential changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls = { direct: 0, cloud: 0 }
|
||||
let key = "sk-cp-first"
|
||||
const scope = yield* Scope.make()
|
||||
const usage = Context.get(
|
||||
yield* Layer.buildWithScope(
|
||||
layer(calls, () => key, "org"),
|
||||
scope,
|
||||
),
|
||||
ProviderUsage.Service,
|
||||
)
|
||||
|
||||
expect((yield* usage.get()).items[0]?.windows[0]?.remaining).toBe(80)
|
||||
key = "sk-cp-second"
|
||||
expect((yield* usage.get()).items[0]?.windows[0]?.remaining).toBe(80)
|
||||
expect(calls.direct).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("coalesces a forced refresh with an in-flight read", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls = { direct: 0, cloud: 0 }
|
||||
const started = yield* Deferred.make<void>()
|
||||
let release!: (value: Response) => void
|
||||
const response = new Promise<Response>((resolve) => (release = resolve))
|
||||
const scope = yield* Scope.make()
|
||||
const usage = Context.get(
|
||||
yield* Layer.buildWithScope(
|
||||
configuredLayer({
|
||||
calls,
|
||||
direct: "sk-cp-direct",
|
||||
accountID: "org",
|
||||
transport: {
|
||||
fetch: (() => {
|
||||
calls.direct++
|
||||
Effect.runSync(Deferred.succeed(started, undefined))
|
||||
return response
|
||||
}) as unknown as typeof fetch,
|
||||
plans: async () => [],
|
||||
byok: async () => [],
|
||||
usage: async () => {
|
||||
throw new Error("unused")
|
||||
},
|
||||
},
|
||||
}),
|
||||
scope,
|
||||
),
|
||||
ProviderUsage.Service,
|
||||
)
|
||||
|
||||
const first = yield* usage.get().pipe(Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
const second = yield* usage.refresh().pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
release(native(80))
|
||||
|
||||
expect((yield* Fiber.join(first)).items).toHaveLength(1)
|
||||
expect((yield* Fiber.join(second)).items).toHaveLength(1)
|
||||
expect(calls.direct).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("serves stale data after a failed refresh and recovers on retry", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls = { direct: 0, cloud: 0 }
|
||||
const responses = [native(80), new Response("private upstream error", { status: 503 }), native(70)]
|
||||
const scope = yield* Scope.make()
|
||||
const usage = Context.get(
|
||||
yield* Layer.buildWithScope(
|
||||
configuredLayer({
|
||||
calls,
|
||||
direct: "sk-cp-direct",
|
||||
accountID: "org",
|
||||
transport: {
|
||||
fetch: mock(() => {
|
||||
calls.direct++
|
||||
return Promise.resolve(responses.shift()!)
|
||||
}) as unknown as typeof fetch,
|
||||
plans: async () => [],
|
||||
byok: async () => [],
|
||||
usage: async () => {
|
||||
throw new Error("unused")
|
||||
},
|
||||
},
|
||||
}),
|
||||
scope,
|
||||
),
|
||||
ProviderUsage.Service,
|
||||
)
|
||||
|
||||
const ready = yield* usage.get()
|
||||
const stale = yield* usage.refresh()
|
||||
const recovered = yield* usage.refresh()
|
||||
|
||||
expect(ready.items[0]).toMatchObject({ fetchState: "ready", windows: [{ remaining: 80 }] })
|
||||
expect(stale.items[0]).toMatchObject({ fetchState: "stale", windows: [{ remaining: 80 }] })
|
||||
expect(recovered.items[0]).toMatchObject({ fetchState: "ready", windows: [{ remaining: 70 }] })
|
||||
expect(JSON.stringify(stale)).not.toContain("private upstream error")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("expires the success cache and retries failures on the shorter error TTL", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls = { direct: 0, cloud: 0 }
|
||||
const responses = [native(80), new Response("upstream failure", { status: 503 }), native(70)]
|
||||
const base = Date.parse("2026-08-12T00:00:00.000Z")
|
||||
setSystemTime(new Date(base))
|
||||
const scope = yield* Scope.make()
|
||||
const usage = Context.get(
|
||||
yield* Layer.buildWithScope(
|
||||
configuredLayer({
|
||||
calls,
|
||||
direct: "sk-cp-direct",
|
||||
accountID: "org",
|
||||
transport: {
|
||||
fetch: mock(() => {
|
||||
calls.direct++
|
||||
return Promise.resolve(responses.shift()!)
|
||||
}) as unknown as typeof fetch,
|
||||
plans: async () => [],
|
||||
byok: async () => [],
|
||||
usage: async () => {
|
||||
throw new Error("unused")
|
||||
},
|
||||
},
|
||||
}),
|
||||
scope,
|
||||
),
|
||||
ProviderUsage.Service,
|
||||
)
|
||||
|
||||
expect((yield* usage.get()).items[0]).toMatchObject({ fetchState: "ready", windows: [{ remaining: 80 }] })
|
||||
expect((yield* usage.get()).items[0]).toMatchObject({ fetchState: "ready" })
|
||||
expect(calls.direct).toBe(1)
|
||||
|
||||
// Success TTL (60s) elapsed: the next get refetches and degrades to stale on failure.
|
||||
setSystemTime(new Date(base + 61_000))
|
||||
expect((yield* usage.get()).items[0]).toMatchObject({ fetchState: "stale", windows: [{ remaining: 80 }] })
|
||||
expect((yield* usage.get()).items[0]).toMatchObject({ fetchState: "stale" })
|
||||
expect(calls.direct).toBe(2)
|
||||
|
||||
// Error TTL (10s) elapsed: the failure is retried and recovers.
|
||||
setSystemTime(new Date(base + 72_000))
|
||||
expect((yield* usage.get()).items[0]).toMatchObject({ fetchState: "ready", windows: [{ remaining: 70 }] })
|
||||
expect(calls.direct).toBe(3)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => setSystemTime()))),
|
||||
)
|
||||
|
||||
it.live("prunes authoritative removal but preserves a transient credential failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const removedCalls = { direct: 0, cloud: 0 }
|
||||
let removedKey: string | undefined = "sk-cp-present"
|
||||
const removedScope = yield* Scope.make()
|
||||
const removed = Context.get(
|
||||
yield* Layer.buildWithScope(
|
||||
layer(removedCalls, () => removedKey, "org"),
|
||||
removedScope,
|
||||
),
|
||||
ProviderUsage.Service,
|
||||
)
|
||||
expect((yield* removed.get()).items).toHaveLength(1)
|
||||
removedKey = undefined
|
||||
expect((yield* removed.get()).items).toEqual([])
|
||||
|
||||
const failedCalls = { direct: 0, cloud: 0 }
|
||||
let failure: "global" | undefined
|
||||
const failedScope = yield* Scope.make()
|
||||
const failed = Context.get(
|
||||
yield* Layer.buildWithScope(
|
||||
layer(failedCalls, "sk-cp-present", "org", undefined, () => failure),
|
||||
failedScope,
|
||||
),
|
||||
ProviderUsage.Service,
|
||||
)
|
||||
expect((yield* failed.get()).items).toHaveLength(1)
|
||||
failure = "global"
|
||||
expect((yield* failed.get()).items[0]).toMatchObject({ fetchState: "stale" })
|
||||
expect(failedCalls.direct).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("preserves only the failed direct provider while pruning a removed sibling", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls = { direct: 0, cloud: 0 }
|
||||
let chinaKey: string | undefined = "sk-cp-china"
|
||||
let failure: "global" | undefined
|
||||
const scope = yield* Scope.make()
|
||||
const usage = Context.get(
|
||||
yield* Layer.buildWithScope(
|
||||
configuredLayer({
|
||||
calls,
|
||||
accountID: "org",
|
||||
config: { china: true, apiKey: "sk-cp-fallback" },
|
||||
direct: (id) => (id === chinaIntegration ? chinaKey : "sk-cp-global"),
|
||||
failure: () => failure,
|
||||
}),
|
||||
scope,
|
||||
),
|
||||
ProviderUsage.Service,
|
||||
)
|
||||
|
||||
expect((yield* usage.get()).items).toHaveLength(2)
|
||||
failure = "global"
|
||||
chinaKey = undefined
|
||||
const result = yield* usage.get()
|
||||
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]).toMatchObject({ providerID: "minimax-coding-plan", fetchState: "stale" })
|
||||
expect(calls.direct).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps same-key providers independent when one credential fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls = { direct: 0, cloud: 0 }
|
||||
let failure: "global" | undefined
|
||||
const scope = yield* Scope.make()
|
||||
const usage = Context.get(
|
||||
yield* Layer.buildWithScope(
|
||||
configuredLayer({
|
||||
calls,
|
||||
accountID: "org",
|
||||
config: { china: true },
|
||||
direct: "sk-cp-shared",
|
||||
failure: () => failure,
|
||||
}),
|
||||
scope,
|
||||
),
|
||||
ProviderUsage.Service,
|
||||
)
|
||||
|
||||
expect((yield* usage.get()).items).toHaveLength(2)
|
||||
failure = "global"
|
||||
const result = yield* usage.get()
|
||||
|
||||
expect(result.items).toHaveLength(2)
|
||||
expect(result.items.find((item) => item.id === "minimax-direct-global")).toMatchObject({ fetchState: "stale" })
|
||||
expect(result.items.find((item) => item.id === "minimax-direct-china")).toMatchObject({ fetchState: "ready" })
|
||||
// The surviving sibling serves from cache; the failed one is not refetched.
|
||||
expect(calls.direct).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refreshes a rotated credential while preserving the failed sibling's cached usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls = { direct: 0, cloud: 0 }
|
||||
let failure: "global" | undefined
|
||||
let key = "sk-cp-shared"
|
||||
const scope = yield* Scope.make()
|
||||
const usage = Context.get(
|
||||
yield* Layer.buildWithScope(
|
||||
configuredLayer({
|
||||
calls,
|
||||
accountID: "org",
|
||||
config: { china: true },
|
||||
direct: () => key,
|
||||
failure: () => failure,
|
||||
}),
|
||||
scope,
|
||||
),
|
||||
ProviderUsage.Service,
|
||||
)
|
||||
|
||||
expect((yield* usage.get()).items).toHaveLength(2)
|
||||
failure = "global"
|
||||
key = "sk-cp-rotated"
|
||||
const result = yield* usage.get()
|
||||
|
||||
expect(result.items).toHaveLength(2)
|
||||
expect(result.items.find((item) => item.id === "minimax-direct-global")).toMatchObject({ fetchState: "stale" })
|
||||
expect(result.items.find((item) => item.id === "minimax-direct-china")).toMatchObject({ fetchState: "ready" })
|
||||
expect(calls.direct).toBe(3)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("omits a failed provider with no cached usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls = { direct: 0, cloud: 0 }
|
||||
const scope = yield* Scope.make()
|
||||
const usage = Context.get(
|
||||
yield* Layer.buildWithScope(
|
||||
configuredLayer({
|
||||
calls,
|
||||
accountID: "org",
|
||||
config: { china: true },
|
||||
direct: "sk-cp-shared",
|
||||
failure: () => "global" as const,
|
||||
}),
|
||||
scope,
|
||||
),
|
||||
ProviderUsage.Service,
|
||||
)
|
||||
|
||||
const result = yield* usage.get()
|
||||
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0]).toMatchObject({ id: "minimax-direct-china", fetchState: "ready" })
|
||||
expect(calls.direct).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("preserves managed usage across metadata and credential failures while direct usage refreshes", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls = { direct: 0, cloud: 0 }
|
||||
let byokFailure = false
|
||||
let usageFailure = false
|
||||
let credentialFailure: "kilo" | undefined
|
||||
let organization: string | undefined
|
||||
const scope = yield* Scope.make()
|
||||
const usage = Context.get(
|
||||
yield* Layer.buildWithScope(
|
||||
configuredLayer({
|
||||
calls,
|
||||
direct: "sk-cp-direct",
|
||||
config: { organization: () => organization },
|
||||
failure: () => credentialFailure,
|
||||
transport: {
|
||||
fetch: mock(() => {
|
||||
calls.direct++
|
||||
return Promise.resolve(native(80))
|
||||
}) as unknown as typeof fetch,
|
||||
plans: async () => {
|
||||
calls.cloud++
|
||||
return [subscription]
|
||||
},
|
||||
byok: async () => {
|
||||
if (byokFailure) throw new Error("private metadata failure")
|
||||
return [byok]
|
||||
},
|
||||
usage: async () => {
|
||||
if (usageFailure) throw new Error("private usage failure")
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
fetchedAt: "2026-08-09T12:00:00.000Z",
|
||||
subscription: {
|
||||
id: subscription.id,
|
||||
planName: subscription.planName,
|
||||
providerId: subscription.providerId,
|
||||
providerName: subscription.providerName,
|
||||
windows: [
|
||||
{
|
||||
id: "monthly",
|
||||
remainingPercent: 75,
|
||||
resetsAt: "2026-09-01T00:00:00.000Z",
|
||||
period: { unit: "month", value: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
scope,
|
||||
),
|
||||
ProviderUsage.Service,
|
||||
)
|
||||
|
||||
expect((yield* usage.get()).items).toHaveLength(2)
|
||||
byokFailure = true
|
||||
const partial = yield* usage.refresh()
|
||||
// Discovery failure retains the last good Cloud state, so usage still refreshes.
|
||||
expect(partial.items.find((item) => item.sourceKind === "kilo_managed")).toMatchObject({ fetchState: "ready" })
|
||||
expect(partial.items.find((item) => item.sourceKind === "direct")).toMatchObject({ fetchState: "ready" })
|
||||
expect(JSON.stringify(partial)).not.toContain("private metadata failure")
|
||||
|
||||
usageFailure = true
|
||||
const degraded = yield* usage.refresh()
|
||||
expect(degraded.items.find((item) => item.sourceKind === "kilo_managed")).toMatchObject({ fetchState: "stale" })
|
||||
expect(JSON.stringify(degraded)).not.toContain("private usage failure")
|
||||
|
||||
byokFailure = false
|
||||
usageFailure = false
|
||||
credentialFailure = "kilo"
|
||||
const credential = yield* usage.get()
|
||||
expect(credential.items.find((item) => item.sourceKind === "kilo_managed")).toMatchObject({
|
||||
fetchState: "stale",
|
||||
})
|
||||
expect(credential.items.find((item) => item.sourceKind === "direct")).toBeDefined()
|
||||
|
||||
organization = "org"
|
||||
const organizationResult = yield* usage.get()
|
||||
expect(organizationResult.items.find((item) => item.sourceKind === "kilo_managed")).toBeUndefined()
|
||||
expect(organizationResult.items.find((item) => item.sourceKind === "direct")).toBeDefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f3bb2c260c3294003140fe03f1732915536aab2733151ac17e27bf2a7c40dff2
|
||||
size 23882
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2f353022aa26937291c9d2d3a34bba222e87695e609cc5de4f98cbe57e90a7c0
|
||||
size 19828
|
||||
oid sha256:9b4c9589d542c89b2f2ce43773088e144da6fba554da790251df9b7df70326a2
|
||||
size 33911
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f5fe1c7f2960984736863aac8dc720eff1cf60cc5b1991e966740b395565b8ad
|
||||
size 16069
|
||||
oid sha256:b5807f0f023b36cf7f2d85a2891108275a513c31fa2cc6b76b6356e6ec1dba3b
|
||||
size 33844
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b70dcaf2b3f89e0644fb47273881076cbb84cf5d36ce506685689968edb021dc
|
||||
size 5527
|
||||
oid sha256:4ff5dbb499e51fa149032b492a6d9a61ec2f54ddd5af3d3f786794d9bbee3d20
|
||||
size 17803
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a634c466f2d854a245c210b695f86363ec9bfe76cbf4cb2c1e1baf174e010935
|
||||
size 22571
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:65c9606f05c45b1f143e6283c41a667322d13d728c6c728e627c8075a1df1fc0
|
||||
size 22986
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:445a492fadd3bda00cb28c4942ea57e003be9788bd920414a20f75491a8ea817
|
||||
size 38665
|
||||
@@ -21,6 +21,7 @@
|
||||
"./fim": "./src/fim.ts",
|
||||
"./edit": "./src/edit.ts",
|
||||
"./edit-prompt": "./src/edit-prompt.ts",
|
||||
"./provider-usage": "./src/provider-usage.ts",
|
||||
"./tui": "./src/tui.ts"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -10,12 +10,17 @@ function num(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
// Cloud returns the full subscription record even after cancellation; only
|
||||
// these statuses represent a pass the user can actually consume.
|
||||
const live = new Set(["active", "past_due", "trialing"])
|
||||
|
||||
export function parseKiloPassState(value: unknown): KiloPassState | null {
|
||||
const item = Array.isArray(value) ? value[0] : value
|
||||
const data = record(record(record(item)?.result)?.data)
|
||||
const root = record(data?.json) ?? data ?? record(value)
|
||||
const sub = record(root?.subscription)
|
||||
if (!sub || (sub.currentPeriodBaseCreditsUsd == null && sub.currentPeriodUsageUsd == null)) return null
|
||||
if (typeof sub.status === "string" && !live.has(sub.status)) return null
|
||||
|
||||
const next = sub.nextBillingAt ?? sub.nextRenewalAt
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { z } from "zod"
|
||||
import { buildKiloHeaders } from "../headers.js"
|
||||
import { KILO_API_BASE } from "./constants.js"
|
||||
|
||||
const timeout = 5_000
|
||||
const limit = 512 * 1024
|
||||
|
||||
const CodingPlanSubscriptionSchema = z.object({
|
||||
id: z.string(),
|
||||
planId: z.string(),
|
||||
planName: z.string(),
|
||||
providerName: z.string(),
|
||||
providerId: z.string(),
|
||||
canQueryUsage: z.boolean(),
|
||||
hasInstalledByokKey: z.boolean(),
|
||||
status: z.enum(["active", "past_due", "canceled"]),
|
||||
cancelAtPeriodEnd: z.boolean(),
|
||||
})
|
||||
|
||||
const ByokEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
provider_id: z.string(),
|
||||
management_source: z.enum(["user", "coding_plan"]),
|
||||
is_enabled: z.boolean(),
|
||||
})
|
||||
|
||||
const CodingPlanQuotaWindowSchema = z.object({
|
||||
id: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-z][a-z0-9_]*$/),
|
||||
remainingPercent: z.number().finite().nonnegative(),
|
||||
resetsAt: z.iso.datetime(),
|
||||
startsAt: z.iso.datetime().optional(),
|
||||
period: z.object({
|
||||
unit: z.enum(["hour", "day", "week", "month"]),
|
||||
value: z.number().int().positive(),
|
||||
}),
|
||||
})
|
||||
|
||||
const CodingPlanQuotaWindowsSchema = z
|
||||
.array(CodingPlanQuotaWindowSchema)
|
||||
.min(1)
|
||||
.max(16)
|
||||
.superRefine((windows, ctx) => {
|
||||
const ids = new Set<string>()
|
||||
for (const [index, window] of windows.entries()) {
|
||||
if (ids.has(window.id)) {
|
||||
ctx.addIssue({ code: "custom", message: "Quota window IDs must be unique.", path: [index, "id"] })
|
||||
}
|
||||
ids.add(window.id)
|
||||
}
|
||||
})
|
||||
|
||||
export const CodingPlanUsageSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
fetchedAt: z.iso.datetime(),
|
||||
subscription: z.object({
|
||||
id: z.string(),
|
||||
planName: z.string().min(1),
|
||||
providerId: z.string().min(1),
|
||||
providerName: z.string().min(1),
|
||||
windows: CodingPlanQuotaWindowsSchema,
|
||||
}),
|
||||
})
|
||||
|
||||
const envelope = z.object({
|
||||
result: z.object({ data: z.unknown() }).optional(),
|
||||
error: z.unknown().optional(),
|
||||
})
|
||||
|
||||
export type CodingPlanSubscription = z.infer<typeof CodingPlanSubscriptionSchema>
|
||||
export type ByokEntry = z.infer<typeof ByokEntrySchema>
|
||||
export type CodingPlanUsage = z.infer<typeof CodingPlanUsageSchema>
|
||||
export type CodingPlanQuotaWindow = z.infer<typeof CodingPlanQuotaWindowSchema>
|
||||
|
||||
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<T>(procedure: string, token: string, schema: z.ZodType<T>, input?: unknown): Promise<T> {
|
||||
const params = new URLSearchParams()
|
||||
if (input !== undefined) params.set("input", JSON.stringify(input))
|
||||
const suffix = params.size ? `?${params.toString()}` : ""
|
||||
const response = await fetch(`${KILO_API_BASE}/api/trpc/${procedure}${suffix}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...buildKiloHeaders(),
|
||||
},
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
}).catch(() => {
|
||||
throw new CloudTrpcError("network")
|
||||
})
|
||||
|
||||
const body = await read(response).catch((error) => {
|
||||
if (error instanceof CloudTrpcError) throw error
|
||||
throw new CloudTrpcError("protocol", response.status)
|
||||
})
|
||||
|
||||
const parsed = (() => {
|
||||
try {
|
||||
return envelope.parse(JSON.parse(body))
|
||||
} catch {
|
||||
throw new CloudTrpcError("protocol", response.status)
|
||||
}
|
||||
})()
|
||||
if (parsed.error != null) throw new CloudTrpcError("procedure", response.status)
|
||||
if (!response.ok) throw new CloudTrpcError("http", response.status)
|
||||
if (!parsed.result) throw new CloudTrpcError("protocol", response.status)
|
||||
|
||||
const data = parsed.result.data
|
||||
const value = typeof data === "object" && data !== null && "json" in data ? (data as { json: unknown }).json : data
|
||||
const result = schema.safeParse(value)
|
||||
if (!result.success) throw new CloudTrpcError("schema", response.status)
|
||||
return result.data
|
||||
}
|
||||
|
||||
export function fetchCodingPlanSubscriptions(token: string) {
|
||||
return query("codingPlans.listSubscriptions", token, z.array(CodingPlanSubscriptionSchema))
|
||||
}
|
||||
|
||||
export function fetchByokEntries(token: string) {
|
||||
return query("byok.list", token, z.array(ByokEntrySchema), {})
|
||||
}
|
||||
|
||||
export async function fetchCodingPlanUsage(token: string, subscriptionId: string) {
|
||||
const usage = await query("codingPlans.getUsage", token, CodingPlanUsageSchema, { subscriptionId })
|
||||
if (usage.subscription.id !== subscriptionId) throw new CloudTrpcError("schema")
|
||||
return usage
|
||||
}
|
||||
@@ -70,6 +70,14 @@ export {
|
||||
type OrganizationModeConfig,
|
||||
} from "./api/modes.js"
|
||||
export { fetchKilocodeNotifications, type KilocodeNotification } from "./api/notifications.js"
|
||||
export {
|
||||
fetchByokEntries,
|
||||
fetchCodingPlanSubscriptions,
|
||||
fetchCodingPlanUsage,
|
||||
type ByokEntry,
|
||||
type CodingPlanSubscription,
|
||||
type CodingPlanQuotaWindow,
|
||||
} from "./api/trpc.js"
|
||||
export {
|
||||
fetchCloudSession,
|
||||
fetchCloudSessionForImport,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Shared display formatting for provider usage windows.
|
||||
*
|
||||
* This is the single source of truth for how a quota window is presented.
|
||||
* Both the TUI dialog (packages/opencode) and the VS Code webview consume it
|
||||
* so the two surfaces can never drift; labels are injectable for i18n.
|
||||
*/
|
||||
|
||||
export interface UsagePeriodLike {
|
||||
unit: "hour" | "day" | "week" | "month"
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface UsageWindowLike {
|
||||
state: "active" | "exhausted" | "unlimited" | "not_in_plan" | "unknown"
|
||||
orientation: "used_percent" | "remaining_percent" | "amount" | "count"
|
||||
unit: string
|
||||
resource: string
|
||||
period?: UsagePeriodLike
|
||||
used?: number
|
||||
remaining?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface UsageLabels {
|
||||
unlimited: string
|
||||
notInPlan: string
|
||||
unknown: string
|
||||
exhausted: string
|
||||
used(value: string): string
|
||||
remaining(value: string): string
|
||||
remainingOf(value: string, limit: string): string
|
||||
usedOf(value: string, limit: string): string
|
||||
quota: string
|
||||
daily: string
|
||||
weekly: string
|
||||
monthly: string
|
||||
hours(count: number): string
|
||||
days(count: number): string
|
||||
weeks(count: number): string
|
||||
months(count: number): string
|
||||
/** Display name for MiniMax's pooled "general" resource. */
|
||||
shared: string
|
||||
scoped(resource: string, period: string): string
|
||||
}
|
||||
|
||||
export const english: UsageLabels = {
|
||||
unlimited: "Unlimited",
|
||||
notInPlan: "Not in plan",
|
||||
unknown: "Unknown",
|
||||
exhausted: "Exhausted",
|
||||
used: (value) => `${value} used`,
|
||||
remaining: (value) => `${value} remaining`,
|
||||
remainingOf: (value, limit) => `${value} of ${limit} remaining`,
|
||||
usedOf: (value, limit) => `${value} of ${limit} used`,
|
||||
quota: "Quota",
|
||||
daily: "Daily quota",
|
||||
weekly: "Weekly quota",
|
||||
monthly: "Monthly quota",
|
||||
hours: (count) => `${count}-hour quota`,
|
||||
days: (count) => `${count}-day quota`,
|
||||
weeks: (count) => `${count}-week quota`,
|
||||
months: (count) => `${count}-month quota`,
|
||||
shared: "Shared",
|
||||
scoped: (resource, period) => `${resource} · ${period}`,
|
||||
}
|
||||
|
||||
const period = (value: UsagePeriodLike, labels: UsageLabels) => {
|
||||
if (value.unit === "hour") return labels.hours(value.value)
|
||||
if (value.unit === "day") return value.value === 1 ? labels.daily : labels.days(value.value)
|
||||
if (value.unit === "week") return value.value === 1 ? labels.weekly : labels.weeks(value.value)
|
||||
return value.value === 1 ? labels.monthly : labels.months(value.value)
|
||||
}
|
||||
|
||||
export const windowLabel = (window: UsageWindowLike, labels: UsageLabels = english) => {
|
||||
const phrase = window.period ? period(window.period, labels) : labels.quota
|
||||
// Plan-level windows ("subscription") are the whole card; named resources prefix theirs.
|
||||
if (window.resource === "subscription") return phrase
|
||||
return labels.scoped(window.resource === "general" ? labels.shared : window.resource, phrase)
|
||||
}
|
||||
|
||||
const number = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 2 })
|
||||
|
||||
const amount = (value: number, unit: string) => {
|
||||
if (unit === "USD") return `$${value.toFixed(2)}`
|
||||
if (unit === "percent") return `${number(value)}%`
|
||||
if (unit === "count") return number(value)
|
||||
return `${number(value)} ${unit}`
|
||||
}
|
||||
|
||||
export const formatWindow = (window: UsageWindowLike, labels: UsageLabels = english) => {
|
||||
if (window.state === "unlimited") return labels.unlimited
|
||||
if (window.state === "not_in_plan") return labels.notInPlan
|
||||
if (window.state === "unknown") return labels.unknown
|
||||
if (window.orientation === "used_percent" && window.used !== undefined) return labels.used(`${number(window.used)}%`)
|
||||
if (window.orientation === "remaining_percent" && window.remaining !== undefined)
|
||||
return labels.remaining(`${number(window.remaining)}%`)
|
||||
if (window.remaining !== undefined && window.limit !== undefined)
|
||||
return labels.remainingOf(amount(window.remaining, window.unit), amount(window.limit, window.unit))
|
||||
if (window.used !== undefined && window.limit !== undefined)
|
||||
return labels.usedOf(amount(window.used, window.unit), amount(window.limit, window.unit))
|
||||
return window.state === "exhausted" ? labels.exhausted : labels.unknown
|
||||
}
|
||||
|
||||
export const windowProgress = (window: UsageWindowLike) => {
|
||||
if (window.limit === undefined || window.limit <= 0) return undefined
|
||||
if (window.used !== undefined) return Math.min(100, Math.max(0, (window.used / window.limit) * 100))
|
||||
if (window.remaining !== undefined) return Math.min(100, Math.max(0, 100 - (window.remaining / window.limit) * 100))
|
||||
return undefined
|
||||
}
|
||||
@@ -61,6 +61,30 @@ describe("parseKiloPassState", () => {
|
||||
expect(parseKiloPassState({ status: "none" })).toBeNull()
|
||||
})
|
||||
|
||||
test("hides canceled and expired subscriptions that still report period credits", () => {
|
||||
const payload = (status: string) => [
|
||||
{
|
||||
result: {
|
||||
data: {
|
||||
subscription: {
|
||||
tier: "tier_19",
|
||||
status,
|
||||
cancelAtPeriodEnd: true,
|
||||
currentPeriodBaseCreditsUsd: 19,
|
||||
currentPeriodUsageUsd: 0,
|
||||
currentPeriodBonusCreditsUsd: null,
|
||||
nextBillingAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
expect(parseKiloPassState(payload("canceled"))).toBeNull()
|
||||
expect(parseKiloPassState(payload("expired"))).toBeNull()
|
||||
expect(parseKiloPassState(payload("past_due"))).toMatchObject({ currentPeriodBaseCreditsUsd: 19 })
|
||||
})
|
||||
|
||||
test("silently ignores transport failures", async () => {
|
||||
const prev = global.fetch
|
||||
const warn = spyOn(console, "warn").mockImplementation(() => undefined)
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||
import {
|
||||
CloudTrpcError,
|
||||
fetchByokEntries,
|
||||
fetchCodingPlanSubscriptions,
|
||||
fetchCodingPlanUsage,
|
||||
} from "../../src/api/trpc"
|
||||
|
||||
const original = global.fetch
|
||||
|
||||
const result = (data: unknown, status = 200) =>
|
||||
new Response(JSON.stringify({ result: { data: { json: data } } }), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
|
||||
const subscription = {
|
||||
id: "subscription",
|
||||
planId: "minimax-token-plan-plus",
|
||||
planName: "Token Plan Plus",
|
||||
providerName: "MiniMax",
|
||||
providerId: "minimax",
|
||||
canQueryUsage: true,
|
||||
hasInstalledByokKey: true,
|
||||
status: "active",
|
||||
cancelAtPeriodEnd: false,
|
||||
}
|
||||
|
||||
const quota = (id = "plan") => ({
|
||||
schemaVersion: 1,
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
subscription: {
|
||||
id,
|
||||
planName: "Token Plan Plus",
|
||||
providerId: "minimax",
|
||||
providerName: "MiniMax",
|
||||
windows: [
|
||||
{
|
||||
id: "short_term",
|
||||
remainingPercent: 80,
|
||||
resetsAt: "2026-06-19T05:00:00.000Z",
|
||||
period: { unit: "hour", value: 5 },
|
||||
},
|
||||
{
|
||||
id: "weekly",
|
||||
remainingPercent: 150,
|
||||
resetsAt: "2026-06-26T00:00:00.000Z",
|
||||
period: { unit: "week", value: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = original
|
||||
})
|
||||
|
||||
describe("Cloud tRPC client", () => {
|
||||
test("uses unbatched GET queries without an organization header", async () => {
|
||||
const fn = mock(() =>
|
||||
Promise.resolve(
|
||||
result([
|
||||
{
|
||||
...subscription,
|
||||
routeLabel: "MiniMax via Kilo Gateway",
|
||||
billingPeriodDays: 30,
|
||||
currentPeriodStart: "2026-06-01T00:00:00.000Z",
|
||||
currentPeriodEnd: "2026-07-01T00:00:00.000Z",
|
||||
creditRenewalAt: "2026-07-01T00:00:00.000Z",
|
||||
paymentGraceExpiresAt: null,
|
||||
canceledAt: null,
|
||||
cancellationReason: null,
|
||||
createdAt: "2026-06-01T00:00:00.000Z",
|
||||
costKiloCredits: 20,
|
||||
additive: "ignored",
|
||||
},
|
||||
]),
|
||||
),
|
||||
)
|
||||
global.fetch = fn as unknown as typeof fetch
|
||||
|
||||
const subscriptions = await fetchCodingPlanSubscriptions("secret-token")
|
||||
|
||||
expect(subscriptions).toHaveLength(1)
|
||||
expect(subscriptions[0]).not.toHaveProperty("additive")
|
||||
const call = fn.mock.calls[0] as unknown as [string, RequestInit]
|
||||
const url = new URL(call[0])
|
||||
expect(url.pathname).toBe("/api/trpc/codingPlans.listSubscriptions")
|
||||
expect(url.searchParams.has("batch")).toBe(false)
|
||||
expect(call[1].method).toBe("GET")
|
||||
expect(new Headers(call[1].headers).get("authorization")).toBe("Bearer secret-token")
|
||||
expect(new Headers(call[1].headers).has("x-kilocode-organizationid")).toBe(false)
|
||||
expect(call[1].redirect).toBe("error")
|
||||
expect(call[1].signal).toBeInstanceOf(AbortSignal)
|
||||
})
|
||||
|
||||
test("encodes query input", async () => {
|
||||
global.fetch = mock(() => Promise.resolve(result([]))) as unknown as typeof fetch
|
||||
await fetchByokEntries("token")
|
||||
const call = (global.fetch as unknown as { mock: { calls: Array<[string, RequestInit]> } }).mock.calls[0]
|
||||
const url = new URL(call[0])
|
||||
expect(url.pathname).toBe("/api/trpc/byok.list")
|
||||
expect(JSON.parse(url.searchParams.get("input") ?? "null")).toEqual({})
|
||||
})
|
||||
|
||||
test("validates every supported procedure projection", async () => {
|
||||
const payloads: Record<string, unknown> = {
|
||||
"codingPlans.getUsage": {
|
||||
...quota(),
|
||||
additive: "stripped",
|
||||
subscription: {
|
||||
...quota().subscription,
|
||||
windows: quota().subscription.windows.map((window, index) =>
|
||||
index === 0 ? { ...window, providerPrivate: "stripped" } : window,
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
global.fetch = mock((input: string | URL | Request) => {
|
||||
const procedure = new URL(String(input)).pathname.split("/").at(-1) ?? ""
|
||||
return Promise.resolve(result(payloads[procedure]))
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
const usage = await fetchCodingPlanUsage("token", "plan")
|
||||
expect(usage).toEqual(quota())
|
||||
const call = (global.fetch as unknown as { mock: { calls: Array<[string]> } }).mock.calls[0]
|
||||
expect(JSON.parse(new URL(call[0]).searchParams.get("input") ?? "null")).toEqual({ subscriptionId: "plan" })
|
||||
})
|
||||
|
||||
test("decodes procedure errors even when HTTP is successful", async () => {
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ error: { json: { message: "raw private error" } } }), { status: 200 }),
|
||||
),
|
||||
) as unknown as typeof fetch
|
||||
|
||||
const error = await fetchCodingPlanSubscriptions("secret-token").catch((value) => value)
|
||||
expect(error).toBeInstanceOf(CloudTrpcError)
|
||||
expect(error).toMatchObject({ kind: "procedure", message: "Kilo Cloud data is temporarily unavailable." })
|
||||
// Include non-enumerable Error surfaces that JSON.stringify would omit.
|
||||
const surface = `${error.name} ${error.message} ${error.stack} ${JSON.stringify(error)}`
|
||||
expect(surface).not.toContain("raw private error")
|
||||
expect(surface).not.toContain("secret-token")
|
||||
})
|
||||
|
||||
test("tolerates an explicit null error field in successful envelopes", async () => {
|
||||
global.fetch = mock(() =>
|
||||
Promise.resolve(new Response(JSON.stringify({ result: { data: { json: [] } }, error: null }))),
|
||||
) as unknown as typeof fetch
|
||||
|
||||
await expect(fetchCodingPlanSubscriptions("token")).resolves.toEqual([])
|
||||
})
|
||||
|
||||
test("maps malformed envelopes and schema failures safely", async () => {
|
||||
global.fetch = mock(() => Promise.resolve(new Response("not-json"))) as unknown as typeof fetch
|
||||
await expect(fetchCodingPlanSubscriptions("token")).rejects.toMatchObject({ kind: "protocol" })
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(result({ status: "unknown" }))) as unknown as typeof fetch
|
||||
await expect(fetchCodingPlanSubscriptions("token")).rejects.toMatchObject({ kind: "schema" })
|
||||
})
|
||||
|
||||
test.each([
|
||||
["unknown version", { schemaVersion: 2 }],
|
||||
["mismatched subscription", quota("other")],
|
||||
[
|
||||
"duplicate windows",
|
||||
{
|
||||
...quota(),
|
||||
subscription: {
|
||||
...quota().subscription,
|
||||
windows: [quota().subscription.windows[1], quota().subscription.windows[1]],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"missing period",
|
||||
{
|
||||
...quota(),
|
||||
subscription: {
|
||||
...quota().subscription,
|
||||
windows: [{ ...quota().subscription.windows[1], period: undefined }],
|
||||
},
|
||||
},
|
||||
],
|
||||
])("rejects %s usage payloads", async (_description, payload) => {
|
||||
global.fetch = mock(() => Promise.resolve(result(payload))) as unknown as typeof fetch
|
||||
|
||||
await expect(fetchCodingPlanUsage("token", "plan")).rejects.toMatchObject({ kind: "schema" })
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,7 @@
|
||||
"./app-icon": "./src/components/app-icon.tsx",
|
||||
"./markdown": "./src/components/markdown.tsx",
|
||||
"./keybind": "./src/components/keybind.tsx",
|
||||
"./kilo-pass-meter": "./src/components/kilo-pass-meter.tsx",
|
||||
"./popover": "./src/components/popover.tsx",
|
||||
"./hover-card": "./src/components/hover-card.tsx",
|
||||
"./dropdown-menu": "./src/components/dropdown-menu.tsx",
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-weak-base);
|
||||
|
||||
[data-slot="card-header"] {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
&[data-variant="error"] {
|
||||
padding: 12px;
|
||||
background-color: color-mix(in srgb, var(--surface-critical-strong) 10%, var(--surface-inset-base));
|
||||
|
||||
@@ -1 +1,12 @@
|
||||
import { type ComponentProps, splitProps } from "solid-js"
|
||||
|
||||
export * from "@opencode-ai/ui/card"
|
||||
|
||||
export function CardHeader(props: ComponentProps<"div">) {
|
||||
const [local, rest] = splitProps(props, ["children", "class", "classList"])
|
||||
return (
|
||||
<div {...rest} data-slot="card-header" classList={{ ...local.classList, [local.class ?? ""]: !!local.class }}>
|
||||
{local.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
[data-component="kilo-pass-meter"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
|
||||
[data-slot="kilo-pass-meter-header"] {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: var(--text-base);
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
[data-slot="kilo-pass-meter-header"] strong {
|
||||
text-align: right;
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
[data-slot="kilo-pass-meter-track"] {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
overflow: visible;
|
||||
border-radius: 4px;
|
||||
background: var(--border-weak-base);
|
||||
}
|
||||
|
||||
:is(
|
||||
[data-slot="kilo-pass-meter-paid-background"],
|
||||
[data-slot="kilo-pass-meter-bonus-background"],
|
||||
[data-slot="kilo-pass-meter-paid-fill"],
|
||||
[data-slot="kilo-pass-meter-bonus-fill"]
|
||||
) {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
}
|
||||
|
||||
[data-slot="kilo-pass-meter-paid-background"] {
|
||||
left: 0;
|
||||
background: color-mix(in srgb, var(--icon-warning-base) 18%, transparent);
|
||||
}
|
||||
|
||||
[data-slot="kilo-pass-meter-bonus-background"] {
|
||||
background: color-mix(in srgb, var(--icon-success-base) 18%, transparent);
|
||||
}
|
||||
|
||||
[data-slot="kilo-pass-meter-paid-fill"] {
|
||||
left: 0;
|
||||
border-radius: 4px 0 0 4px;
|
||||
background: var(--icon-warning-base);
|
||||
}
|
||||
|
||||
[data-slot="kilo-pass-meter-bonus-fill"] {
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: var(--icon-success-base);
|
||||
}
|
||||
|
||||
[data-slot="kilo-pass-meter-boundary"] {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 2px;
|
||||
height: 5px;
|
||||
margin-left: -1px;
|
||||
background: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-slot="kilo-pass-meter-amounts"] {
|
||||
position: relative;
|
||||
height: 15px;
|
||||
color: var(--icon-warning-base);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
[data-slot="kilo-pass-meter-amounts"] > span {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* Boundary labels at the track edges stay inside the card. */
|
||||
[data-slot="kilo-pass-meter-amounts"] > span[data-pin="end"] {
|
||||
right: 0;
|
||||
left: auto !important;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
[data-slot="kilo-pass-meter-amounts"] > span[data-pin="start"] {
|
||||
left: 0 !important;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
[data-slot="kilo-pass-meter-bonus-amount"] {
|
||||
right: 0;
|
||||
left: auto !important;
|
||||
color: var(--icon-success-base);
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
[data-slot="kilo-pass-meter-legend"],
|
||||
[data-slot="kilo-pass-meter-legend"] > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[data-slot="kilo-pass-meter-legend"] {
|
||||
justify-content: space-between;
|
||||
color: var(--text-weak);
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
[data-slot="kilo-pass-meter-legend"] > span {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
[data-slot="kilo-pass-meter-legend"] i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
[data-slot="kilo-pass-meter-paid-dot"] {
|
||||
background: var(--icon-warning-base);
|
||||
}
|
||||
|
||||
[data-slot="kilo-pass-meter-bonus-dot"] {
|
||||
background: var(--icon-success-base);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { type ComponentProps, type JSX, splitProps } from "solid-js"
|
||||
|
||||
export interface KiloPassMeterProps extends Omit<ComponentProps<"div">, "children"> {
|
||||
used: number
|
||||
paid: number
|
||||
bonus: number
|
||||
label: JSX.Element
|
||||
paidLabel: JSX.Element
|
||||
bonusLabel: JSX.Element
|
||||
format: (value: number) => string
|
||||
}
|
||||
|
||||
export function KiloPassMeter(props: KiloPassMeterProps) {
|
||||
const [local, rest] = splitProps(props, [
|
||||
"used",
|
||||
"paid",
|
||||
"bonus",
|
||||
"label",
|
||||
"paidLabel",
|
||||
"bonusLabel",
|
||||
"format",
|
||||
"class",
|
||||
"classList",
|
||||
])
|
||||
const model = () => {
|
||||
const paid = Math.max(0, local.paid)
|
||||
const bonus = Math.max(0, local.bonus)
|
||||
const used = Math.max(0, local.used)
|
||||
const total = paid + bonus
|
||||
// With no credits at all the track stays empty instead of rendering a
|
||||
// full-width paid allocation for a $0 pass.
|
||||
const boundary = total > 0 ? (paid / total) * 100 : 0
|
||||
const filled = total > 0 ? Math.min(100, (used / total) * 100) : 0
|
||||
return {
|
||||
paid,
|
||||
bonus,
|
||||
used,
|
||||
total,
|
||||
boundary,
|
||||
paidFill: Math.min(filled, boundary),
|
||||
bonusFill: Math.max(0, filled - boundary),
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
data-component="kilo-pass-meter"
|
||||
role="meter"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={Math.max(model().total, 1)}
|
||||
aria-valuenow={Math.min(model().used, Math.max(model().total, 1))}
|
||||
aria-valuetext={`${local.format(model().used)} / ${local.format(model().total)}`}
|
||||
classList={{ ...local.classList, [local.class ?? ""]: !!local.class }}
|
||||
>
|
||||
<div data-slot="kilo-pass-meter-header">
|
||||
<span>{local.label}</span>
|
||||
<strong>
|
||||
{local.format(model().used)} / {local.format(model().total)}
|
||||
</strong>
|
||||
</div>
|
||||
<div data-slot="kilo-pass-meter-track" aria-hidden="true">
|
||||
<div data-slot="kilo-pass-meter-paid-background" style={{ width: `${model().boundary}%` }} />
|
||||
<div
|
||||
data-slot="kilo-pass-meter-bonus-background"
|
||||
hidden={model().bonus <= 0}
|
||||
style={{ left: `${model().boundary}%`, width: `${100 - model().boundary}%` }}
|
||||
/>
|
||||
<div data-slot="kilo-pass-meter-paid-fill" style={{ width: `${model().paidFill}%` }} />
|
||||
<div
|
||||
data-slot="kilo-pass-meter-bonus-fill"
|
||||
hidden={model().bonusFill <= 0}
|
||||
style={{ left: `${model().boundary}%`, width: `${model().bonusFill}%` }}
|
||||
/>
|
||||
<div
|
||||
data-slot="kilo-pass-meter-boundary"
|
||||
hidden={model().bonus <= 0}
|
||||
style={{ left: `${model().boundary}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div data-slot="kilo-pass-meter-amounts" aria-hidden="true">
|
||||
<span
|
||||
hidden={model().total <= 0}
|
||||
data-pin={model().bonus <= 0 ? "end" : model().paid <= 0 ? "start" : undefined}
|
||||
style={{ left: `${model().boundary}%` }}
|
||||
>
|
||||
{local.format(model().paid)}
|
||||
</span>
|
||||
<span data-slot="kilo-pass-meter-bonus-amount" hidden={model().bonus <= 0}>
|
||||
{local.format(model().bonus)}
|
||||
</span>
|
||||
</div>
|
||||
<div data-slot="kilo-pass-meter-legend">
|
||||
<span>
|
||||
<i data-slot="kilo-pass-meter-paid-dot" aria-hidden="true" />
|
||||
{local.paidLabel}
|
||||
</span>
|
||||
<span hidden={model().bonus <= 0}>
|
||||
<i data-slot="kilo-pass-meter-bonus-dot" aria-hidden="true" />
|
||||
{local.bonusLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/** @jsxImportSource solid-js */
|
||||
import type { Meta, StoryObj } from "storybook-solidjs-vite"
|
||||
import { Card } from "@opencode-ai/ui/card"
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "../components/card"
|
||||
import { Tag } from "../components/tag"
|
||||
|
||||
const meta: Meta<typeof Card> = {
|
||||
title: "Components/Card",
|
||||
@@ -33,6 +34,20 @@ export const Info: Story = {
|
||||
args: { variant: "info", children: "This is an info card" },
|
||||
}
|
||||
|
||||
export const WithHeader: Story = {
|
||||
render: () => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle icon={false}>MiniMax</CardTitle>
|
||||
<CardDescription>Token Plan Plus</CardDescription>
|
||||
</div>
|
||||
<Tag>Direct</Tag>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
),
|
||||
}
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", "flex-direction": "column", gap: "8px", width: "300px" }}>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/** @jsxImportSource solid-js */
|
||||
import type { Meta, StoryObj } from "storybook-solidjs-vite"
|
||||
import { KiloPassMeter } from "../components/kilo-pass-meter"
|
||||
|
||||
const meta: Meta<typeof KiloPassMeter> = {
|
||||
title: "Components/Kilo Pass Meter",
|
||||
component: KiloPassMeter,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ padding: "16px", width: "320px" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
parameters: { layout: "centered" },
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof KiloPassMeter>
|
||||
|
||||
const format = (value: number) => `$${value.toFixed(2)}`
|
||||
const render = (used: number, paid: number, bonus: number) => (
|
||||
<KiloPassMeter
|
||||
used={used}
|
||||
paid={paid}
|
||||
bonus={bonus}
|
||||
label="This month's usage"
|
||||
paidLabel="Paid"
|
||||
bonusLabel="Bonus"
|
||||
format={format}
|
||||
aria-label="Kilo Pass monthly usage"
|
||||
/>
|
||||
)
|
||||
|
||||
export const CurrentPlan: Story = { render: () => render(73.27, 199, 99.5) }
|
||||
export const UsingBonus: Story = { render: () => render(240, 199, 99.5) }
|
||||
export const PaidOnly: Story = { render: () => render(73.27, 199, 0) }
|
||||
export const Empty: Story = { render: () => render(0, 0, 0) }
|
||||
export const OverLimit: Story = { render: () => render(325, 199, 99.5) }
|
||||
@@ -22,6 +22,7 @@
|
||||
@import "../components/error-details.css";
|
||||
@import "../components/icon-button.css";
|
||||
@import "../components/inline-input.css";
|
||||
@import "../components/kilo-pass-meter.css";
|
||||
@import "../components/list.css";
|
||||
@import "../components/markdown.css";
|
||||
@import "../components/message-part.css";
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as vscode from "vscode"
|
||||
import { TRANSIENT as MEMORY_TRANSIENT } from "@kilocode/kilo-memory/schema"
|
||||
import type {
|
||||
KiloClient,
|
||||
ProviderUsage,
|
||||
Session,
|
||||
SessionStatus,
|
||||
Event,
|
||||
@@ -369,6 +370,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
private configWarningsShown = false
|
||||
/** Cached notificationsLoaded payload */
|
||||
private cachedNotificationsMessage: NotificationsMessage | null = null
|
||||
/** Cached provider usage payload for profile view remounts and temporary disconnects. */
|
||||
private cachedProviderUsageMessage: { type: "providerUsageLoaded"; data: ProviderUsage } | null = null
|
||||
private providerUsageGeneration = 0
|
||||
private pendingKiloModel: { modelID?: string; agent?: string } | null = null
|
||||
private pendingReviewComments: { comments: unknown[]; autoSend: boolean }[] = []
|
||||
private readyResolvers: (() => void)[] = []
|
||||
@@ -553,6 +557,8 @@ 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.configBindings.clear()
|
||||
this.cachedConfigMessage = null
|
||||
this.postMessage({ type: "workspaceDirectoryChanged", directory: directory ?? "" })
|
||||
@@ -1078,6 +1084,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.handleStreamVisibilityMessage(message)
|
||||
if (this.handleChildSyncMessage(message)) return
|
||||
if (await this.handleMemoryMessage(message)) return
|
||||
if (await this.handleProfileDataMessage(message)) return
|
||||
if (this.handleLegacyMigrationMessage(message)) return
|
||||
switch (message.type) {
|
||||
case "webviewReady":
|
||||
@@ -1193,9 +1200,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
await handleSetOrganization(this.authCtx, message.organizationId)
|
||||
}
|
||||
break
|
||||
case "refreshProfile":
|
||||
await handleRefreshProfile(this.authCtx)
|
||||
break
|
||||
case "openSettingsPanel":
|
||||
vscode.commands.executeCommand("kilo-code.new.settingsButtonClicked", message.tab)
|
||||
break
|
||||
@@ -1550,6 +1554,22 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.webviewMessageDisposable = watchWorkStyleConfig((msg) => this.postMessage(msg), this.webviewMessageDisposable)
|
||||
}
|
||||
|
||||
private async handleProfileDataMessage(message: TypedWebviewMessage): Promise<boolean> {
|
||||
if (message.type === "refreshProfile") {
|
||||
await handleRefreshProfile(this.authCtx)
|
||||
return true
|
||||
}
|
||||
if (message.type === "requestProviderUsage") {
|
||||
await this.fetchAndSendProviderUsage()
|
||||
return true
|
||||
}
|
||||
if (message.type === "refreshProviderUsage") {
|
||||
await this.fetchAndSendProviderUsage(true)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private handleWebviewFocusMessage(message: TypedWebviewMessage & { focused?: unknown; target?: unknown }): void {
|
||||
if (message.type === "webviewFocusChanged" && this.opts.focusContext) {
|
||||
void vscode.commands.executeCommand("setContext", this.opts.focusContext, message.focused === true)
|
||||
@@ -2441,6 +2461,45 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchAndSendProviderUsage(force = false): Promise<void> {
|
||||
const generation = ++this.providerUsageGeneration
|
||||
const client = this.client
|
||||
if (!client) {
|
||||
this.postMessage(
|
||||
this.cachedProviderUsageMessage ?? {
|
||||
type: "providerUsageLoaded",
|
||||
error: "Provider usage could not be loaded.",
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const directory = this.getProjectDirectory(this.currentSession?.id)
|
||||
const result = await (
|
||||
force ? client.kilocode.providerUsage.refresh({ directory }) : client.kilocode.providerUsage.get({ directory })
|
||||
).catch((error) => {
|
||||
console.error("[Kilo New] KiloProvider: Failed to fetch provider usage:", error)
|
||||
return undefined
|
||||
})
|
||||
if (generation !== this.providerUsageGeneration) return
|
||||
if (!result?.data) {
|
||||
if (this.cachedProviderUsageMessage) {
|
||||
this.postMessage(
|
||||
force
|
||||
? { ...this.cachedProviderUsageMessage, error: "Provider usage could not be refreshed." }
|
||||
: this.cachedProviderUsageMessage,
|
||||
)
|
||||
return
|
||||
}
|
||||
this.postMessage({ type: "providerUsageLoaded", error: "Provider usage could not be loaded." })
|
||||
return
|
||||
}
|
||||
|
||||
const message = { type: "providerUsageLoaded" as const, 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<void> {
|
||||
const next = ++this.providersGeneration
|
||||
@@ -4128,12 +4187,19 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
postMessage: (msg) => this.postMessage(msg),
|
||||
getWorkspaceDirectory: () => this.getWorkspaceDirectory(),
|
||||
disposeGlobal: () => this.disposeGlobal(),
|
||||
invalidateProviderUsage: () => this.invalidateProviderUsage(),
|
||||
fetchAndSendProviders: () => this.fetchAndSendProviders(),
|
||||
fetchAndSendAgents: () => this.fetchAndSendAgents(),
|
||||
fetchAndSendSpeechToTextModels: () => this.fetchAndSendSpeechToTextModels(),
|
||||
}
|
||||
}
|
||||
|
||||
private invalidateProviderUsage(): void {
|
||||
this.providerUsageGeneration++
|
||||
this.cachedProviderUsageMessage = null
|
||||
this.postMessage({ type: "providerUsageLoaded", reset: true })
|
||||
}
|
||||
|
||||
private async disposeGlobal(): Promise<void> {
|
||||
if (!this.client) return
|
||||
|
||||
@@ -4277,6 +4343,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
/** Re-fetch all server-side state after an auth change. */
|
||||
private async reloadAfterAuthChange(): Promise<void> {
|
||||
this.invalidateProviderUsage()
|
||||
await this.fetchAndSendConfig()
|
||||
await Promise.all([
|
||||
this.fetchAndSendProviders(),
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface AuthContext {
|
||||
postMessage(msg: unknown): void
|
||||
getWorkspaceDirectory(): string
|
||||
disposeGlobal(): Promise<void>
|
||||
invalidateProviderUsage(): void
|
||||
fetchAndSendProviders(): Promise<void>
|
||||
fetchAndSendAgents(): Promise<void>
|
||||
fetchAndSendSpeechToTextModels(): Promise<void>
|
||||
@@ -60,6 +61,7 @@ export async function handleLogin(ctx: AuthContext, attempt: number, getAttempt:
|
||||
|
||||
console.log("[Kilo New] KiloProvider: 🔐 Login successful")
|
||||
|
||||
ctx.invalidateProviderUsage()
|
||||
await ctx.disposeGlobal()
|
||||
|
||||
// Step 3: Fetch profile and push to webview
|
||||
@@ -85,6 +87,7 @@ export async function handleLogout(ctx: AuthContext): Promise<void> {
|
||||
console.log("[Kilo New] KiloProvider: 🚪 Logged out successfully")
|
||||
ctx.postMessage({ type: "profileData", data: null })
|
||||
|
||||
ctx.invalidateProviderUsage()
|
||||
await ctx.disposeGlobal()
|
||||
|
||||
await ctx.fetchAndSendProviders()
|
||||
@@ -119,6 +122,7 @@ export async function handleSetOrganization(ctx: AuthContext, organizationId: st
|
||||
return
|
||||
}
|
||||
|
||||
ctx.invalidateProviderUsage()
|
||||
await ctx.disposeGlobal()
|
||||
|
||||
// Org switch succeeded — refresh profile and providers independently (best-effort)
|
||||
|
||||
@@ -10,6 +10,9 @@ 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--empty-usage", name: "Profile / empty usage" },
|
||||
{ id: "settings--providers-configure", name: "Settings / providers empty state" },
|
||||
{ id: "marketplace--empty-list", name: "Marketplace / empty state" },
|
||||
{ id: "agentmanager--sidebar-search-open", name: "Agent Manager / sidebar search" },
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import type { ProviderUsage, ProviderUsageWindow } from "@kilocode/sdk/v2/client"
|
||||
import { formatWindow, windowLabel, windowProgress } from "@kilocode/kilo-gateway/provider-usage"
|
||||
|
||||
const { KiloProvider } = await import("../../src/KiloProvider")
|
||||
|
||||
const data: ProviderUsage = {
|
||||
generatedAt: "2026-06-19T00:00:00.000Z",
|
||||
items: [],
|
||||
}
|
||||
|
||||
type Internals = {
|
||||
cachedProviderUsageMessage: unknown
|
||||
fetchAndSendProviderUsage: (force?: boolean) => Promise<void>
|
||||
reloadAfterAuthChange: () => Promise<void>
|
||||
postMessage: (message: unknown) => void
|
||||
}
|
||||
|
||||
type UsageClient = {
|
||||
get: (input: { directory?: string }) => Promise<unknown>
|
||||
refresh: (input: { directory?: string }) => Promise<unknown>
|
||||
}
|
||||
|
||||
// Answers any SDK endpoint outside the fake usage client with a benign empty
|
||||
// response, so tests never have to mirror KiloProvider's internal fetcher list.
|
||||
const benign = (value: unknown): unknown =>
|
||||
typeof value === "function"
|
||||
? value
|
||||
: new Proxy(() => {}, {
|
||||
get: (_, prop) =>
|
||||
prop === "then" ? undefined : benign((value as Record<PropertyKey, unknown> | undefined)?.[prop]),
|
||||
apply: () => Promise.resolve({ data: [] }),
|
||||
})
|
||||
|
||||
function bridge(usage: UsageClient) {
|
||||
const messages: unknown[] = []
|
||||
const provider = new KiloProvider(
|
||||
{} as never,
|
||||
{ getClient: () => benign({ kilocode: { providerUsage: usage } }) } as never,
|
||||
undefined,
|
||||
{ projectDirectory: "/repo" },
|
||||
)
|
||||
const internal = provider as unknown as Internals
|
||||
internal.postMessage = (message) => messages.push(message)
|
||||
return { provider, internal, messages }
|
||||
}
|
||||
|
||||
const usageMessages = (messages: unknown[]) =>
|
||||
messages.filter((message) => (message as { type?: string }).type === "providerUsageLoaded")
|
||||
|
||||
describe("provider usage presentation", () => {
|
||||
const window = (value: Partial<ProviderUsageWindow>): ProviderUsageWindow => ({
|
||||
id: "quota",
|
||||
resource: "general",
|
||||
unit: "percent",
|
||||
orientation: "remaining_percent",
|
||||
state: "active",
|
||||
...value,
|
||||
})
|
||||
|
||||
it("formats used and remaining orientations without provider branching", () => {
|
||||
expect(formatWindow(window({ remaining: 75, limit: 100 }))).toBe("75% remaining")
|
||||
expect(formatWindow(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(formatWindow(window({ remaining: 0, limit: 100, state: "exhausted" }))).toBe("0% remaining")
|
||||
expect(formatWindow(window({ state: "unknown" }))).toBe("Unknown")
|
||||
expect(formatWindow(window({ state: "unlimited" }))).toBe("Unlimited")
|
||||
expect(formatWindow(window({ state: "not_in_plan" }))).toBe("Not in plan")
|
||||
})
|
||||
|
||||
it("composes window labels from structured periods instead of wire strings", () => {
|
||||
expect(windowLabel(window({ resource: "subscription", period: { unit: "month", value: 1 } }))).toBe("Monthly quota")
|
||||
expect(windowLabel(window({ resource: "subscription", period: { unit: "day", value: 3 } }))).toBe("3-day quota")
|
||||
expect(windowLabel(window({ period: { unit: "hour", value: 5 } }))).toBe("Shared · 5-hour quota")
|
||||
expect(windowLabel(window({ period: { unit: "week", value: 1 } }))).toBe("Shared · Weekly quota")
|
||||
expect(windowLabel(window({ resource: "image" }))).toBe("image · Quota")
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloProvider provider usage bridge", () => {
|
||||
it("uses cache-aware GET on open and forced POST for refresh", async () => {
|
||||
const get: Array<{ directory?: string }> = []
|
||||
const refresh: Array<{ directory?: string }> = []
|
||||
const { internal, messages } = bridge({
|
||||
get: async (input) => {
|
||||
get.push(input)
|
||||
return { data }
|
||||
},
|
||||
refresh: async (input) => {
|
||||
refresh.push(input)
|
||||
return { data }
|
||||
},
|
||||
})
|
||||
|
||||
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.cachedProviderUsageMessage).toEqual({ type: "providerUsageLoaded", data })
|
||||
})
|
||||
|
||||
it("surfaces a failed forced refresh alongside the cached data", async () => {
|
||||
const { internal, messages } = bridge({
|
||||
get: async () => ({ data }),
|
||||
refresh: async () => ({ error: { _tag: "ServiceUnavailable" } }),
|
||||
})
|
||||
|
||||
internal.cachedProviderUsageMessage = { type: "providerUsageLoaded", data }
|
||||
await internal.fetchAndSendProviderUsage(true)
|
||||
|
||||
expect(messages).toEqual([{ type: "providerUsageLoaded", data, error: "Provider usage could not be refreshed." }])
|
||||
})
|
||||
|
||||
it("posts a terminal loading error when the backend has no cached response", async () => {
|
||||
const { internal, messages } = bridge({
|
||||
get: async () => ({ error: { _tag: "ServiceUnavailable" } }),
|
||||
refresh: async () => ({ error: { _tag: "ServiceUnavailable" } }),
|
||||
})
|
||||
|
||||
await internal.fetchAndSendProviderUsage()
|
||||
|
||||
expect(messages).toEqual([{ type: "providerUsageLoaded", error: "Provider usage could not be loaded." }])
|
||||
})
|
||||
|
||||
it("invalidates cached usage without reloading on auth change", async () => {
|
||||
const requests: unknown[] = []
|
||||
const { internal, messages } = bridge({
|
||||
get: async (input) => {
|
||||
requests.push(input)
|
||||
return { data: { generatedAt: "a", items: [] } }
|
||||
},
|
||||
refresh: async () => ({ data }),
|
||||
})
|
||||
|
||||
await internal.fetchAndSendProviderUsage()
|
||||
await internal.reloadAfterAuthChange()
|
||||
|
||||
expect(usageMessages(messages)).toEqual([
|
||||
{ type: "providerUsageLoaded", data: { generatedAt: "a", items: [] } },
|
||||
{ type: "providerUsageLoaded", reset: true },
|
||||
])
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(internal.cachedProviderUsageMessage).toBeNull()
|
||||
})
|
||||
|
||||
it("resets usage without fetching when auth changes before the profile is opened", async () => {
|
||||
const requests: unknown[] = []
|
||||
const { internal, messages } = bridge({
|
||||
get: async () => ({ data }),
|
||||
refresh: async (input) => {
|
||||
requests.push(input)
|
||||
return { data }
|
||||
},
|
||||
})
|
||||
|
||||
await internal.reloadAfterAuthChange()
|
||||
|
||||
expect(requests).toEqual([])
|
||||
expect(usageMessages(messages)).toEqual([{ type: "providerUsageLoaded", reset: true }])
|
||||
})
|
||||
|
||||
it("drops an in-flight usage response from the previous account", async () => {
|
||||
let release!: (value: { data: ProviderUsage }) => void
|
||||
const first = new Promise<{ data: ProviderUsage }>((resolve) => (release = resolve))
|
||||
const calls: unknown[] = []
|
||||
const { internal, messages } = bridge({
|
||||
get: (input) => {
|
||||
calls.push(input)
|
||||
return first
|
||||
},
|
||||
refresh: async () => ({ data }),
|
||||
})
|
||||
|
||||
const hung = internal.fetchAndSendProviderUsage()
|
||||
await internal.reloadAfterAuthChange()
|
||||
release({ data: { generatedAt: "a", items: [] } })
|
||||
await hung
|
||||
|
||||
expect(usageMessages(messages)).toEqual([{ type: "providerUsageLoaded", reset: true }])
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(internal.cachedProviderUsageMessage).toBeNull()
|
||||
})
|
||||
|
||||
it("invalidates cached usage without fetching when the workspace directory changes", async () => {
|
||||
const requests: unknown[] = []
|
||||
const { provider, internal, messages } = bridge({
|
||||
get: async (input) => {
|
||||
requests.push(input)
|
||||
return { data }
|
||||
},
|
||||
refresh: async () => ({ data }),
|
||||
})
|
||||
|
||||
await internal.fetchAndSendProviderUsage()
|
||||
provider.setProjectDirectory("/other")
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(internal.cachedProviderUsageMessage).toBeNull()
|
||||
expect(messages).toContainEqual({ type: "workspaceDirectoryChanged", directory: "/other" })
|
||||
})
|
||||
})
|
||||
@@ -384,8 +384,13 @@ const AppContent: Component = () => {
|
||||
<Match when={currentView() === "profile"}>
|
||||
<ProfileView
|
||||
profileData={server.profileData()}
|
||||
providerUsage={server.providerUsage()}
|
||||
providerUsageLoading={server.providerUsageLoading()}
|
||||
providerUsageError={server.providerUsageError()}
|
||||
deviceAuth={server.deviceAuth()}
|
||||
onLogin={server.startLogin}
|
||||
onRequestProviderUsage={server.requestProviderUsage}
|
||||
onRefreshProviderUsage={server.refreshProviderUsage}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={currentView() === "settings"}>
|
||||
|
||||
@@ -6,31 +6,27 @@ import { Select } from "@kilocode/kilo-ui/select"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
import { useVSCode } from "../../context/vscode"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { localeToBcp47, type Locale } from "../../context/language-utils"
|
||||
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 => {
|
||||
return `$${amount.toFixed(2)}`
|
||||
}
|
||||
|
||||
const short = (amount: number): string => `$${Math.round(amount)}`
|
||||
|
||||
const resetLabel = (iso: string | null | undefined, loc: Locale): string | undefined => {
|
||||
if (!iso) return undefined
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return undefined
|
||||
return new Intl.DateTimeFormat(localeToBcp47(loc), { month: "short", day: "numeric", timeZone: "UTC" }).format(date)
|
||||
}
|
||||
|
||||
const PERSONAL = "personal"
|
||||
|
||||
interface OrgOption {
|
||||
@@ -46,9 +42,10 @@ const ProfileView: Component<ProfileViewProps> = (props) => {
|
||||
|
||||
const personal = createMemo(() => props.profileData?.profile.hasPersonalAccount !== false)
|
||||
|
||||
// Always fetch fresh profile+balance when navigating to this view
|
||||
// Load current profile and usage 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)
|
||||
@@ -106,6 +103,10 @@ const ProfileView: Component<ProfileViewProps> = (props) => {
|
||||
vscode.postMessage({ type: "openExternal", url: "https://app.kilo.ai/profile" })
|
||||
}
|
||||
|
||||
const openExternal = (url: string) => {
|
||||
vscode.postMessage({ type: "openExternal", url })
|
||||
}
|
||||
|
||||
const handleTopUp = () => {
|
||||
vscode.postMessage({ type: "openExternal", url: "https://app.kilo.ai/credits" })
|
||||
}
|
||||
@@ -118,8 +119,25 @@ const ProfileView: Component<ProfileViewProps> = (props) => {
|
||||
vscode.postMessage({ type: "cancelLogin" })
|
||||
}
|
||||
|
||||
const usage = () => (
|
||||
<ProviderUsageCards
|
||||
data={props.providerUsage}
|
||||
loading={props.providerUsageLoading ?? !props.providerUsage}
|
||||
error={props.providerUsageError}
|
||||
onRefresh={() => props.onRefreshProviderUsage?.()}
|
||||
onOpen={openExternal}
|
||||
kiloPass={props.profileData?.kiloPass}
|
||||
showKiloPass={
|
||||
!!props.profileData &&
|
||||
(props.profileData.currentOrgId ?? null) === null &&
|
||||
props.profileData.profile.hasPersonalAccount !== false
|
||||
}
|
||||
onGetKiloPass={handleGetPass}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", "flex-direction": "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", "flex-direction": "column", height: "100%", "min-height": 0, overflow: "hidden" }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
@@ -134,7 +152,18 @@ const ProfileView: Component<ProfileViewProps> = (props) => {
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
style={{ padding: "16px", "max-width": "480px", margin: "0 auto", width: "100%", "box-sizing": "border-box" }}
|
||||
data-profile-scroll
|
||||
style={{
|
||||
flex: 1,
|
||||
"min-height": 0,
|
||||
"overflow-y": "auto",
|
||||
"overflow-x": "hidden",
|
||||
padding: "16px",
|
||||
"max-width": "480px",
|
||||
margin: "0 auto",
|
||||
width: "100%",
|
||||
"box-sizing": "border-box",
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={props.profileData}
|
||||
@@ -259,118 +288,6 @@ const ProfileView: Component<ProfileViewProps> = (props) => {
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Kilo Pass is part of personal credits, so only show it on the personal account */}
|
||||
<Show
|
||||
when={
|
||||
(data().currentOrgId ?? null) === null && data().profile.hasPersonalAccount !== false
|
||||
? data().kiloPass
|
||||
: null
|
||||
}
|
||||
>
|
||||
{(pass) => (
|
||||
<div
|
||||
style={{
|
||||
"border-top": "1px solid var(--border-weak-base)",
|
||||
"padding-top": "12px",
|
||||
display: "flex",
|
||||
"flex-direction": "column",
|
||||
gap: "6px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"align-items": "baseline",
|
||||
"justify-content": "space-between",
|
||||
"font-size": "var(--kilo-font-size-13)",
|
||||
}}
|
||||
>
|
||||
<span style={{ "font-weight": "600", color: "var(--vscode-foreground)" }}>Kilo Pass</span>
|
||||
<span style={{ color: "var(--vscode-descriptionForeground)" }}>
|
||||
{short(pass().currentPeriodUsageUsd)} / {short(pass().currentPeriodBaseCreditsUsd)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: "6px",
|
||||
"border-radius": "3px",
|
||||
background: "var(--border-weak-base)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${Math.min(100, (pass().currentPeriodUsageUsd / Math.max(1, pass().currentPeriodBaseCreditsUsd)) * 100)}%`,
|
||||
background: "var(--vscode-progressBar-background, var(--vscode-button-background))",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Show when={pass().currentPeriodBonusCreditsUsd > 0}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"justify-content": "space-between",
|
||||
"font-size": "var(--kilo-font-size-11)",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}
|
||||
>
|
||||
<span>{language.t("profile.pass.bonus")}</span>
|
||||
<span>+{formatBalance(pass().currentPeriodBonusCreditsUsd)}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={resetLabel(pass().nextBillingAt, language.locale())}>
|
||||
{(date) => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"justify-content": "space-between",
|
||||
"font-size": "var(--kilo-font-size-11)",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}
|
||||
>
|
||||
<span>{language.t("profile.pass.renews")}</span>
|
||||
<span>{date()}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
{/* No active Kilo Pass on the personal account — nudge to subscribe */}
|
||||
<Show
|
||||
when={
|
||||
(data().currentOrgId ?? null) === null &&
|
||||
data().profile.hasPersonalAccount !== false &&
|
||||
!data().kiloPass
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
"border-top": "1px solid var(--border-weak-base)",
|
||||
"padding-top": "12px",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGetPass}
|
||||
style={{
|
||||
background: "none",
|
||||
border: 0,
|
||||
padding: 0,
|
||||
"font-size": "var(--kilo-font-size-13)",
|
||||
"font-family": "inherit",
|
||||
color: "var(--vscode-textLink-foreground)",
|
||||
cursor: "pointer",
|
||||
"text-align": "left",
|
||||
}}
|
||||
>
|
||||
{language.t("profile.pass.subscribe")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
)}
|
||||
</Show>
|
||||
@@ -391,9 +308,13 @@ const ProfileView: Component<ProfileViewProps> = (props) => {
|
||||
{language.t("profile.action.logout")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{usage()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={!props.profileData}>{usage()}</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { Component, For, Show } from "solid-js"
|
||||
import type { KiloPassState, ProviderUsageData } from "../../types/messages"
|
||||
import type { ProviderUsageSnapshot } from "@kilocode/sdk/v2/client"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { Card, CardActions, CardDescription, CardHeader, CardTitle } from "@kilocode/kilo-ui/card"
|
||||
import { KiloPassMeter } from "@kilocode/kilo-ui/kilo-pass-meter"
|
||||
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 { localeToBcp47 } from "../../context/language-utils"
|
||||
import { formatWindow, windowLabel, windowProgress } from "@kilocode/kilo-gateway/provider-usage"
|
||||
|
||||
export interface ProviderUsageCardsProps {
|
||||
data: ProviderUsageData | undefined
|
||||
loading: boolean
|
||||
error?: string
|
||||
kiloPass?: KiloPassState | null
|
||||
showKiloPass: boolean
|
||||
onRefresh: () => void
|
||||
onOpen: (url: string) => void
|
||||
onGetKiloPass: () => void
|
||||
}
|
||||
|
||||
type Language = ReturnType<typeof useLanguage>
|
||||
|
||||
const source = (item: ProviderUsageSnapshot, language: Language) => {
|
||||
if (item.sourceKind === "kilo_managed") return "Kilo Gateway"
|
||||
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 }),
|
||||
quota: language.t("profile.usage.window.quota"),
|
||||
daily: language.t("profile.usage.window.daily"),
|
||||
weekly: language.t("profile.usage.window.weekly"),
|
||||
monthly: language.t("profile.usage.window.monthly"),
|
||||
hours: (count: number) => language.t("profile.usage.window.hours", { count: String(count) }),
|
||||
days: (count: number) => language.t("profile.usage.window.days", { count: String(count) }),
|
||||
weeks: (count: number) => language.t("profile.usage.window.weeks", { count: String(count) }),
|
||||
months: (count: number) => language.t("profile.usage.window.months", { count: String(count) }),
|
||||
shared: language.t("profile.usage.window.shared"),
|
||||
scoped: (resource: string, period: string) => language.t("profile.usage.window.scoped", { resource, period }),
|
||||
})
|
||||
|
||||
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 stale = (item: ProviderUsageSnapshot, language: Language) => {
|
||||
const notice = `${language.t("profile.usage.state.unavailable")} ${language.t("profile.usage.state.stale")}`
|
||||
if (!item.fetchedAt) return notice
|
||||
const date = new Date(item.fetchedAt)
|
||||
if (Number.isNaN(date.getTime())) return notice
|
||||
return `${notice} (${date.toLocaleString(localeToBcp47(language.locale()))})`
|
||||
}
|
||||
|
||||
const order = (items: ProviderUsageSnapshot[]) =>
|
||||
[...items].sort(
|
||||
(left, right) => Number(left.sourceKind !== "kilo_managed") - Number(right.sourceKind !== "kilo_managed"),
|
||||
)
|
||||
|
||||
const UsageCard: Component<{
|
||||
item: ProviderUsageSnapshot
|
||||
onOpen: (url: string) => void
|
||||
language: Language
|
||||
}> = (props) => (
|
||||
<Card variant={variant(props.item)}>
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle icon={false} role="heading" aria-level={4}>
|
||||
{props.item.providerLabel}
|
||||
</CardTitle>
|
||||
<CardDescription>{props.item.planLabel}</CardDescription>
|
||||
</div>
|
||||
<Tag>{source(props.item, props.language)}</Tag>
|
||||
</CardHeader>
|
||||
|
||||
<Show
|
||||
when={
|
||||
props.item.planState !== "active" && !(props.item.planState === "unknown" && props.item.fetchState !== "ready")
|
||||
}
|
||||
>
|
||||
<CardDescription>
|
||||
{props.language.t(
|
||||
props.item.planState === "past_due"
|
||||
? "profile.usage.plan.pastDue"
|
||||
: props.item.planState === "canceling"
|
||||
? "profile.usage.plan.canceling"
|
||||
: "profile.usage.plan.unknown",
|
||||
)}
|
||||
</CardDescription>
|
||||
</Show>
|
||||
|
||||
<Show when={props.item.fetchState !== "ready"}>
|
||||
<p class="provider-usage-notice">
|
||||
{props.item.fetchState === "stale"
|
||||
? stale(props.item, props.language)
|
||||
: props.language.t("profile.usage.state.unavailable")}
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<div class="provider-usage-resources">
|
||||
<For each={props.item.windows}>
|
||||
{(window) => {
|
||||
const progress = () => windowProgress(window)
|
||||
const value = () => formatWindow(window, labels(props.language))
|
||||
const title = () => windowLabel(window, labels(props.language))
|
||||
return (
|
||||
<div class="provider-usage-row">
|
||||
<Show when={progress() !== undefined}>
|
||||
<Progress
|
||||
value={progress()}
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
showValueLabel
|
||||
getValueLabel={value}
|
||||
aria-label={`${title()}: ${value()}`}
|
||||
aria-valuetext={value()}
|
||||
>
|
||||
{title()}
|
||||
</Progress>
|
||||
</Show>
|
||||
<Show when={progress() === undefined}>
|
||||
<div class="provider-usage-summary">
|
||||
<span>{title()}</span>
|
||||
<strong>{value()}</strong>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={window.resetAt}>
|
||||
{(reset) => (
|
||||
<CardDescription>
|
||||
{props.language.t("profile.usage.reset", {
|
||||
date: new Date(reset()).toLocaleString(localeToBcp47(props.language.locale())),
|
||||
})}
|
||||
</CardDescription>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={props.item.routingState !== "active" && props.item.routingState !== "not_applicable"}>
|
||||
<p class="provider-usage-notice">
|
||||
{props.language.t("profile.usage.routing", {
|
||||
state: props.language.t(`profile.usage.routingState.${props.item.routingState}`),
|
||||
})}
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<Show when={props.item.managementUrl}>
|
||||
{(url) => (
|
||||
<CardActions>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => props.onOpen(url())}
|
||||
aria-label={props.language.t("profile.usage.action.managePlan", { plan: props.item.planLabel })}
|
||||
>
|
||||
{props.language.t("profile.usage.action.manage")}
|
||||
</Button>
|
||||
</CardActions>
|
||||
)}
|
||||
</Show>
|
||||
</Card>
|
||||
)
|
||||
|
||||
const money = (value: number) => `$${value.toFixed(2)}`
|
||||
|
||||
const KiloPassCard: Component<{
|
||||
pass?: KiloPassState | null
|
||||
onGet: () => void
|
||||
language: Language
|
||||
}> = (props) => {
|
||||
const renewal = () => {
|
||||
const value = props.pass?.nextBillingAt
|
||||
if (!value) return undefined
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return undefined
|
||||
return new Intl.DateTimeFormat(localeToBcp47(props.language.locale()), {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(date)
|
||||
}
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle icon={false} role="heading" aria-level={4}>
|
||||
Kilo
|
||||
</CardTitle>
|
||||
<CardDescription>Kilo Pass</CardDescription>
|
||||
</div>
|
||||
<Tag>Kilo Gateway</Tag>
|
||||
</CardHeader>
|
||||
<Show
|
||||
when={props.pass}
|
||||
fallback={
|
||||
<CardActions>
|
||||
<Button variant="secondary" size="small" onClick={props.onGet}>
|
||||
{props.language.t("profile.pass.subscribe")}
|
||||
</Button>
|
||||
</CardActions>
|
||||
}
|
||||
>
|
||||
{(pass) => (
|
||||
<div class="provider-usage-resources">
|
||||
<KiloPassMeter
|
||||
used={pass().currentPeriodUsageUsd}
|
||||
paid={pass().currentPeriodBaseCreditsUsd}
|
||||
bonus={pass().currentPeriodBonusCreditsUsd}
|
||||
label={props.language.t("profile.pass.usage")}
|
||||
paidLabel={props.language.t("profile.pass.paid")}
|
||||
bonusLabel={props.language.t("profile.pass.bonus")}
|
||||
format={money}
|
||||
aria-label={props.language.t("profile.pass.meter")}
|
||||
/>
|
||||
<Show when={renewal()}>
|
||||
{(date) => (
|
||||
<div class="provider-usage-summary">
|
||||
<span>{props.language.t("profile.pass.renews")}</span>
|
||||
<strong>{date()}</strong>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProviderUsageCards: Component<ProviderUsageCardsProps> = (props) => {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<section class="provider-usage-section" aria-labelledby="provider-usage-title">
|
||||
<div class="provider-usage-section-heading">
|
||||
<div>
|
||||
<h3 id="provider-usage-title">{language.t("profile.usage.title")}</h3>
|
||||
<p>{language.t("profile.usage.description")}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={props.onRefresh}
|
||||
disabled={props.loading}
|
||||
aria-label={language.t("profile.usage.refresh")}
|
||||
>
|
||||
{props.loading ? <Spinner style={{ width: "14px", height: "14px" }} /> : `↻ ${language.t("common.refresh")}`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="provider-usage-list">
|
||||
<Show when={props.data && props.error}>
|
||||
<p class="provider-usage-notice" role="alert">
|
||||
{props.error}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={props.showKiloPass}>
|
||||
<KiloPassCard pass={props.kiloPass} onGet={props.onGetKiloPass} language={language} />
|
||||
</Show>
|
||||
<Show
|
||||
when={props.data}
|
||||
fallback={
|
||||
<>
|
||||
<Show when={props.loading}>
|
||||
<div class="provider-usage-loading" role="status" aria-label={language.t("profile.usage.title")}>
|
||||
<Spinner />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!props.loading && props.error}>
|
||||
{(error) => (
|
||||
<Card variant="warning" role="alert">
|
||||
<CardDescription>{error()}</CardDescription>
|
||||
</Card>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{(data) => (
|
||||
<>
|
||||
<Show
|
||||
when={data().items.length > 0}
|
||||
fallback={
|
||||
<Show when={!props.showKiloPass}>
|
||||
<Card>
|
||||
<CardDescription>{language.t("profile.usage.empty")}</CardDescription>
|
||||
</Card>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<For each={order(data().items)}>
|
||||
{(item) => <UsageCard item={item} onOpen={props.onOpen} language={language} />}
|
||||
</For>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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<string | undefined>
|
||||
isConnected: Accessor<boolean>
|
||||
profileData: Accessor<ProfileData | null>
|
||||
providerUsage: Accessor<ProviderUsageData | undefined>
|
||||
providerUsageLoading: Accessor<boolean>
|
||||
providerUsageError: Accessor<string | undefined>
|
||||
requestProviderUsage: () => void
|
||||
refreshProviderUsage: () => void
|
||||
deviceAuth: Accessor<DeviceAuthState>
|
||||
startLogin: () => void
|
||||
goToLogin: () => void
|
||||
@@ -38,6 +50,9 @@ export const ServerProvider: ParentComponent = (props) => {
|
||||
const [errorMessage, setErrorMessage] = createSignal<string | undefined>()
|
||||
const [errorDetails, setErrorDetails] = createSignal<string | undefined>()
|
||||
const [profileData, setProfileData] = createSignal<ProfileData | null>(null)
|
||||
const [providerUsage, setProviderUsage] = createSignal<ProviderUsageData>()
|
||||
const [providerUsageLoading, setProviderUsageLoading] = createSignal(false)
|
||||
const [providerUsageError, setProviderUsageError] = createSignal<string>()
|
||||
const [deviceAuth, setDeviceAuth] = createSignal<DeviceAuthState>(initialDeviceAuth)
|
||||
const [vscodeLanguage, setVscodeLanguage] = createSignal<string | undefined>()
|
||||
const [languageOverride, setLanguageOverride] = createSignal<string | undefined>()
|
||||
@@ -53,6 +68,30 @@ export const ServerProvider: ParentComponent = (props) => {
|
||||
if (m.type === "fontSizeChanged") applyFontSize(m.fontSize)
|
||||
})
|
||||
|
||||
const usageSub = vscode.onMessage((m: ExtensionMessage) => {
|
||||
if (m.type !== "providerUsageLoaded") return
|
||||
if (m.reset) {
|
||||
setProviderUsage(undefined)
|
||||
setProviderUsageError(undefined)
|
||||
// The reset itself means previous account/project usage was invalidated:
|
||||
// never show it again, and reload once via the cache-aware endpoint.
|
||||
setProviderUsageLoading(true)
|
||||
vscode.postMessage({ type: "requestProviderUsage" })
|
||||
return
|
||||
}
|
||||
if (m.data) setProviderUsage(m.data)
|
||||
setProviderUsageError(m.error)
|
||||
setProviderUsageLoading(false)
|
||||
})
|
||||
|
||||
const resetProviderUsageForDirectory = () => {
|
||||
if (providerUsage() === undefined && !providerUsageLoading()) return
|
||||
setProviderUsage(undefined)
|
||||
setProviderUsageError(undefined)
|
||||
setProviderUsageLoading(true)
|
||||
vscode.postMessage({ type: "requestProviderUsage" })
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribe = vscode.onMessage((message: ExtensionMessage) => {
|
||||
switch (message.type) {
|
||||
@@ -76,6 +115,7 @@ export const ServerProvider: ParentComponent = (props) => {
|
||||
|
||||
case "workspaceDirectoryChanged":
|
||||
setWorkspaceDirectory(message.directory)
|
||||
resetProviderUsageForDirectory()
|
||||
break
|
||||
|
||||
case "languageChanged":
|
||||
@@ -137,6 +177,7 @@ export const ServerProvider: ParentComponent = (props) => {
|
||||
onCleanup(() => {
|
||||
gitSub()
|
||||
fontSub()
|
||||
usageSub()
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
@@ -168,6 +209,18 @@ export const ServerProvider: ParentComponent = (props) => {
|
||||
startLogin()
|
||||
}
|
||||
|
||||
const requestProviderUsage = () => {
|
||||
setProviderUsageLoading(true)
|
||||
setProviderUsageError(undefined)
|
||||
vscode.postMessage({ type: "requestProviderUsage" })
|
||||
}
|
||||
|
||||
const refreshProviderUsage = () => {
|
||||
setProviderUsageLoading(true)
|
||||
setProviderUsageError(undefined)
|
||||
vscode.postMessage({ type: "refreshProviderUsage" })
|
||||
}
|
||||
|
||||
const value: ServerContextValue = {
|
||||
connectionState,
|
||||
serverInfo,
|
||||
@@ -176,6 +229,11 @@ export const ServerProvider: ParentComponent = (props) => {
|
||||
errorDetails,
|
||||
isConnected: () => connectionState() === "connected",
|
||||
profileData,
|
||||
providerUsage,
|
||||
providerUsageLoading,
|
||||
providerUsageError,
|
||||
requestProviderUsage,
|
||||
refreshProviderUsage,
|
||||
deviceAuth,
|
||||
startLogin,
|
||||
goToLogin,
|
||||
|
||||
+39
@@ -597,10 +597,49 @@ 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.direct": "مباشر",
|
||||
"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.managePlan": "إدارة {{plan}}",
|
||||
"profile.usage.routing": "فوترة الخطة مفعّلة. توجيه Kilo Gateway {{state}}.",
|
||||
"profile.usage.routingState.disabled": "معطّل",
|
||||
"profile.usage.routingState.missing": "مفقود",
|
||||
"profile.usage.routingState.replaced": "تم استبداله",
|
||||
"profile.usage.routingState.unknown": "غير معروف",
|
||||
"profile.usage.window.used": "تم استخدام {{value}}",
|
||||
"profile.usage.window.remaining": "متبقي {{value}}",
|
||||
"profile.usage.window.remainingOf": "متبقي {{value}} من {{limit}}",
|
||||
"profile.usage.window.usedOf": "تم استخدام {{value}} من {{limit}}",
|
||||
"profile.usage.window.quota": "الحصة",
|
||||
"profile.usage.window.daily": "الحصة اليومية",
|
||||
"profile.usage.window.weekly": "الحصة الأسبوعية",
|
||||
"profile.usage.window.monthly": "الحصة الشهرية",
|
||||
"profile.usage.window.hours": "حصة كل {{count}} ساعة",
|
||||
"profile.usage.window.days": "حصة كل {{count}} يوم",
|
||||
"profile.usage.window.weeks": "حصة كل {{count}} أسبوع",
|
||||
"profile.usage.window.months": "حصة كل {{count}} شهر",
|
||||
"profile.usage.window.shared": "مشتركة",
|
||||
"profile.usage.window.scoped": "{{resource}} · {{period}}",
|
||||
"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.topUp": "إضافة رصيد",
|
||||
"profile.pass.subscribe": "احصل على Kilo Pass لإضافة رصيد وكسب مكافآت",
|
||||
"profile.pass.bonus": "مكافأة",
|
||||
"profile.pass.usage": "استخدام هذا الشهر",
|
||||
"profile.pass.paid": "مدفوع",
|
||||
"profile.pass.meter": "استخدام Kilo Pass الشهري",
|
||||
"profile.pass.renews": "يتجدد",
|
||||
"profile.action.logout": "تسجيل الخروج",
|
||||
|
||||
|
||||
+39
@@ -612,10 +612,49 @@ 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.direct": "Direto",
|
||||
"profile.usage.state.stale": "Exibindo os dados de uso da última atualização.",
|
||||
"profile.usage.state.unavailable": "Dados de uso indisponíveis.",
|
||||
"profile.usage.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.managePlan": "Gerenciar {{plan}}",
|
||||
"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.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.window.quota": "Cota",
|
||||
"profile.usage.window.daily": "Cota diária",
|
||||
"profile.usage.window.weekly": "Cota semanal",
|
||||
"profile.usage.window.monthly": "Cota mensal",
|
||||
"profile.usage.window.hours": "Cota de {{count}} horas",
|
||||
"profile.usage.window.days": "Cota de {{count}} dias",
|
||||
"profile.usage.window.weeks": "Cota de {{count}} semanas",
|
||||
"profile.usage.window.months": "Cota de {{count}} meses",
|
||||
"profile.usage.window.shared": "Compartilhada",
|
||||
"profile.usage.window.scoped": "{{resource}} · {{period}}",
|
||||
"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.topUp": "Adicionar créditos",
|
||||
"profile.pass.subscribe": "Obtenha o Kilo Pass para adicionar créditos e ganhar bônus",
|
||||
"profile.pass.bonus": "Bônus",
|
||||
"profile.pass.usage": "Uso deste mês",
|
||||
"profile.pass.paid": "Pago",
|
||||
"profile.pass.meter": "Uso mensal do Kilo Pass",
|
||||
"profile.pass.renews": "Renova",
|
||||
"profile.action.logout": "Sair",
|
||||
|
||||
|
||||
+39
@@ -652,10 +652,49 @@ 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.direct": "Direktno",
|
||||
"profile.usage.state.stale": "Prikazuju se posljednji ažurirani podaci o korištenju.",
|
||||
"profile.usage.state.unavailable": "Podaci o korištenju nisu dostupni.",
|
||||
"profile.usage.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.managePlan": "Upravljaj planom {{plan}}",
|
||||
"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.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.window.quota": "Kvota",
|
||||
"profile.usage.window.daily": "Dnevna kvota",
|
||||
"profile.usage.window.weekly": "Sedmična kvota",
|
||||
"profile.usage.window.monthly": "Mjesečna kvota",
|
||||
"profile.usage.window.hours": "Kvota od {{count}} sati",
|
||||
"profile.usage.window.days": "Kvota od {{count}} dana",
|
||||
"profile.usage.window.weeks": "Kvota od {{count}} sedmica",
|
||||
"profile.usage.window.months": "Kvota od {{count}} mjeseci",
|
||||
"profile.usage.window.shared": "Dijeljena",
|
||||
"profile.usage.window.scoped": "{{resource}} · {{period}}",
|
||||
"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.topUp": "Dopuni",
|
||||
"profile.pass.subscribe": "Nabavite Kilo Pass da dodate kredite i zaradite bonuse",
|
||||
"profile.pass.bonus": "Bonus",
|
||||
"profile.pass.usage": "Potrošnja ovog mjeseca",
|
||||
"profile.pass.paid": "Plaćeno",
|
||||
"profile.pass.meter": "Mjesečna potrošnja Kilo Passa",
|
||||
"profile.pass.renews": "Obnavlja se",
|
||||
"profile.action.logout": "Odjava",
|
||||
|
||||
|
||||
+39
@@ -650,10 +650,49 @@ 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.direct": "Direkte",
|
||||
"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.managePlan": "Administrer {{plan}}",
|
||||
"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.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.window.quota": "Kvote",
|
||||
"profile.usage.window.daily": "Daglig kvote",
|
||||
"profile.usage.window.weekly": "Ugentlig kvote",
|
||||
"profile.usage.window.monthly": "Månedlig kvote",
|
||||
"profile.usage.window.hours": "{{count}}-timers kvote",
|
||||
"profile.usage.window.days": "{{count}}-dages kvote",
|
||||
"profile.usage.window.weeks": "{{count}}-ugers kvote",
|
||||
"profile.usage.window.months": "{{count}}-måneders kvote",
|
||||
"profile.usage.window.shared": "Delt",
|
||||
"profile.usage.window.scoped": "{{resource}} · {{period}}",
|
||||
"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.topUp": "Fyld op",
|
||||
"profile.pass.subscribe": "Få Kilo Pass for at tilføje kredit og optjene bonusser",
|
||||
"profile.pass.bonus": "Bonus",
|
||||
"profile.pass.usage": "Denne måneds forbrug",
|
||||
"profile.pass.paid": "Betalt",
|
||||
"profile.pass.meter": "Månedligt Kilo Pass-forbrug",
|
||||
"profile.pass.renews": "Fornyes",
|
||||
"profile.action.logout": "Log ud",
|
||||
|
||||
|
||||
@@ -662,10 +662,49 @@ 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.direct": "Direkt",
|
||||
"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.managePlan": "{{plan}} 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.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.window.quota": "Kontingent",
|
||||
"profile.usage.window.daily": "Tägliches Kontingent",
|
||||
"profile.usage.window.weekly": "Wöchentliches Kontingent",
|
||||
"profile.usage.window.monthly": "Monatliches Kontingent",
|
||||
"profile.usage.window.hours": "{{count}}-Stunden-Kontingent",
|
||||
"profile.usage.window.days": "{{count}}-Tage-Kontingent",
|
||||
"profile.usage.window.weeks": "{{count}}-Wochen-Kontingent",
|
||||
"profile.usage.window.months": "{{count}}-Monats-Kontingent",
|
||||
"profile.usage.window.shared": "Geteilt",
|
||||
"profile.usage.window.scoped": "{{resource}} · {{period}}",
|
||||
"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.topUp": "Aufladen",
|
||||
"profile.pass.subscribe": "Hol dir Kilo Pass, um Guthaben hinzuzufügen und Boni zu verdienen",
|
||||
"profile.pass.bonus": "Bonus",
|
||||
"profile.pass.usage": "Verbrauch in diesem Monat",
|
||||
"profile.pass.paid": "Bezahlt",
|
||||
"profile.pass.meter": "Monatlicher Verbrauch des Kilo Pass",
|
||||
"profile.pass.renews": "Verlängert sich",
|
||||
"profile.action.logout": "Abmelden",
|
||||
|
||||
|
||||
@@ -561,10 +561,49 @@ 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.direct": "Direct",
|
||||
"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.managePlan": "Manage {{plan}}",
|
||||
"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.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.window.quota": "Quota",
|
||||
"profile.usage.window.daily": "Daily quota",
|
||||
"profile.usage.window.weekly": "Weekly quota",
|
||||
"profile.usage.window.monthly": "Monthly quota",
|
||||
"profile.usage.window.hours": "{{count}}-hour quota",
|
||||
"profile.usage.window.days": "{{count}}-day quota",
|
||||
"profile.usage.window.weeks": "{{count}}-week quota",
|
||||
"profile.usage.window.months": "{{count}}-month quota",
|
||||
"profile.usage.window.shared": "Shared",
|
||||
"profile.usage.window.scoped": "{{resource}} · {{period}}",
|
||||
"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.topUp": "Top up",
|
||||
"profile.pass.subscribe": "Get Kilo Pass to add credits and earn bonuses",
|
||||
"profile.pass.bonus": "Bonus",
|
||||
"profile.pass.usage": "This month's usage",
|
||||
"profile.pass.paid": "Paid",
|
||||
"profile.pass.meter": "Kilo Pass monthly usage",
|
||||
"profile.pass.renews": "Renews",
|
||||
"profile.action.logout": "Log Out",
|
||||
|
||||
|
||||
+39
@@ -656,10 +656,49 @@ 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.direct": "Directo",
|
||||
"profile.usage.state.stale": "Se muestran los últimos datos de uso actualizados.",
|
||||
"profile.usage.state.unavailable": "Datos de uso no disponibles.",
|
||||
"profile.usage.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.managePlan": "Gestionar {{plan}}",
|
||||
"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.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.window.quota": "Cuota",
|
||||
"profile.usage.window.daily": "Cuota diaria",
|
||||
"profile.usage.window.weekly": "Cuota semanal",
|
||||
"profile.usage.window.monthly": "Cuota mensual",
|
||||
"profile.usage.window.hours": "Cuota de {{count}} horas",
|
||||
"profile.usage.window.days": "Cuota de {{count}} días",
|
||||
"profile.usage.window.weeks": "Cuota de {{count}} semanas",
|
||||
"profile.usage.window.months": "Cuota de {{count}} meses",
|
||||
"profile.usage.window.shared": "Compartida",
|
||||
"profile.usage.window.scoped": "{{resource}} · {{period}}",
|
||||
"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.topUp": "Recargar",
|
||||
"profile.pass.subscribe": "Obtén Kilo Pass para añadir créditos y ganar bonificaciones",
|
||||
"profile.pass.bonus": "Bonificación",
|
||||
"profile.pass.usage": "Uso de este mes",
|
||||
"profile.pass.paid": "Pagado",
|
||||
"profile.pass.meter": "Uso mensual de Kilo Pass",
|
||||
"profile.pass.renews": "Se renueva",
|
||||
"profile.action.logout": "Cerrar sesión",
|
||||
|
||||
|
||||
+39
@@ -564,10 +564,49 @@ 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.direct": "مستقیم",
|
||||
"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.managePlan": "مدیریت {{plan}}",
|
||||
"profile.usage.routing": "صورتحساب طرح فعال است. مسیریابی Kilo Gateway {{state}} است.",
|
||||
"profile.usage.routingState.disabled": "غیرفعال",
|
||||
"profile.usage.routingState.missing": "ناموجود",
|
||||
"profile.usage.routingState.replaced": "جایگزینشده",
|
||||
"profile.usage.routingState.unknown": "نامشخص",
|
||||
"profile.usage.window.used": "{{value}} استفادهشده",
|
||||
"profile.usage.window.remaining": "{{value}} باقیمانده",
|
||||
"profile.usage.window.remainingOf": "{{value}} از {{limit}} باقیمانده",
|
||||
"profile.usage.window.usedOf": "{{value}} از {{limit}} استفادهشده",
|
||||
"profile.usage.window.quota": "سهمیه",
|
||||
"profile.usage.window.daily": "سهمیه روزانه",
|
||||
"profile.usage.window.weekly": "سهمیه هفتگی",
|
||||
"profile.usage.window.monthly": "سهمیه ماهانه",
|
||||
"profile.usage.window.hours": "سهمیه {{count}} ساعته",
|
||||
"profile.usage.window.days": "سهمیه {{count}} روزه",
|
||||
"profile.usage.window.weeks": "سهمیه {{count}} هفتهای",
|
||||
"profile.usage.window.months": "سهمیه {{count}} ماهه",
|
||||
"profile.usage.window.shared": "مشترک",
|
||||
"profile.usage.window.scoped": "{{resource}} · {{period}}",
|
||||
"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.topUp": "شارژ کردن",
|
||||
"profile.pass.subscribe": "Kilo Pass را دریافت کنید تا اعتبار اضافه کنید و پاداش کسب کنید",
|
||||
"profile.pass.bonus": "پاداش",
|
||||
"profile.pass.usage": "مصرف این ماه",
|
||||
"profile.pass.paid": "پرداختشده",
|
||||
"profile.pass.meter": "مصرف ماهانه Kilo Pass",
|
||||
"profile.pass.renews": "تمدید میشود",
|
||||
"profile.action.logout": "خروج",
|
||||
|
||||
|
||||
+39
@@ -663,10 +663,49 @@ 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.direct": "Direct",
|
||||
"profile.usage.state.stale": "Affichage des dernières données d'utilisation mises à jour.",
|
||||
"profile.usage.state.unavailable": "Données d'utilisation indisponibles.",
|
||||
"profile.usage.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.managePlan": "Gérer {{plan}}",
|
||||
"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.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.window.quota": "Quota",
|
||||
"profile.usage.window.daily": "Quota quotidien",
|
||||
"profile.usage.window.weekly": "Quota hebdomadaire",
|
||||
"profile.usage.window.monthly": "Quota mensuel",
|
||||
"profile.usage.window.hours": "Quota de {{count}} heures",
|
||||
"profile.usage.window.days": "Quota de {{count}} jours",
|
||||
"profile.usage.window.weeks": "Quota de {{count}} semaines",
|
||||
"profile.usage.window.months": "Quota de {{count}} mois",
|
||||
"profile.usage.window.shared": "Partagé",
|
||||
"profile.usage.window.scoped": "{{resource}} · {{period}}",
|
||||
"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.topUp": "Recharger",
|
||||
"profile.pass.subscribe": "Obtenez Kilo Pass pour ajouter des crédits et gagner des bonus",
|
||||
"profile.pass.bonus": "Bonus",
|
||||
"profile.pass.usage": "Consommation de ce mois-ci",
|
||||
"profile.pass.paid": "Payé",
|
||||
"profile.pass.meter": "Consommation mensuelle du Kilo Pass",
|
||||
"profile.pass.renews": "Renouvelle",
|
||||
"profile.action.logout": "Déconnexion",
|
||||
|
||||
|
||||
+39
@@ -491,10 +491,49 @@ 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.direct": "Diretto",
|
||||
"profile.usage.state.stale": "Vengono mostrati i dati di utilizzo dell'ultimo aggiornamento.",
|
||||
"profile.usage.state.unavailable": "Dati di utilizzo non disponibili.",
|
||||
"profile.usage.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.managePlan": "Gestisci {{plan}}",
|
||||
"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.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.window.quota": "Quota",
|
||||
"profile.usage.window.daily": "Quota giornaliera",
|
||||
"profile.usage.window.weekly": "Quota settimanale",
|
||||
"profile.usage.window.monthly": "Quota mensile",
|
||||
"profile.usage.window.hours": "Quota di {{count}} ore",
|
||||
"profile.usage.window.days": "Quota di {{count}} giorni",
|
||||
"profile.usage.window.weeks": "Quota di {{count}} settimane",
|
||||
"profile.usage.window.months": "Quota di {{count}} mesi",
|
||||
"profile.usage.window.shared": "Condivisa",
|
||||
"profile.usage.window.scoped": "{{resource}} · {{period}}",
|
||||
"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.topUp": "Ricarica",
|
||||
"profile.pass.subscribe": "Ottieni Kilo Pass per aggiungere crediti e guadagnare bonus",
|
||||
"profile.pass.bonus": "Bonus",
|
||||
"profile.pass.usage": "Utilizzo di questo mese",
|
||||
"profile.pass.paid": "Pagato",
|
||||
"profile.pass.meter": "Utilizzo mensile di Kilo Pass",
|
||||
"profile.pass.renews": "Si rinnova",
|
||||
"profile.action.logout": "Esci",
|
||||
"settings.agentBehaviour.title": "Comportamento agente",
|
||||
|
||||
+39
@@ -645,10 +645,49 @@ 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.direct": "直接",
|
||||
"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.managePlan": "{{plan}} を管理",
|
||||
"profile.usage.routing": "プランの請求は有効です。Kilo Gatewayのルーティングは{{state}}です。",
|
||||
"profile.usage.routingState.disabled": "無効",
|
||||
"profile.usage.routingState.missing": "欠落",
|
||||
"profile.usage.routingState.replaced": "置換済み",
|
||||
"profile.usage.routingState.unknown": "不明",
|
||||
"profile.usage.window.used": "{{value}} 使用済み",
|
||||
"profile.usage.window.remaining": "残り {{value}}",
|
||||
"profile.usage.window.remainingOf": "{{limit}} のうち残り {{value}}",
|
||||
"profile.usage.window.usedOf": "{{limit}} のうち {{value}} 使用済み",
|
||||
"profile.usage.window.quota": "クォータ",
|
||||
"profile.usage.window.daily": "日次クォータ",
|
||||
"profile.usage.window.weekly": "週次クォータ",
|
||||
"profile.usage.window.monthly": "月次クォータ",
|
||||
"profile.usage.window.hours": "{{count}}時間クォータ",
|
||||
"profile.usage.window.days": "{{count}}日クォータ",
|
||||
"profile.usage.window.weeks": "{{count}}週間クォータ",
|
||||
"profile.usage.window.months": "{{count}}か月クォータ",
|
||||
"profile.usage.window.shared": "共有",
|
||||
"profile.usage.window.scoped": "{{resource}} · {{period}}",
|
||||
"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.topUp": "チャージ",
|
||||
"profile.pass.subscribe": "Kilo Passに登録してクレジットを追加し、ボーナスを獲得",
|
||||
"profile.pass.bonus": "ボーナス",
|
||||
"profile.pass.usage": "今月の使用量",
|
||||
"profile.pass.paid": "有料分",
|
||||
"profile.pass.meter": "Kilo Pass の月間使用量",
|
||||
"profile.pass.renews": "更新",
|
||||
"profile.action.logout": "ログアウト",
|
||||
|
||||
|
||||
+39
@@ -605,10 +605,49 @@ 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.direct": "직접",
|
||||
"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.managePlan": "{{plan}} 관리",
|
||||
"profile.usage.routing": "요금제 결제가 활성화되어 있습니다. Kilo Gateway 라우팅은 {{state}}입니다.",
|
||||
"profile.usage.routingState.disabled": "비활성화 상태",
|
||||
"profile.usage.routingState.missing": "누락된 상태",
|
||||
"profile.usage.routingState.replaced": "대체된 상태",
|
||||
"profile.usage.routingState.unknown": "알 수 없는 상태",
|
||||
"profile.usage.window.used": "{{value}} 사용됨",
|
||||
"profile.usage.window.remaining": "{{value}} 남음",
|
||||
"profile.usage.window.remainingOf": "{{limit}} 중 {{value}} 남음",
|
||||
"profile.usage.window.usedOf": "{{limit}} 중 {{value}} 사용됨",
|
||||
"profile.usage.window.quota": "할당량",
|
||||
"profile.usage.window.daily": "일일 할당량",
|
||||
"profile.usage.window.weekly": "주간 할당량",
|
||||
"profile.usage.window.monthly": "월간 할당량",
|
||||
"profile.usage.window.hours": "{{count}}시간 할당량",
|
||||
"profile.usage.window.days": "{{count}}일 할당량",
|
||||
"profile.usage.window.weeks": "{{count}}주 할당량",
|
||||
"profile.usage.window.months": "{{count}}개월 할당량",
|
||||
"profile.usage.window.shared": "공유",
|
||||
"profile.usage.window.scoped": "{{resource}} · {{period}}",
|
||||
"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.topUp": "충전",
|
||||
"profile.pass.subscribe": "Kilo Pass를 구독하여 크레딧을 추가하고 보너스를 받으세요",
|
||||
"profile.pass.bonus": "보너스",
|
||||
"profile.pass.usage": "이번 달 사용량",
|
||||
"profile.pass.paid": "유료",
|
||||
"profile.pass.meter": "Kilo Pass 월간 사용량",
|
||||
"profile.pass.renews": "갱신",
|
||||
"profile.action.logout": "로그아웃",
|
||||
|
||||
|
||||
+39
@@ -604,10 +604,49 @@ 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.direct": "Direct",
|
||||
"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.managePlan": "{{plan}} 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.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.window.quota": "Quotum",
|
||||
"profile.usage.window.daily": "Dagelijks quotum",
|
||||
"profile.usage.window.weekly": "Wekelijks quotum",
|
||||
"profile.usage.window.monthly": "Maandelijks quotum",
|
||||
"profile.usage.window.hours": "Quotum per {{count}} uur",
|
||||
"profile.usage.window.days": "Quotum per {{count}} dagen",
|
||||
"profile.usage.window.weeks": "Quotum per {{count}} weken",
|
||||
"profile.usage.window.months": "Quotum per {{count}} maanden",
|
||||
"profile.usage.window.shared": "Gedeeld",
|
||||
"profile.usage.window.scoped": "{{resource}} · {{period}}",
|
||||
"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.topUp": "Opwaarderen",
|
||||
"profile.pass.subscribe": "Schaf Kilo Pass aan om tegoed toe te voegen en bonussen te verdienen",
|
||||
"profile.pass.bonus": "Bonus",
|
||||
"profile.pass.usage": "Verbruik deze maand",
|
||||
"profile.pass.paid": "Betaald",
|
||||
"profile.pass.meter": "Maandelijks Kilo Pass-verbruik",
|
||||
"profile.pass.renews": "Vernieuwt",
|
||||
"profile.action.logout": "Uitloggen",
|
||||
|
||||
|
||||
+39
@@ -612,10 +612,49 @@ 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.direct": "Direkte",
|
||||
"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.managePlan": "Administrer {{plan}}",
|
||||
"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.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.window.quota": "Kvote",
|
||||
"profile.usage.window.daily": "Daglig kvote",
|
||||
"profile.usage.window.weekly": "Ukentlig kvote",
|
||||
"profile.usage.window.monthly": "Månedlig kvote",
|
||||
"profile.usage.window.hours": "{{count}}-timers kvote",
|
||||
"profile.usage.window.days": "{{count}}-dagers kvote",
|
||||
"profile.usage.window.weeks": "{{count}}-ukers kvote",
|
||||
"profile.usage.window.months": "{{count}}-måneders kvote",
|
||||
"profile.usage.window.shared": "Delt",
|
||||
"profile.usage.window.scoped": "{{resource}} · {{period}}",
|
||||
"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.topUp": "Fyll på",
|
||||
"profile.pass.subscribe": "Få Kilo Pass for å legge til kreditt og tjene bonuser",
|
||||
"profile.pass.bonus": "Bonus",
|
||||
"profile.pass.usage": "Denne månedens forbruk",
|
||||
"profile.pass.paid": "Betalt",
|
||||
"profile.pass.meter": "Månedlig Kilo Pass-forbruk",
|
||||
"profile.pass.renews": "Fornyes",
|
||||
"profile.action.logout": "Logg ut",
|
||||
|
||||
|
||||
+39
@@ -608,10 +608,49 @@ 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.direct": "Bezpośrednio",
|
||||
"profile.usage.state.stale": "Wyświetlane są ostatnio zaktualizowane dane o wykorzystaniu.",
|
||||
"profile.usage.state.unavailable": "Dane o wykorzystaniu są niedostępne.",
|
||||
"profile.usage.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.managePlan": "Zarządzaj {{plan}}",
|
||||
"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.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.window.quota": "Limit",
|
||||
"profile.usage.window.daily": "Limit dzienny",
|
||||
"profile.usage.window.weekly": "Limit tygodniowy",
|
||||
"profile.usage.window.monthly": "Limit miesięczny",
|
||||
"profile.usage.window.hours": "Limit {{count}}-godzinny",
|
||||
"profile.usage.window.days": "Limit {{count}}-dniowy",
|
||||
"profile.usage.window.weeks": "Limit {{count}}-tygodniowy",
|
||||
"profile.usage.window.months": "Limit {{count}}-miesięczny",
|
||||
"profile.usage.window.shared": "Wspólny",
|
||||
"profile.usage.window.scoped": "{{resource}} · {{period}}",
|
||||
"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.topUp": "Doładuj",
|
||||
"profile.pass.subscribe": "Zdobądź Kilo Pass, aby dodać środki i zdobywać bonusy",
|
||||
"profile.pass.bonus": "Bonus",
|
||||
"profile.pass.usage": "Zużycie w tym miesiącu",
|
||||
"profile.pass.paid": "Opłacone",
|
||||
"profile.pass.meter": "Miesięczne zużycie Kilo Pass",
|
||||
"profile.pass.renews": "Odnawia się",
|
||||
"profile.action.logout": "Wyloguj się",
|
||||
|
||||
|
||||
+39
@@ -649,10 +649,49 @@ 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.direct": "Напрямую",
|
||||
"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.managePlan": "Управление {{plan}}",
|
||||
"profile.usage.routing": "Оплата тарифа активна. Маршрутизация через Kilo Gateway {{state}}.",
|
||||
"profile.usage.routingState.disabled": "отключена",
|
||||
"profile.usage.routingState.missing": "отсутствует",
|
||||
"profile.usage.routingState.replaced": "заменена",
|
||||
"profile.usage.routingState.unknown": "неизвестна",
|
||||
"profile.usage.window.used": "Использовано {{value}}",
|
||||
"profile.usage.window.remaining": "Осталось {{value}}",
|
||||
"profile.usage.window.remainingOf": "Осталось {{value}} из {{limit}}",
|
||||
"profile.usage.window.usedOf": "Использовано {{value}} из {{limit}}",
|
||||
"profile.usage.window.quota": "Квота",
|
||||
"profile.usage.window.daily": "Дневная квота",
|
||||
"profile.usage.window.weekly": "Недельная квота",
|
||||
"profile.usage.window.monthly": "Месячная квота",
|
||||
"profile.usage.window.hours": "{{count}}-часовая квота",
|
||||
"profile.usage.window.days": "{{count}}-дневная квота",
|
||||
"profile.usage.window.weeks": "{{count}}-недельная квота",
|
||||
"profile.usage.window.months": "{{count}}-месячная квота",
|
||||
"profile.usage.window.shared": "Общая",
|
||||
"profile.usage.window.scoped": "{{resource}} · {{period}}",
|
||||
"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.topUp": "Пополнить",
|
||||
"profile.pass.subscribe": "Оформите Kilo Pass, чтобы добавить кредиты и получать бонусы",
|
||||
"profile.pass.bonus": "Бонус",
|
||||
"profile.pass.usage": "Использование за этот месяц",
|
||||
"profile.pass.paid": "Оплачено",
|
||||
"profile.pass.meter": "Ежемесячное использование Kilo Pass",
|
||||
"profile.pass.renews": "Продлевается",
|
||||
"profile.action.logout": "Выйти",
|
||||
|
||||
|
||||
+39
@@ -642,10 +642,49 @@ 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.direct": "โดยตรง",
|
||||
"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.managePlan": "จัดการ {{plan}}",
|
||||
"profile.usage.routing": "การเรียกเก็บเงินตามแผนเปิดใช้งานอยู่ การกำหนดเส้นทาง Kilo Gateway {{state}}",
|
||||
"profile.usage.routingState.disabled": "ปิดใช้งาน",
|
||||
"profile.usage.routingState.missing": "ขาดหาย",
|
||||
"profile.usage.routingState.replaced": "ถูกแทนที่",
|
||||
"profile.usage.routingState.unknown": "ไม่ทราบ",
|
||||
"profile.usage.window.used": "ใช้ไป {{value}}",
|
||||
"profile.usage.window.remaining": "เหลือ {{value}}",
|
||||
"profile.usage.window.remainingOf": "เหลือ {{value}} จาก {{limit}}",
|
||||
"profile.usage.window.usedOf": "ใช้ไป {{value}} จาก {{limit}}",
|
||||
"profile.usage.window.quota": "โควตา",
|
||||
"profile.usage.window.daily": "โควตารายวัน",
|
||||
"profile.usage.window.weekly": "โควตารายสัปดาห์",
|
||||
"profile.usage.window.monthly": "โควตารายเดือน",
|
||||
"profile.usage.window.hours": "โควตา {{count}} ชั่วโมง",
|
||||
"profile.usage.window.days": "โควตา {{count}} วัน",
|
||||
"profile.usage.window.weeks": "โควตา {{count}} สัปดาห์",
|
||||
"profile.usage.window.months": "โควตา {{count}} เดือน",
|
||||
"profile.usage.window.shared": "ใช้ร่วมกัน",
|
||||
"profile.usage.window.scoped": "{{resource}} · {{period}}",
|
||||
"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.topUp": "เติมเงิน",
|
||||
"profile.pass.subscribe": "รับ Kilo Pass เพื่อเพิ่มเครดิตและรับโบนัส",
|
||||
"profile.pass.bonus": "โบนัส",
|
||||
"profile.pass.usage": "การใช้งานเดือนนี้",
|
||||
"profile.pass.paid": "ชำระแล้ว",
|
||||
"profile.pass.meter": "การใช้งาน Kilo Pass รายเดือน",
|
||||
"profile.pass.renews": "ต่ออายุ",
|
||||
"profile.action.logout": "ออกจากระบบ",
|
||||
|
||||
|
||||
+39
@@ -601,10 +601,49 @@ 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.direct": "Doğrudan",
|
||||
"profile.usage.state.stale": "Son güncellenen kullanım verileri gösteriliyor.",
|
||||
"profile.usage.state.unavailable": "Kullanım verileri kullanılamıyor.",
|
||||
"profile.usage.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.managePlan": "{{plan}} planını 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.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.window.quota": "Kota",
|
||||
"profile.usage.window.daily": "Günlük kota",
|
||||
"profile.usage.window.weekly": "Haftalık kota",
|
||||
"profile.usage.window.monthly": "Aylık kota",
|
||||
"profile.usage.window.hours": "{{count}} saatlik kota",
|
||||
"profile.usage.window.days": "{{count}} günlük kota",
|
||||
"profile.usage.window.weeks": "{{count}} haftalık kota",
|
||||
"profile.usage.window.months": "{{count}} aylık kota",
|
||||
"profile.usage.window.shared": "Paylaşımlı",
|
||||
"profile.usage.window.scoped": "{{resource}} · {{period}}",
|
||||
"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.topUp": "Bakiye yükle",
|
||||
"profile.pass.subscribe": "Kredi eklemek ve bonus kazanmak için Kilo Pass edinin",
|
||||
"profile.pass.bonus": "Bonus",
|
||||
"profile.pass.usage": "Bu ayki kullanım",
|
||||
"profile.pass.paid": "Ücretli",
|
||||
"profile.pass.meter": "Aylık Kilo Pass kullanımı",
|
||||
"profile.pass.renews": "Yenilenir",
|
||||
"profile.action.logout": "Çıkış Yap",
|
||||
|
||||
|
||||
+39
@@ -602,10 +602,49 @@ 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.direct": "Напряму",
|
||||
"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.managePlan": "Керування {{plan}}",
|
||||
"profile.usage.routing": "Оплата плану активна. Маршрутизація через Kilo Gateway {{state}}.",
|
||||
"profile.usage.routingState.disabled": "вимкнена",
|
||||
"profile.usage.routingState.missing": "відсутня",
|
||||
"profile.usage.routingState.replaced": "замінена",
|
||||
"profile.usage.routingState.unknown": "невідома",
|
||||
"profile.usage.window.used": "Використано {{value}}",
|
||||
"profile.usage.window.remaining": "Залишилося {{value}}",
|
||||
"profile.usage.window.remainingOf": "Залишилося {{value}} з {{limit}}",
|
||||
"profile.usage.window.usedOf": "Використано {{value}} з {{limit}}",
|
||||
"profile.usage.window.quota": "Квота",
|
||||
"profile.usage.window.daily": "Денна квота",
|
||||
"profile.usage.window.weekly": "Тижнева квота",
|
||||
"profile.usage.window.monthly": "Місячна квота",
|
||||
"profile.usage.window.hours": "{{count}}-годинна квота",
|
||||
"profile.usage.window.days": "{{count}}-денна квота",
|
||||
"profile.usage.window.weeks": "{{count}}-тижнева квота",
|
||||
"profile.usage.window.months": "{{count}}-місячна квота",
|
||||
"profile.usage.window.shared": "Спільна",
|
||||
"profile.usage.window.scoped": "{{resource}} · {{period}}",
|
||||
"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.topUp": "Поповнити",
|
||||
"profile.pass.subscribe": "Отримайте Kilo Pass, щоб додати кредити та заробляти бонуси",
|
||||
"profile.pass.bonus": "Бонус",
|
||||
"profile.pass.usage": "Використання за цей місяць",
|
||||
"profile.pass.paid": "Оплачено",
|
||||
"profile.pass.meter": "Щомісячне використання Kilo Pass",
|
||||
"profile.pass.renews": "Поновлюється",
|
||||
"profile.action.logout": "Вийти",
|
||||
|
||||
|
||||
+39
@@ -626,10 +626,49 @@ 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.direct": "直接",
|
||||
"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.managePlan": "管理 {{plan}}",
|
||||
"profile.usage.routing": "套餐账单处于有效状态。Kilo Gateway 路由状态为 {{state}}。",
|
||||
"profile.usage.routingState.disabled": "已禁用",
|
||||
"profile.usage.routingState.missing": "缺失",
|
||||
"profile.usage.routingState.replaced": "已替换",
|
||||
"profile.usage.routingState.unknown": "未知",
|
||||
"profile.usage.window.used": "已使用 {{value}}",
|
||||
"profile.usage.window.remaining": "剩余 {{value}}",
|
||||
"profile.usage.window.remainingOf": "共 {{limit}},剩余 {{value}}",
|
||||
"profile.usage.window.usedOf": "共 {{limit}},已使用 {{value}}",
|
||||
"profile.usage.window.quota": "配额",
|
||||
"profile.usage.window.daily": "每日配额",
|
||||
"profile.usage.window.weekly": "每周配额",
|
||||
"profile.usage.window.monthly": "每月配额",
|
||||
"profile.usage.window.hours": "{{count}}小时配额",
|
||||
"profile.usage.window.days": "{{count}}天配额",
|
||||
"profile.usage.window.weeks": "{{count}}周配额",
|
||||
"profile.usage.window.months": "{{count}}个月配额",
|
||||
"profile.usage.window.shared": "共享",
|
||||
"profile.usage.window.scoped": "{{resource}} · {{period}}",
|
||||
"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.topUp": "充值",
|
||||
"profile.pass.subscribe": "订阅 Kilo Pass 以添加额度并赚取奖励",
|
||||
"profile.pass.bonus": "奖励",
|
||||
"profile.pass.usage": "本月用量",
|
||||
"profile.pass.paid": "付费",
|
||||
"profile.pass.meter": "Kilo Pass 每月用量",
|
||||
"profile.pass.renews": "续订",
|
||||
"profile.action.logout": "退出登录",
|
||||
|
||||
|
||||
+39
@@ -586,10 +586,49 @@ 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.direct": "直接",
|
||||
"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.managePlan": "管理 {{plan}}",
|
||||
"profile.usage.routing": "方案帳單目前有效。Kilo Gateway 路由狀態為 {{state}}。",
|
||||
"profile.usage.routingState.disabled": "已停用",
|
||||
"profile.usage.routingState.missing": "缺失",
|
||||
"profile.usage.routingState.replaced": "已取代",
|
||||
"profile.usage.routingState.unknown": "未知",
|
||||
"profile.usage.window.used": "已使用 {{value}}",
|
||||
"profile.usage.window.remaining": "剩餘 {{value}}",
|
||||
"profile.usage.window.remainingOf": "共 {{limit}},剩餘 {{value}}",
|
||||
"profile.usage.window.usedOf": "共 {{limit}},已使用 {{value}}",
|
||||
"profile.usage.window.quota": "配額",
|
||||
"profile.usage.window.daily": "每日配額",
|
||||
"profile.usage.window.weekly": "每週配額",
|
||||
"profile.usage.window.monthly": "每月配額",
|
||||
"profile.usage.window.hours": "{{count}}小時配額",
|
||||
"profile.usage.window.days": "{{count}}天配額",
|
||||
"profile.usage.window.weeks": "{{count}}週配額",
|
||||
"profile.usage.window.months": "{{count}}個月配額",
|
||||
"profile.usage.window.shared": "共享",
|
||||
"profile.usage.window.scoped": "{{resource}} · {{period}}",
|
||||
"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.topUp": "儲值",
|
||||
"profile.pass.subscribe": "訂閱 Kilo Pass 以新增額度並賺取獎勵",
|
||||
"profile.pass.bonus": "獎勵",
|
||||
"profile.pass.usage": "本月用量",
|
||||
"profile.pass.paid": "付費",
|
||||
"profile.pass.meter": "Kilo Pass 每月用量",
|
||||
"profile.pass.renews": "續訂",
|
||||
"profile.action.logout": "登出",
|
||||
|
||||
|
||||
@@ -229,6 +229,11 @@ const chatServer = {
|
||||
errorDetails: () => undefined,
|
||||
isConnected: () => true,
|
||||
profileData: () => null,
|
||||
providerUsage: () => undefined,
|
||||
providerUsageLoading: () => false,
|
||||
providerUsageError: () => undefined,
|
||||
requestProviderUsage: () => undefined,
|
||||
refreshProviderUsage: () => undefined,
|
||||
deviceAuth: () => ({ status: "idle" as const }),
|
||||
startLogin: () => undefined,
|
||||
goToLogin: () => undefined,
|
||||
|
||||
@@ -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",
|
||||
@@ -46,37 +46,127 @@ const personalProfile: ProfileData = {
|
||||
|
||||
const idleAuth: DeviceAuthState = { status: "idle" }
|
||||
|
||||
const usage: ProviderUsageData = {
|
||||
generatedAt: "2026-06-19T12:00:00.000Z",
|
||||
items: [
|
||||
{
|
||||
id: "kilo-managed:plan",
|
||||
providerID: "minimax",
|
||||
sourceKind: "kilo_managed",
|
||||
providerLabel: "MiniMax",
|
||||
planLabel: "Token Plan Plus",
|
||||
sourceLabel: "via Kilo",
|
||||
fetchState: "ready",
|
||||
planState: "active",
|
||||
routingState: "active",
|
||||
fetchedAt: "2026-06-19T12:00:00.000Z",
|
||||
managementUrl: "https://app.kilo.ai/subscriptions/coding-plans/plan",
|
||||
windows: [
|
||||
{
|
||||
id: "general-interval",
|
||||
resource: "general",
|
||||
period: { unit: "hour", value: 5 },
|
||||
unit: "percent",
|
||||
orientation: "remaining_percent",
|
||||
used: 24,
|
||||
remaining: 76,
|
||||
limit: 100,
|
||||
state: "active",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const directUsage: ProviderUsageData = {
|
||||
generatedAt: usage.generatedAt,
|
||||
items: [
|
||||
{
|
||||
...usage.items[0],
|
||||
id: "minimax-direct-global",
|
||||
providerID: "minimax-coding-plan",
|
||||
sourceKind: "direct",
|
||||
sourceLabel: "MiniMax Global",
|
||||
routingState: "not_applicable",
|
||||
managementUrl: "https://platform.minimax.io/subscribe/token-plan",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const noop = () => {}
|
||||
const render = (profileData: ProfileData | null, providerUsage: ProviderUsageData, height: number, error?: string) => (
|
||||
<StoryProviders noPadding>
|
||||
<div style={{ width: "420px", height: `${height}px` }}>
|
||||
<ProfileView
|
||||
profileData={profileData}
|
||||
providerUsage={providerUsage}
|
||||
providerUsageError={error}
|
||||
deviceAuth={idleAuth}
|
||||
onLogin={noop}
|
||||
/>
|
||||
</div>
|
||||
</StoryProviders>
|
||||
)
|
||||
|
||||
export const LoggedIn: Story = {
|
||||
name: "ProfileView — logged in with orgs",
|
||||
render: () => (
|
||||
<StoryProviders noPadding>
|
||||
<div style={{ width: "420px", height: "500px" }}>
|
||||
<ProfileView profileData={loggedInProfile} deviceAuth={idleAuth} onLogin={noop} />
|
||||
</div>
|
||||
</StoryProviders>
|
||||
),
|
||||
render: () => render(loggedInProfile, usage, 900),
|
||||
}
|
||||
|
||||
export const LoggedInPersonal: Story = {
|
||||
name: "ProfileView — personal account",
|
||||
render: () => (
|
||||
<StoryProviders noPadding>
|
||||
<div style={{ width: "420px", height: "400px" }}>
|
||||
<ProfileView profileData={personalProfile} deviceAuth={idleAuth} onLogin={noop} />
|
||||
</div>
|
||||
</StoryProviders>
|
||||
),
|
||||
render: () => render(personalProfile, usage, 900),
|
||||
}
|
||||
|
||||
export const ScrollableUsage: Story = {
|
||||
name: "ProfileView — scrollable usage",
|
||||
render: () => render(personalProfile, usage, 480),
|
||||
play: (context: { canvasElement: HTMLElement }) => {
|
||||
const pane = context.canvasElement.querySelector<HTMLElement>("[data-profile-scroll]")
|
||||
if (pane) pane.scrollTop = pane.scrollHeight
|
||||
},
|
||||
}
|
||||
|
||||
export const NotLoggedIn: Story = {
|
||||
name: "ProfileView — not logged in",
|
||||
render: () => (
|
||||
<StoryProviders noPadding>
|
||||
<div style={{ width: "420px", height: "300px" }}>
|
||||
<ProfileView profileData={null} deviceAuth={idleAuth} onLogin={noop} />
|
||||
</div>
|
||||
</StoryProviders>
|
||||
),
|
||||
render: () => render(null, directUsage, 620),
|
||||
}
|
||||
|
||||
export const OrganizationContext: Story = {
|
||||
name: "ProfileView — organization context",
|
||||
render: () =>
|
||||
render({ ...loggedInProfile, currentOrgId: "org-1" }, { generatedAt: usage.generatedAt, items: [] }, 620),
|
||||
}
|
||||
|
||||
export const StaleAndUnavailable: Story = {
|
||||
name: "ProfileView — stale and unavailable usage",
|
||||
render: () =>
|
||||
render(
|
||||
personalProfile,
|
||||
{
|
||||
generatedAt: usage.generatedAt,
|
||||
items: [
|
||||
{
|
||||
...directUsage.items[0],
|
||||
fetchState: "stale",
|
||||
planState: "unknown",
|
||||
error: { code: "timeout", message: "The latest usage could not be loaded.", retryable: true },
|
||||
},
|
||||
{
|
||||
...usage.items[0],
|
||||
id: "managed-unavailable",
|
||||
fetchState: "unavailable",
|
||||
windows: [],
|
||||
error: { code: "upstream", message: "Usage unavailable.", retryable: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
760,
|
||||
"Provider usage could not be refreshed.",
|
||||
),
|
||||
}
|
||||
|
||||
export const EmptyUsage: Story = {
|
||||
name: "ProfileView — no usage sources",
|
||||
render: () => render(personalProfile, { generatedAt: usage.generatedAt, items: [] }, 480),
|
||||
}
|
||||
|
||||
@@ -26,4 +26,5 @@
|
||||
@import "./plan-exit.css";
|
||||
@import "./suggest-bar.css";
|
||||
@import "./settings.css";
|
||||
@import "./provider-usage.css";
|
||||
@import "./high-contrast.css";
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/* Provider Usage */
|
||||
.provider-usage-section {
|
||||
margin-top: 24px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--border-weak-base);
|
||||
}
|
||||
|
||||
.provider-usage-section-heading,
|
||||
.provider-usage-summary {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.provider-usage-section-heading {
|
||||
margin-bottom: 12px;
|
||||
/* Optically align with the text inside the cards (1px border + 8px card padding) */
|
||||
padding: 0 9px;
|
||||
}
|
||||
|
||||
.provider-usage-section-heading h3 {
|
||||
margin: 0;
|
||||
color: var(--text-base);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.provider-usage-section-heading p,
|
||||
.provider-usage-notice {
|
||||
margin: 3px 0 0;
|
||||
color: var(--text-weak);
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
.provider-usage-list,
|
||||
.provider-usage-resources {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.provider-usage-row {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.provider-usage-summary {
|
||||
align-items: baseline;
|
||||
color: var(--text-base);
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.provider-usage-summary strong {
|
||||
text-align: right;
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.provider-usage-notice {
|
||||
padding-left: 8px;
|
||||
border-left: 2px solid var(--icon-warning-base);
|
||||
}
|
||||
|
||||
/* Align the action button label with the card content; small buttons have 12px inline padding */
|
||||
.provider-usage-list [data-slot="card-actions"] {
|
||||
margin-left: -12px;
|
||||
}
|
||||
|
||||
.provider-usage-loading {
|
||||
display: grid;
|
||||
min-height: 92px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.provider-usage-section-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import type {
|
||||
} 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,
|
||||
@@ -1400,6 +1401,7 @@ export type ExtensionMessage =
|
||||
| GitRemoteUrlLoadedMessage
|
||||
| ActionMessage
|
||||
| ProfileDataMessage
|
||||
| ProviderUsageLoadedMessage
|
||||
| DeviceAuthStartedMessage
|
||||
| DeviceAuthCompleteMessage
|
||||
| DeviceAuthFailedMessage
|
||||
|
||||
@@ -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 "./memory"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { Config } from "./config"
|
||||
import type { ModelAllocation, ReviewCommentEntry, TerminalDestination, TerminalPlacement } from "./agent-manager"
|
||||
import type { ReviewMessageData } from "../../../../src/shared/review-comments"
|
||||
import type { WorkStyle, WorkStyleState } from "../../../../src/shared/work-style-presets"
|
||||
import type { RefreshProviderUsageMessage, RequestProviderUsageMessage } from "./provider-usage"
|
||||
import type { AnacondaDesktopWebviewMessage } from "../../../../src/shared/anaconda-desktop-messages"
|
||||
import type {
|
||||
ClearLegacyDataMessage,
|
||||
@@ -1478,6 +1479,8 @@ export type WebviewMessage =
|
||||
| LoginRequest
|
||||
| LogoutRequest
|
||||
| RefreshProfileRequest
|
||||
| RequestProviderUsageMessage
|
||||
| RefreshProviderUsageMessage
|
||||
| OpenExternalRequest
|
||||
| OpenSettingsPanelRequest
|
||||
| OpenProfilePanelRequest
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import type { ProviderUsage, ProviderUsageSnapshot } from "@kilocode/sdk/v2"
|
||||
import { formatWindow, windowLabel } from "@kilocode/kilo-gateway/provider-usage"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
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"
|
||||
|
||||
function Item(props: { item: ProviderUsageSnapshot }) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<box border={true} borderColor={theme.border} paddingLeft={1} paddingRight={1} marginBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
||||
{props.item.providerLabel} - {props.item.planLabel}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{props.item.sourceLabel}</text>
|
||||
</box>
|
||||
<text fg={props.item.fetchState === "ready" ? theme.textMuted : theme.warning}>
|
||||
{props.item.fetchState === "ready" ? props.item.planState : props.item.fetchState}
|
||||
</text>
|
||||
<For each={props.item.windows}>
|
||||
{(window) => (
|
||||
<box>
|
||||
<text fg={theme.text}>
|
||||
{windowLabel(window)}: {formatWindow(window)}
|
||||
</text>
|
||||
<Show when={window.resetAt}>
|
||||
{(reset) => <text fg={theme.textMuted}>Resets {new Date(reset()).toLocaleString()}</text>}
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
<Show when={props.item.routingState !== "not_applicable" && props.item.routingState !== "active"}>
|
||||
<text fg={theme.warning}>Routing: {props.item.routingState}</text>
|
||||
</Show>
|
||||
<Show when={props.item.error}>{(error) => <text fg={theme.warning}>{error().message}</text>}</Show>
|
||||
<Show when={props.item.managementUrl}>
|
||||
{(url) => (
|
||||
<box flexDirection="row">
|
||||
<text fg={theme.textMuted}>Manage: </text>
|
||||
<Link href={url()} fg={theme.primary}>
|
||||
{url()}
|
||||
</Link>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderUsageBody(props: { data: ProviderUsage }) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<box>
|
||||
<Show
|
||||
when={props.data.items.length > 0}
|
||||
fallback={<text fg={theme.textMuted}>No provider usage sources detected.</text>}
|
||||
>
|
||||
<For each={props.data.items}>{(item) => <Item item={item} />}</For>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogProviderUsage() {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const [data, setData] = createSignal<ProviderUsage>()
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [failure, setFailure] = createSignal<string>()
|
||||
|
||||
async function load(force: boolean) {
|
||||
if (loading()) return
|
||||
setLoading(true)
|
||||
setFailure(undefined)
|
||||
const response = await (force
|
||||
? sdk.client.kilocode.providerUsage.refresh().catch(() => undefined)
|
||||
: sdk.client.kilocode.providerUsage.get().catch(() => 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 (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
||||
Plans & usage
|
||||
</text>
|
||||
<text fg={theme.textMuted}>esc</text>
|
||||
</box>
|
||||
<scrollbox maxHeight={24} flexGrow={1}>
|
||||
<box>
|
||||
<Show when={data()}>{(value) => <ProviderUsageBody data={value()} />}</Show>
|
||||
<Show when={loading() && !data()}>
|
||||
<Spinner />
|
||||
</Show>
|
||||
<Show when={failure()}>{(message) => <text fg={theme.warning}>{message()}</text>}</Show>
|
||||
</box>
|
||||
</scrollbox>
|
||||
<box flexDirection="row" justifyContent="flex-end" gap={2}>
|
||||
<text fg={loading() ? theme.textMuted : theme.primary} onMouseUp={() => !loading() && void load(true)}>
|
||||
refresh ctrl+r
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,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"
|
||||
import { refreshBalance } from "./balance-refresh"
|
||||
|
||||
@@ -127,6 +128,18 @@ export function registerKiloCommands(useSDK: () => UseSDK) {
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "kilo.usage",
|
||||
title: "Plans & usage",
|
||||
desc: "View provider plans and quota",
|
||||
category: "Kilo",
|
||||
slashName: "usage",
|
||||
slashAliases: ["plans", "quota"],
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogProviderUsage />)
|
||||
},
|
||||
},
|
||||
|
||||
// /profile command
|
||||
{
|
||||
name: "kilo.profile",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
WorkspaceRoutingQueryFields,
|
||||
} from "@/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { described } from "@/server/routes/instance/httpapi/groups/metadata"
|
||||
import { ProviderUsage } from "@opencode-ai/schema/kilocode/provider-usage"
|
||||
import { AnacondaDesktopApi } from "./anaconda-desktop"
|
||||
import {
|
||||
Failure as AgentManagerFailure,
|
||||
@@ -68,6 +69,8 @@ export const KilocodePaths = {
|
||||
removeCommand: `${root}/command/remove`,
|
||||
removeSkill: `${root}/skill/remove`,
|
||||
removeAgent: `${root}/agent/remove`,
|
||||
providerUsage: `${root}/provider-usage`,
|
||||
providerUsageRefresh: `${root}/provider-usage/refresh`,
|
||||
notebookList: `${root}/notebook`,
|
||||
notebookReply: `${root}/notebook/:requestID/reply`,
|
||||
notebookReject: `${root}/notebook/:requestID/reject`,
|
||||
@@ -141,6 +144,28 @@ export const KilocodeApi = HttpApi.make("kilocode")
|
||||
"Remove a custom (non-native) agent from one writable configuration scope, or every writable scope when omitted, and dispose cached instance state.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("providerUsage", KilocodePaths.providerUsage, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(ProviderUsage.Info, "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(ProviderUsage.Info, "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.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("notebookList", KilocodePaths.notebookList, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(NotebookRequest), "Pending notebook host requests"),
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as KiloSkill from "@/kilocode/skill-remove"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Command } from "@/command"
|
||||
import { Config } from "@/config/config"
|
||||
import { WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { HeapSnapshot } from "@/kilocode/cli/heap-snapshot"
|
||||
import type { RequestID as AgentManagerRequestID } from "@/kilocode/agent-manager/protocol"
|
||||
@@ -13,6 +14,10 @@ import { AgentManager } from "@/kilocode/agent-manager/service"
|
||||
import type { RequestID as NotebookRequestID } from "@/kilocode/notebook/protocol"
|
||||
import { Notebook } from "@/kilocode/notebook/service"
|
||||
import { ModelUsage } from "@/kilocode/session/model-usage"
|
||||
import { ProviderUsage } from "@opencode-ai/core/kilocode/provider-usage"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api"
|
||||
import { InvalidRequestError } from "@/server/routes/instance/httpapi/errors"
|
||||
@@ -43,6 +48,21 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode"
|
||||
const notebook = yield* Notebook.Service
|
||||
const background = yield* BackgroundJob.Service
|
||||
const runState = yield* SessionRunState.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
|
||||
// Location-scoped services, keyed by the request's directory and workspace.
|
||||
const located = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||
return yield* effect.pipe(
|
||||
Effect.provide(
|
||||
locations.get(
|
||||
Location.Ref.make({
|
||||
directory: AbsolutePath.make((yield* InstanceState.context).directory),
|
||||
workspaceID: yield* WorkspaceRef,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const heapSnapshot = Effect.fn("KilocodeHttpApi.heapSnapshot")(function* () {
|
||||
return yield* Effect.sync(() => HeapSnapshot.write())
|
||||
@@ -117,6 +137,18 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode"
|
||||
return true
|
||||
})
|
||||
|
||||
const providerUsage = Effect.fn("KilocodeHttpApi.providerUsage")(function* () {
|
||||
return yield* located(ProviderUsage.Service.use((usage) => usage.get())).pipe(
|
||||
Effect.mapError(() => new HttpApiError.ServiceUnavailable({})),
|
||||
)
|
||||
})
|
||||
|
||||
const providerUsageRefresh = Effect.fn("KilocodeHttpApi.providerUsageRefresh")(function* () {
|
||||
return yield* located(ProviderUsage.Service.use((usage) => usage.refresh())).pipe(
|
||||
Effect.mapError(() => new HttpApiError.ServiceUnavailable({})),
|
||||
)
|
||||
})
|
||||
|
||||
const notebookList = Effect.fn("KilocodeHttpApi.notebookList")(function* () {
|
||||
return yield* notebook.list()
|
||||
})
|
||||
@@ -208,6 +240,8 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode"
|
||||
.handle("removeCommand", removeCommand)
|
||||
.handle("removeSkill", removeSkill)
|
||||
.handle("removeAgent", removeAgent)
|
||||
.handle("providerUsage", providerUsage)
|
||||
.handle("providerUsageRefresh", providerUsageRefresh)
|
||||
.handle("notebookList", notebookList)
|
||||
.handle("notebookReply", notebookReply)
|
||||
.handle("notebookReject", notebookReject)
|
||||
|
||||
@@ -664,6 +664,20 @@ export const kiloScenarios: Scenario[] = [
|
||||
check(!(yield* Effect.promise(() => Bun.file(location).exists())), "removed agent should not remain on disk")
|
||||
}),
|
||||
),
|
||||
http.protected
|
||||
.get("/kilocode/provider-usage", "kilocode.providerUsage.get")
|
||||
.inProject({ git: true })
|
||||
.json(200, (body) => {
|
||||
object(body)
|
||||
array(body.items)
|
||||
}),
|
||||
http.protected
|
||||
.post("/kilocode/provider-usage/refresh", "kilocode.providerUsage.refresh")
|
||||
.inProject({ git: true })
|
||||
.json(200, (body) => {
|
||||
object(body)
|
||||
array(body.items)
|
||||
}),
|
||||
http.protected
|
||||
.post("/kilocode/agent/remove", "kilocode.removeAgent.duplicates")
|
||||
.inProject({ git: true, init: duplicates })
|
||||
|
||||
@@ -152,6 +152,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 },
|
||||
{ method: "get", path: KilocodePaths.sessionModelUsage },
|
||||
{ method: "post", path: BranchNamePaths.generate },
|
||||
{ method: "get", path: MemoryPaths.status },
|
||||
@@ -238,6 +240,39 @@ describe("Kilo PublicApi OpenAPI contract", () => {
|
||||
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<string, Schema>
|
||||
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.ProviderUsageSnapshot?.properties?.windows).toBeDefined()
|
||||
for (const forbidden of [
|
||||
"key",
|
||||
"token",
|
||||
"authorization",
|
||||
"raw",
|
||||
"endpoint",
|
||||
"stripepaymentmethodid",
|
||||
"inventoryid",
|
||||
"upstreamplanid",
|
||||
"fingerprint",
|
||||
"ciphertext",
|
||||
]) {
|
||||
expect(
|
||||
fields.filter((field) => field.includes(forbidden)),
|
||||
forbidden,
|
||||
).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
test("documents the transcription model catalog route", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi)
|
||||
const route = spec.paths[KiloGatewayPaths.transcriptionModels]?.get
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
export * as ProviderUsage from "./provider-usage"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "../schema"
|
||||
|
||||
export interface UsageError extends Schema.Schema.Type<typeof UsageError> {}
|
||||
export const UsageError = Schema.Struct({
|
||||
code: Schema.String,
|
||||
message: Schema.String,
|
||||
retryable: Schema.Boolean,
|
||||
}).annotate({ identifier: "ProviderUsageError" })
|
||||
|
||||
export interface UsagePeriod extends Schema.Schema.Type<typeof UsagePeriod> {}
|
||||
export const UsagePeriod = Schema.Struct({
|
||||
unit: Schema.Literals(["hour", "day", "week", "month"]),
|
||||
value: Schema.Int,
|
||||
}).annotate({ identifier: "ProviderUsagePeriod" })
|
||||
|
||||
export interface UsageWindow extends Schema.Schema.Type<typeof UsageWindow> {}
|
||||
export const UsageWindow = Schema.Struct({
|
||||
id: Schema.String,
|
||||
resource: Schema.String,
|
||||
unit: Schema.String,
|
||||
orientation: Schema.Literals(["used_percent", "remaining_percent", "amount", "count"]),
|
||||
used: optional(Schema.Finite),
|
||||
remaining: optional(Schema.Finite),
|
||||
limit: optional(Schema.Finite),
|
||||
period: optional(UsagePeriod),
|
||||
durationMs: optional(Schema.Finite),
|
||||
resetAt: optional(Schema.String),
|
||||
state: Schema.Literals(["active", "exhausted", "unlimited", "not_in_plan", "unknown"]),
|
||||
}).annotate({ identifier: "ProviderUsageWindow" })
|
||||
|
||||
export interface UsageSnapshot extends Schema.Schema.Type<typeof UsageSnapshot> {}
|
||||
export const UsageSnapshot = Schema.Struct({
|
||||
id: Schema.String,
|
||||
providerID: Schema.String,
|
||||
sourceKind: Schema.Literals(["kilo_managed", "direct"]),
|
||||
providerLabel: Schema.String,
|
||||
planLabel: Schema.String,
|
||||
sourceLabel: 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"]),
|
||||
fetchedAt: optional(Schema.String),
|
||||
managementUrl: optional(Schema.String),
|
||||
windows: Schema.Array(UsageWindow),
|
||||
error: optional(UsageError),
|
||||
}).annotate({ identifier: "ProviderUsageSnapshot" })
|
||||
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
export const Info = Schema.Struct({
|
||||
items: Schema.Array(UsageSnapshot),
|
||||
generatedAt: Schema.String,
|
||||
}).annotate({ identifier: "ProviderUsage" })
|
||||
@@ -195,6 +195,10 @@ import type {
|
||||
KilocodeNotebookRejectResponses,
|
||||
KilocodeNotebookReplyErrors,
|
||||
KilocodeNotebookReplyResponses,
|
||||
KilocodeProviderUsageGetErrors,
|
||||
KilocodeProviderUsageGetResponses,
|
||||
KilocodeProviderUsageRefreshErrors,
|
||||
KilocodeProviderUsageRefreshResponses,
|
||||
KilocodeRemoveAgentErrors,
|
||||
KilocodeRemoveAgentResponses,
|
||||
KilocodeRemoveCommandErrors,
|
||||
@@ -7446,6 +7450,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<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
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<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
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 Notebook extends HeyApiClient {
|
||||
/**
|
||||
* List pending notebook requests
|
||||
@@ -8346,6 +8420,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 _notebook?: Notebook
|
||||
get notebook(): Notebook {
|
||||
return (this._notebook ??= new Notebook({ client: this.client }))
|
||||
|
||||
@@ -4155,6 +4155,52 @@ export type CommandFile = {
|
||||
hints: Array<string>
|
||||
}
|
||||
|
||||
export type ProviderUsagePeriod = {
|
||||
unit: "hour" | "day" | "week" | "month"
|
||||
value: number
|
||||
}
|
||||
|
||||
export type ProviderUsageWindow = {
|
||||
id: string
|
||||
resource: string
|
||||
unit: string
|
||||
orientation: "used_percent" | "remaining_percent" | "amount" | "count"
|
||||
used?: number
|
||||
remaining?: number
|
||||
limit?: number
|
||||
period?: ProviderUsagePeriod
|
||||
durationMs?: number
|
||||
resetAt?: string
|
||||
state: "active" | "exhausted" | "unlimited" | "not_in_plan" | "unknown"
|
||||
}
|
||||
|
||||
export type ProviderUsageError = {
|
||||
code: string
|
||||
message: string
|
||||
retryable: boolean
|
||||
}
|
||||
|
||||
export type ProviderUsageSnapshot = {
|
||||
id: string
|
||||
providerID: string
|
||||
sourceKind: "kilo_managed" | "direct"
|
||||
providerLabel: string
|
||||
planLabel: string
|
||||
sourceLabel: string
|
||||
fetchState: "ready" | "stale" | "unavailable" | "error"
|
||||
planState: "active" | "past_due" | "canceling" | "unknown"
|
||||
routingState: "active" | "disabled" | "missing" | "replaced" | "not_applicable" | "unknown"
|
||||
fetchedAt?: string
|
||||
managementUrl?: string
|
||||
windows: Array<ProviderUsageWindow>
|
||||
error?: ProviderUsageError
|
||||
}
|
||||
|
||||
export type ProviderUsage = {
|
||||
items: Array<ProviderUsageSnapshot>
|
||||
generatedAt: string
|
||||
}
|
||||
|
||||
export type NotebookOutput = {
|
||||
mime: string
|
||||
text?: string
|
||||
@@ -16611,6 +16657,73 @@ 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 = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
/**
|
||||
* 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 = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
/**
|
||||
* 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 KilocodeNotebookListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
||||
@@ -15226,6 +15226,134 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BadRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BadRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/kilocode/notebook": {
|
||||
"get": {
|
||||
"tags": ["kilocode"],
|
||||
@@ -38656,6 +38784,158 @@
|
||||
"required": ["name", "builtin", "location", "editable", "hints"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ProviderUsagePeriod": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"unit": {
|
||||
"type": "string",
|
||||
"enum": ["hour", "day", "week", "month"]
|
||||
},
|
||||
"value": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": ["unit", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ProviderUsageWindow": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"resource": {
|
||||
"type": "string"
|
||||
},
|
||||
"unit": {
|
||||
"type": "string"
|
||||
},
|
||||
"orientation": {
|
||||
"type": "string",
|
||||
"enum": ["used_percent", "remaining_percent", "amount", "count"]
|
||||
},
|
||||
"used": {
|
||||
"type": "number"
|
||||
},
|
||||
"remaining": {
|
||||
"type": "number"
|
||||
},
|
||||
"limit": {
|
||||
"type": "number"
|
||||
},
|
||||
"period": {
|
||||
"$ref": "#/components/schemas/ProviderUsagePeriod"
|
||||
},
|
||||
"durationMs": {
|
||||
"type": "number"
|
||||
},
|
||||
"resetAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": ["active", "exhausted", "unlimited", "not_in_plan", "unknown"]
|
||||
}
|
||||
},
|
||||
"required": ["id", "resource", "unit", "orientation", "state"],
|
||||
"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_managed", "direct"]
|
||||
},
|
||||
"providerLabel": {
|
||||
"type": "string"
|
||||
},
|
||||
"planLabel": {
|
||||
"type": "string"
|
||||
},
|
||||
"sourceLabel": {
|
||||
"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"]
|
||||
},
|
||||
"fetchedAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"managementUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"windows": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ProviderUsageWindow"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"$ref": "#/components/schemas/ProviderUsageError"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"providerID",
|
||||
"sourceKind",
|
||||
"providerLabel",
|
||||
"planLabel",
|
||||
"sourceLabel",
|
||||
"fetchState",
|
||||
"planState",
|
||||
"routingState",
|
||||
"windows"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ProviderUsage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ProviderUsageSnapshot"
|
||||
}
|
||||
},
|
||||
"generatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["items", "generatedAt"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"NotebookOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Reference in New Issue
Block a user