Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/jetbrains-profile-balance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Show Kilo Pass usage, bonus credits, renewal date, and top-up actions in the JetBrains user profile.
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import ai.kilocode.rpc.dto.ModelStateDto
import ai.kilocode.rpc.dto.ModelVariantUpdateDto
import ai.kilocode.rpc.dto.ProfileBalanceDto
import ai.kilocode.rpc.dto.ProfileDto
import ai.kilocode.rpc.dto.ProfileKiloPassDto
import ai.kilocode.rpc.dto.ProfileOrganizationDto
import ai.kilocode.rpc.dto.ProfileStatusDto
import ai.kilocode.rpc.dto.TelemetryCaptureDto
Expand Down Expand Up @@ -148,6 +149,14 @@ internal fun profileDto(p: KiloProfile200Response): ProfileDto = ProfileDto(
ProfileOrganizationDto(id = org.id, name = org.name, role = org.role)
},
balance = p.balance?.let { ProfileBalanceDto(balance = it.balance) },
kiloPass = p.kiloPass?.let {
ProfileKiloPassDto(
currentPeriodBaseCreditsUsd = it.currentPeriodBaseCreditsUsd,
currentPeriodUsageUsd = it.currentPeriodUsageUsd,
currentPeriodBonusCreditsUsd = it.currentPeriodBonusCreditsUsd,
nextBillingAt = it.nextBillingAt,
)
},
currentOrgId = p.currentOrgId,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ class ApiModelSerializationTest {
val src = """{
"profile": {"email": "user@test.com", "name": "User"},
"balance": {"balance": 42.5},
"kiloPass": null,
"currentOrgId": "org-1"
}"""
val obj = json.decodeFromString<KiloProfile200Response>(src)
Expand All @@ -125,6 +126,7 @@ class ApiModelSerializationTest {
val src = """{
"profile": {"email": "user@test.com"},
"balance": null,
"kiloPass": null,
"currentOrgId": null
}"""
val obj = json.decodeFromString<KiloProfile200Response>(src)
Expand All @@ -144,6 +146,7 @@ class ApiModelSerializationTest {
]
},
"balance": null,
"kiloPass": null,
"currentOrgId": "org-1"
}"""
val obj = json.decodeFromString<KiloProfile200Response>(src)
Expand All @@ -153,6 +156,27 @@ class ApiModelSerializationTest {
assertEquals("admin", obj.profile.organizations!![0].role)
}

@Test
fun `KiloProfile200Response with kilo pass`() {
val src = """{
"profile": {"email": "user@test.com"},
"balance": {"balance": 267.59},
"kiloPass": {
"currentPeriodBaseCreditsUsd": 199,
"currentPeriodUsageUsd": 73.27,
"currentPeriodBonusCreditsUsd": 99.5,
"nextBillingAt": "2026-07-01T00:00:00.000Z"
},
"currentOrgId": null
}"""
val obj = json.decodeFromString<KiloProfile200Response>(src)
assertNotNull(obj.kiloPass)
assertEquals(199.0, obj.kiloPass!!.currentPeriodBaseCreditsUsd)
assertEquals(73.27, obj.kiloPass!!.currentPeriodUsageUsd)
assertEquals(99.5, obj.kiloPass!!.currentPeriodBonusCreditsUsd)
assertEquals("2026-07-01T00:00:00.000Z", obj.kiloPass!!.nextBillingAt)
}

@Test
fun `Config roundtrip preserves model field`() {
val original = Config(model = "gpt-4o", username = "test")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ internal object OpenApiSpecNormalizer {

/**
* Fix the `/kilo/profile` GET 200 response schema: Effect's OpenAPI generator
* emits `balance` and `currentOrgId` as non-nullable required fields even
* emits `balance`, `kiloPass`, and `currentOrgId` as non-nullable required fields even
* though the server schema is `Schema.NullOr(...)`. Wrap each non-nullable
* property in `anyOf: [<original-schema>, {"type": "null"}]` so the generated
* Kotlin model uses a nullable type. Already-nullable properties (those that
Expand All @@ -124,7 +124,7 @@ internal object OpenApiSpecNormalizer {
as? JsonObject ?: return root
val props = schema["properties"] as? JsonObject ?: return root

val nullable = setOf("balance", "currentOrgId")
val nullable = setOf("balance", "kiloPass", "currentOrgId")
val fixed = JsonObject(props.mapValues { (key, value) ->
if (key !in nullable) return@mapValues value
val obj = value as? JsonObject ?: return@mapValues value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ class OpenApiSpecNormalizerTest {
}

@Test
fun `makes balance and currentOrgId nullable in kilo profile response`() {
fun `makes nullable profile fields nullable in kilo profile response`() {
val raw = """
{
"paths": {
Expand All @@ -121,9 +121,10 @@ class OpenApiSpecNormalizerTest {
"properties": {
"profile": { "type": "object", "properties": { "email": { "type": "string" } }, "required": ["email"], "additionalProperties": false },
"balance": { "type": "object", "properties": { "balance": { "type": "number" } }, "required": ["balance"], "additionalProperties": false },
"kiloPass": { "type": "object", "properties": { "currentPeriodBaseCreditsUsd": { "type": "number" } }, "required": ["currentPeriodBaseCreditsUsd"], "additionalProperties": false },
"currentOrgId": { "type": "string" }
},
"required": ["profile", "balance", "currentOrgId"],
"required": ["profile", "balance", "kiloPass", "currentOrgId"],
"additionalProperties": false
}
}
Expand All @@ -148,6 +149,14 @@ class OpenApiSpecNormalizerTest {
assert("null" in balanceTypes) { "balance anyOf should include null but got $balanceTypes" }
assert(balanceAnyOf.any { it is JsonObject && "properties" in it }) { "balance anyOf should include the object schema" }

// kiloPass must be anyOf [object, null]
val pass = obj(props["kiloPass"])
val passAnyOf = arr(pass["anyOf"])
assertEquals(2, passAnyOf.size, "kiloPass should have anyOf with 2 entries")
val passTypes = passAnyOf.map { (it as? JsonObject)?.get("type").let { t -> (t as? JsonPrimitive)?.content } }
assert("null" in passTypes) { "kiloPass anyOf should include null but got $passTypes" }
assert(passAnyOf.any { it is JsonObject && "properties" in it }) { "kiloPass anyOf should include the object schema" }

// currentOrgId must be anyOf [string, null]
val orgId = obj(props["currentOrgId"])
val orgIdAnyOf = arr(orgId["anyOf"])
Expand All @@ -164,7 +173,7 @@ class OpenApiSpecNormalizerTest {

@Test
fun `leaves already-nullable fields unchanged in kilo profile response`() {
// If balance already has anyOf (i.e. the spec was generated correctly), normalizer must not double-wrap it.
// If nullable fields already have anyOf (i.e. the spec was generated correctly), normalizer must not double-wrap them.
val raw = """
{
"paths": {
Expand All @@ -180,9 +189,10 @@ class OpenApiSpecNormalizerTest {
"properties": {
"profile": { "type": "object", "properties": { "email": { "type": "string" } }, "required": ["email"], "additionalProperties": false },
"balance": { "anyOf": [{ "type": "object", "properties": { "balance": { "type": "number" } }, "required": ["balance"], "additionalProperties": false }, { "type": "null" }] },
"kiloPass": { "anyOf": [{ "type": "object", "properties": { "currentPeriodBaseCreditsUsd": { "type": "number" } }, "required": ["currentPeriodBaseCreditsUsd"], "additionalProperties": false }, { "type": "null" }] },
"currentOrgId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }
},
"required": ["profile", "balance", "currentOrgId"],
"required": ["profile", "balance", "kiloPass", "currentOrgId"],
"additionalProperties": false
}
}
Expand All @@ -203,6 +213,9 @@ class OpenApiSpecNormalizerTest {
val balance = obj(props["balance"])
val balanceAnyOf = arr(balance["anyOf"])
assertEquals(2, balanceAnyOf.size, "balance should still have exactly 2 anyOf entries, not be double-wrapped")
val pass = obj(props["kiloPass"])
val passAnyOf = arr(pass["anyOf"])
assertEquals(2, passAnyOf.size, "kiloPass should still have exactly 2 anyOf entries, not be double-wrapped")
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
package ai.kilocode.client.settings.profile

import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale

private val FMT = DecimalFormat("\$#,##0.00")
private val SYMBOLS = DecimalFormatSymbols(Locale.US)
private val FMT = DecimalFormat("\$#,##0.00", SYMBOLS)
private val SHORT = DecimalFormat("\$#,##0", SYMBOLS).apply { roundingMode = RoundingMode.HALF_UP }
private val DATE = DateTimeFormatter.ofPattern("MMM d", Locale.US).withZone(ZoneOffset.UTC)

/** Format a USD balance value for display (e.g. `$1,234.56`). */
internal fun formatBalance(value: Double): String = FMT.format(value)
Comment thread
johnnyeric marked this conversation as resolved.

internal fun formatShortBalance(value: Double): String = SHORT.format(value)

internal fun formatResetDate(iso: String?): String? {
if (iso.isNullOrBlank()) return null
return runCatching { DATE.format(Instant.parse(iso)) }.getOrNull()
}
Loading
Loading