Skip to content

Commit 29e2b8a

Browse files
committed
feat(jetbrains): show profile balance details
1 parent 06279c9 commit 29e2b8a

12 files changed

Lines changed: 385 additions & 17 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@kilocode/kilo-jetbrains": patch
3+
---
4+
5+
Show Kilo Pass usage, bonus credits, renewal date, and top-up actions in the JetBrains user profile.

packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import ai.kilocode.rpc.dto.ModelStateDto
3030
import ai.kilocode.rpc.dto.ModelVariantUpdateDto
3131
import ai.kilocode.rpc.dto.ProfileBalanceDto
3232
import ai.kilocode.rpc.dto.ProfileDto
33+
import ai.kilocode.rpc.dto.ProfileKiloPassDto
3334
import ai.kilocode.rpc.dto.ProfileOrganizationDto
3435
import ai.kilocode.rpc.dto.ProfileStatusDto
3536
import ai.kilocode.rpc.dto.TelemetryCaptureDto
@@ -148,6 +149,14 @@ internal fun profileDto(p: KiloProfile200Response): ProfileDto = ProfileDto(
148149
ProfileOrganizationDto(id = org.id, name = org.name, role = org.role)
149150
},
150151
balance = p.balance?.let { ProfileBalanceDto(balance = it.balance) },
152+
kiloPass = p.kiloPass?.let {
153+
ProfileKiloPassDto(
154+
currentPeriodBaseCreditsUsd = it.currentPeriodBaseCreditsUsd,
155+
currentPeriodUsageUsd = it.currentPeriodUsageUsd,
156+
currentPeriodBonusCreditsUsd = it.currentPeriodBonusCreditsUsd,
157+
nextBillingAt = it.nextBillingAt,
158+
)
159+
},
151160
currentOrgId = p.currentOrgId,
152161
)
153162

packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ApiModelSerializationTest.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ class ApiModelSerializationTest {
110110
val src = """{
111111
"profile": {"email": "user@test.com", "name": "User"},
112112
"balance": {"balance": 42.5},
113+
"kiloPass": null,
113114
"currentOrgId": "org-1"
114115
}"""
115116
val obj = json.decodeFromString<KiloProfile200Response>(src)
@@ -125,6 +126,7 @@ class ApiModelSerializationTest {
125126
val src = """{
126127
"profile": {"email": "user@test.com"},
127128
"balance": null,
129+
"kiloPass": null,
128130
"currentOrgId": null
129131
}"""
130132
val obj = json.decodeFromString<KiloProfile200Response>(src)
@@ -144,6 +146,7 @@ class ApiModelSerializationTest {
144146
]
145147
},
146148
"balance": null,
149+
"kiloPass": null,
147150
"currentOrgId": "org-1"
148151
}"""
149152
val obj = json.decodeFromString<KiloProfile200Response>(src)
@@ -153,6 +156,27 @@ class ApiModelSerializationTest {
153156
assertEquals("admin", obj.profile.organizations!![0].role)
154157
}
155158

159+
@Test
160+
fun `KiloProfile200Response with kilo pass`() {
161+
val src = """{
162+
"profile": {"email": "user@test.com"},
163+
"balance": {"balance": 267.59},
164+
"kiloPass": {
165+
"currentPeriodBaseCreditsUsd": 199,
166+
"currentPeriodUsageUsd": 73.27,
167+
"currentPeriodBonusCreditsUsd": 99.5,
168+
"nextBillingAt": "2026-07-01T00:00:00.000Z"
169+
},
170+
"currentOrgId": null
171+
}"""
172+
val obj = json.decodeFromString<KiloProfile200Response>(src)
173+
assertNotNull(obj.kiloPass)
174+
assertEquals(199.0, obj.kiloPass!!.currentPeriodBaseCreditsUsd)
175+
assertEquals(73.27, obj.kiloPass!!.currentPeriodUsageUsd)
176+
assertEquals(99.5, obj.kiloPass!!.currentPeriodBonusCreditsUsd)
177+
assertEquals("2026-07-01T00:00:00.000Z", obj.kiloPass!!.nextBillingAt)
178+
}
179+
156180
@Test
157181
fun `Config roundtrip preserves model field`() {
158182
val original = Config(model = "gpt-4o", username = "test")

packages/kilo-jetbrains/build-tasks/src/main/kotlin/normalization/OpenApiSpecNormalizer.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ internal object OpenApiSpecNormalizer {
106106

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

127-
val nullable = setOf("balance", "currentOrgId")
127+
val nullable = setOf("balance", "kiloPass", "currentOrgId")
128128
val fixed = JsonObject(props.mapValues { (key, value) ->
129129
if (key !in nullable) return@mapValues value
130130
val obj = value as? JsonObject ?: return@mapValues value

packages/kilo-jetbrains/build-tasks/src/test/kotlin/normalization/OpenApiSpecNormalizerTest.kt

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ class OpenApiSpecNormalizerTest {
105105
}
106106

107107
@Test
108-
fun `makes balance and currentOrgId nullable in kilo profile response`() {
108+
fun `makes nullable profile fields nullable in kilo profile response`() {
109109
val raw = """
110110
{
111111
"paths": {
@@ -121,9 +121,10 @@ class OpenApiSpecNormalizerTest {
121121
"properties": {
122122
"profile": { "type": "object", "properties": { "email": { "type": "string" } }, "required": ["email"], "additionalProperties": false },
123123
"balance": { "type": "object", "properties": { "balance": { "type": "number" } }, "required": ["balance"], "additionalProperties": false },
124+
"kiloPass": { "type": "object", "properties": { "currentPeriodBaseCreditsUsd": { "type": "number" } }, "required": ["currentPeriodBaseCreditsUsd"], "additionalProperties": false },
124125
"currentOrgId": { "type": "string" }
125126
},
126-
"required": ["profile", "balance", "currentOrgId"],
127+
"required": ["profile", "balance", "kiloPass", "currentOrgId"],
127128
"additionalProperties": false
128129
}
129130
}
@@ -148,6 +149,14 @@ class OpenApiSpecNormalizerTest {
148149
assert("null" in balanceTypes) { "balance anyOf should include null but got $balanceTypes" }
149150
assert(balanceAnyOf.any { it is JsonObject && "properties" in it }) { "balance anyOf should include the object schema" }
150151

152+
// kiloPass must be anyOf [object, null]
153+
val pass = obj(props["kiloPass"])
154+
val passAnyOf = arr(pass["anyOf"])
155+
assertEquals(2, passAnyOf.size, "kiloPass should have anyOf with 2 entries")
156+
val passTypes = passAnyOf.map { (it as? JsonObject)?.get("type").let { t -> (t as? JsonPrimitive)?.content } }
157+
assert("null" in passTypes) { "kiloPass anyOf should include null but got $passTypes" }
158+
assert(passAnyOf.any { it is JsonObject && "properties" in it }) { "kiloPass anyOf should include the object schema" }
159+
151160
// currentOrgId must be anyOf [string, null]
152161
val orgId = obj(props["currentOrgId"])
153162
val orgIdAnyOf = arr(orgId["anyOf"])
@@ -164,7 +173,7 @@ class OpenApiSpecNormalizerTest {
164173

165174
@Test
166175
fun `leaves already-nullable fields unchanged in kilo profile response`() {
167-
// If balance already has anyOf (i.e. the spec was generated correctly), normalizer must not double-wrap it.
176+
// If nullable fields already have anyOf (i.e. the spec was generated correctly), normalizer must not double-wrap them.
168177
val raw = """
169178
{
170179
"paths": {
@@ -180,9 +189,10 @@ class OpenApiSpecNormalizerTest {
180189
"properties": {
181190
"profile": { "type": "object", "properties": { "email": { "type": "string" } }, "required": ["email"], "additionalProperties": false },
182191
"balance": { "anyOf": [{ "type": "object", "properties": { "balance": { "type": "number" } }, "required": ["balance"], "additionalProperties": false }, { "type": "null" }] },
192+
"kiloPass": { "anyOf": [{ "type": "object", "properties": { "currentPeriodBaseCreditsUsd": { "type": "number" } }, "required": ["currentPeriodBaseCreditsUsd"], "additionalProperties": false }, { "type": "null" }] },
183193
"currentOrgId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }
184194
},
185-
"required": ["profile", "balance", "currentOrgId"],
195+
"required": ["profile", "balance", "kiloPass", "currentOrgId"],
186196
"additionalProperties": false
187197
}
188198
}
@@ -203,6 +213,9 @@ class OpenApiSpecNormalizerTest {
203213
val balance = obj(props["balance"])
204214
val balanceAnyOf = arr(balance["anyOf"])
205215
assertEquals(2, balanceAnyOf.size, "balance should still have exactly 2 anyOf entries, not be double-wrapped")
216+
val pass = obj(props["kiloPass"])
217+
val passAnyOf = arr(pass["anyOf"])
218+
assertEquals(2, passAnyOf.size, "kiloPass should still have exactly 2 anyOf entries, not be double-wrapped")
206219
}
207220

208221
@Test
Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
11
package ai.kilocode.client.settings.profile
22

33
import java.text.DecimalFormat
4+
import java.text.DecimalFormatSymbols
5+
import java.time.Instant
6+
import java.time.ZoneOffset
7+
import java.time.format.DateTimeFormatter
8+
import java.util.Locale
49

5-
private val FMT = DecimalFormat("\$#,##0.00")
10+
private val SYMBOLS = DecimalFormatSymbols(Locale.US)
11+
private val FMT = DecimalFormat("\$#,##0.00", SYMBOLS)
12+
private val SHORT = DecimalFormat("\$#,##0", SYMBOLS)
13+
private val DATE = DateTimeFormatter.ofPattern("MMM d", Locale.US).withZone(ZoneOffset.UTC)
614

715
/** Format a USD balance value for display (e.g. `$1,234.56`). */
816
internal fun formatBalance(value: Double): String = FMT.format(value)
17+
18+
internal fun formatShortBalance(value: Double): String = SHORT.format(value)
19+
20+
internal fun formatResetDate(iso: String?): String? {
21+
if (iso.isNullOrBlank()) return null
22+
return runCatching { DATE.format(Instant.parse(iso)) }.getOrNull()
23+
}

0 commit comments

Comments
 (0)