mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
feat(gateway): expose kilo pass profile contract (#11721)
* feat(gateway): expose kilo pass profile contract * chore(changeset): include sdk contract bump
This commit is contained in:
committed by
GitHub
parent
e0c1e729d3
commit
be1f77d432
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"@kilocode/kilo-gateway": patch
|
||||
"@kilocode/sdk": patch
|
||||
---
|
||||
|
||||
Expose Kilo Pass state on the Kilo profile API contract.
|
||||
@@ -0,0 +1,44 @@
|
||||
import { buildKiloHeaders } from "../headers.js"
|
||||
import type { KiloPassState } from "../types.js"
|
||||
import { KILO_API_BASE } from "./constants.js"
|
||||
|
||||
function record(value: unknown) {
|
||||
return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
function num(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
const next = sub.nextBillingAt ?? sub.nextRenewalAt
|
||||
return {
|
||||
currentPeriodBaseCreditsUsd: num(sub.currentPeriodBaseCreditsUsd),
|
||||
currentPeriodUsageUsd: num(sub.currentPeriodUsageUsd),
|
||||
currentPeriodBonusCreditsUsd: num(sub.currentPeriodBonusCreditsUsd),
|
||||
nextBillingAt: typeof next === "string" ? next : null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchKiloPassState(token: string): Promise<KiloPassState | null> {
|
||||
try {
|
||||
const params = new URLSearchParams({ batch: "1", input: JSON.stringify({ "0": null }) })
|
||||
const response = await fetch(`${KILO_API_BASE}/api/trpc/kiloPass.getState?${params}`, {
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...buildKiloHeaders() },
|
||||
})
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to fetch Kilo Pass: ${response.status}`)
|
||||
return null
|
||||
}
|
||||
return parseKiloPassState(await response.json())
|
||||
} catch (err) {
|
||||
console.warn("Error fetching Kilo Pass:", err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export {
|
||||
getKiloDefaultModel,
|
||||
promptOrganizationSelection,
|
||||
} from "./api/profile.js"
|
||||
export { fetchKiloPassState } from "./api/kilo-pass.js"
|
||||
export { fetchKiloModels, type KiloModelsResult } from "./api/models.js"
|
||||
export {
|
||||
EMPTY_KILO_EMBEDDING_MODEL_CATALOG,
|
||||
@@ -94,6 +95,7 @@ export type {
|
||||
Organization,
|
||||
KilocodeProfile,
|
||||
KilocodeBalance,
|
||||
KiloPassState,
|
||||
PollOptions,
|
||||
PollResult,
|
||||
// Provider types
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { fetchBalance, fetchProfile } from "../api/profile.js"
|
||||
import { fetchKiloPassState } from "../api/kilo-pass.js"
|
||||
import { fetchKilocodeNotifications } from "../api/notifications.js"
|
||||
import { clearModesCache } from "../api/modes.js"
|
||||
import { HEADER_ORGANIZATIONID, KILO_API_BASE, KILO_CHAT_URL, KILO_EVENT_SERVICE_URL } from "../api/constants.js"
|
||||
import type { KilocodeBalance, KilocodeProfile } from "../types.js"
|
||||
import type { KilocodeBalance, KilocodeProfile, KiloPassState } from "../types.js"
|
||||
import { buildKiloHeaders } from "../headers.js"
|
||||
|
||||
export type KiloAuth =
|
||||
@@ -13,6 +14,7 @@ export type KiloAuth =
|
||||
export interface KiloProfileResult {
|
||||
profile: KilocodeProfile
|
||||
balance: KilocodeBalance | null
|
||||
kiloPass: KiloPassState | null
|
||||
currentOrgId: string | null
|
||||
}
|
||||
|
||||
@@ -67,11 +69,12 @@ export async function getProfile(auth: AuthStore): Promise<KiloProfileResult> {
|
||||
if (!info || info.type !== "oauth") throw new UnauthorizedError("Not authenticated with Kilo Gateway")
|
||||
|
||||
const currentOrgId = info.accountId ?? null
|
||||
const [profile, balance] = await Promise.all([
|
||||
const [profile, balance, kiloPass] = await Promise.all([
|
||||
fetchProfile(info.access),
|
||||
fetchBalance(info.access, currentOrgId ?? undefined),
|
||||
fetchKiloPassState(info.access),
|
||||
])
|
||||
return { profile, balance, currentOrgId }
|
||||
return { profile, balance, kiloPass, currentOrgId }
|
||||
}
|
||||
|
||||
export async function getNotifications(auth: AuthStore) {
|
||||
|
||||
@@ -107,9 +107,17 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
|
||||
balance: z.number(),
|
||||
})
|
||||
|
||||
const KiloPassState = z.object({
|
||||
currentPeriodBaseCreditsUsd: z.number(),
|
||||
currentPeriodUsageUsd: z.number(),
|
||||
currentPeriodBonusCreditsUsd: z.number(),
|
||||
nextBillingAt: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
const ProfileWithBalance = z.object({
|
||||
profile: Profile,
|
||||
balance: Balance.nullable(),
|
||||
kiloPass: KiloPassState.nullable(),
|
||||
currentOrgId: z.string().nullable(),
|
||||
})
|
||||
|
||||
|
||||
@@ -33,6 +33,13 @@ export interface KilocodeBalance {
|
||||
balance: number
|
||||
}
|
||||
|
||||
export interface KiloPassState {
|
||||
currentPeriodBaseCreditsUsd: number
|
||||
currentPeriodUsageUsd: number
|
||||
currentPeriodBonusCreditsUsd: number
|
||||
nextBillingAt?: string | null
|
||||
}
|
||||
|
||||
export interface PollOptions<T> {
|
||||
interval: number
|
||||
maxAttempts: number
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parseKiloPassState } from "../../src/api/kilo-pass"
|
||||
|
||||
describe("parseKiloPassState", () => {
|
||||
test("parses batched tRPC subscription data", () => {
|
||||
const state = parseKiloPassState([
|
||||
{
|
||||
result: {
|
||||
data: {
|
||||
json: {
|
||||
subscription: {
|
||||
tier: "tier_199",
|
||||
currentPeriodBaseCreditsUsd: 199,
|
||||
currentPeriodUsageUsd: 73.27,
|
||||
currentPeriodBonusCreditsUsd: 99.5,
|
||||
nextBillingAt: "2026-07-01T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
expect(state).toEqual({
|
||||
currentPeriodBaseCreditsUsd: 199,
|
||||
currentPeriodUsageUsd: 73.27,
|
||||
currentPeriodBonusCreditsUsd: 99.5,
|
||||
nextBillingAt: "2026-07-01T00:00:00.000Z",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses plain subscription payload", () => {
|
||||
const state = parseKiloPassState([
|
||||
{
|
||||
result: {
|
||||
data: {
|
||||
subscription: {
|
||||
tier: "tier_199",
|
||||
status: "active",
|
||||
currentPeriodBaseCreditsUsd: 199,
|
||||
currentPeriodUsageUsd: 0.01,
|
||||
currentPeriodBonusCreditsUsd: 29.85,
|
||||
isBonusUnlocked: false,
|
||||
nextBillingAt: "2026-07-20T09:30:20.806Z",
|
||||
},
|
||||
isEligibleForFirstMonthPromo: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
expect(state).toEqual({
|
||||
currentPeriodBaseCreditsUsd: 199,
|
||||
currentPeriodUsageUsd: 0.01,
|
||||
currentPeriodBonusCreditsUsd: 29.85,
|
||||
nextBillingAt: "2026-07-20T09:30:20.806Z",
|
||||
})
|
||||
})
|
||||
|
||||
test("returns null without period amounts", () => {
|
||||
expect(parseKiloPassState({ status: "none" })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -27,9 +27,17 @@ export const Balance = Schema.Struct({
|
||||
balance: Schema.Finite,
|
||||
})
|
||||
|
||||
export const KiloPassState = Schema.Struct({
|
||||
currentPeriodBaseCreditsUsd: Schema.Finite,
|
||||
currentPeriodUsageUsd: Schema.Finite,
|
||||
currentPeriodBonusCreditsUsd: Schema.Finite,
|
||||
nextBillingAt: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
})
|
||||
|
||||
export const ProfileWithBalance = Schema.Struct({
|
||||
profile: Profile,
|
||||
balance: Schema.NullOr(Balance),
|
||||
kiloPass: Schema.NullOr(KiloPassState),
|
||||
currentOrgId: Schema.NullOr(Schema.String),
|
||||
})
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
clearModesCache,
|
||||
fetchBalance,
|
||||
fetchKilocodeNotifications,
|
||||
fetchKiloPassState,
|
||||
fetchOrganizationModes,
|
||||
fetchProfile,
|
||||
} from "@kilocode/kilo-gateway"
|
||||
@@ -66,11 +67,16 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo",
|
||||
if (!info || info.type !== "oauth") return yield* Effect.fail(new HttpApiError.Unauthorized({}))
|
||||
|
||||
const currentOrgId = info.accountId ?? null
|
||||
const [profile, balance] = yield* Effect.tryPromise({
|
||||
try: () => Promise.all([fetchProfile(info.access), fetchBalance(info.access, currentOrgId ?? undefined)]),
|
||||
const [profile, balance, kiloPass] = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
Promise.all([
|
||||
fetchProfile(info.access),
|
||||
fetchBalance(info.access, currentOrgId ?? undefined),
|
||||
fetchKiloPassState(info.access),
|
||||
]),
|
||||
catch: () => new HttpApiError.BadRequest({}),
|
||||
})
|
||||
return { profile, balance, currentOrgId }
|
||||
return { profile, balance, kiloPass, currentOrgId }
|
||||
})
|
||||
|
||||
const authStatus = Effect.fn("KiloGatewayHttpApi.authStatus")(function* () {
|
||||
|
||||
@@ -52,6 +52,7 @@ export function matchLegacyKiloOpenApi(input: Record<string, unknown>) {
|
||||
const json = (path: string) => spec.paths?.[path]?.get?.responses?.["200"]?.content?.["application/json"]
|
||||
const profile = json("/kilo/profile")?.schema?.properties
|
||||
if (profile?.balance) profile.balance = nullable(profile.balance)
|
||||
if (profile?.kiloPass) profile.kiloPass = nullable(profile.kiloPass)
|
||||
if (profile?.currentOrgId) profile.currentOrgId = nullable(profile.currentOrgId)
|
||||
|
||||
const sessions = json("/kilo/cloud-sessions")?.schema?.properties
|
||||
|
||||
@@ -164,6 +164,7 @@ describe("Kilo PublicApi OpenAPI contract", () => {
|
||||
|
||||
const profile = response(KiloGatewayPaths.profile)?.properties
|
||||
expect(profile?.balance).toEqual({ anyOf: [expect.objectContaining({ type: "object" }), { type: "null" }] })
|
||||
expect(profile?.kiloPass).toEqual({ anyOf: [expect.objectContaining({ type: "object" }), { type: "null" }] })
|
||||
expect(profile?.currentOrgId).toEqual({ anyOf: [{ type: "string" }, { type: "null" }] })
|
||||
|
||||
const auth = response(KiloGatewayPaths.authStatus)?.properties
|
||||
|
||||
@@ -10114,6 +10114,12 @@ export type KiloProfileResponses = {
|
||||
balance: {
|
||||
balance: number
|
||||
} | null
|
||||
kiloPass: {
|
||||
currentPeriodBaseCreditsUsd: number
|
||||
currentPeriodUsageUsd: number
|
||||
currentPeriodBonusCreditsUsd: number
|
||||
nextBillingAt?: string | null
|
||||
} | null
|
||||
currentOrgId: string | null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13064,6 +13064,43 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"kiloPass": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"currentPeriodBaseCreditsUsd": {
|
||||
"type": "number"
|
||||
},
|
||||
"currentPeriodUsageUsd": {
|
||||
"type": "number"
|
||||
},
|
||||
"currentPeriodBonusCreditsUsd": {
|
||||
"type": "number"
|
||||
},
|
||||
"nextBillingAt": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"currentPeriodBaseCreditsUsd",
|
||||
"currentPeriodUsageUsd",
|
||||
"currentPeriodBonusCreditsUsd"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"currentOrgId": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -13075,7 +13112,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["profile", "balance", "currentOrgId"],
|
||||
"required": ["profile", "balance", "kiloPass", "currentOrgId"],
|
||||
"additionalProperties": false,
|
||||
"description": "Profile data"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user