Show product usage types in credit ledger

Persist hotword request origin so every reservation lifecycle entry can be classified without mutating the immutable ledger.
This commit is contained in:
Rocky
2026-08-19 19:47:12 +08:00
parent e1fd35b1ff
commit 3edc86a9a0
22 changed files with 205 additions and 16 deletions
@@ -706,6 +706,7 @@ private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse =
amount = amountDelta,
balanceAfter = balanceAfter,
reasonCode = type,
usageType = usageType,
createdAt = createdAt,
)
@@ -897,6 +898,7 @@ private data class AdminLedgerResponse(
val amount: Long,
val balanceAfter: Long,
val reasonCode: String,
val usageType: String?,
val createdAt: String,
)
@@ -32,6 +32,7 @@ data class AdminUserLedgerEntryDto(
val balanceAfter: Long,
val referenceId: String?,
val createdAt: String,
val usageType: String? = null,
)
@Serializable
@@ -135,7 +135,7 @@ class ExposedAdminUsersRepository(
.thenByDescending { it.id.toString() },
)
.take(ledgerLimit)
.map(UserLedgerRow::toDto)
.map { it.toDto(support.ledgerUsageTypes[it.referenceId]) }
AdminUserDetailDto(
summary = account.toSummary(support),
referralCode = findReferralCode(userId),
@@ -171,12 +171,14 @@ class ExposedAdminUsersRepository(
)
}
}
query.orderBy(
val ledger = query.orderBy(
AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC,
AdminUsersCreditLedgerTable.id to SortOrder.DESC,
)
.limit(limit)
.map { it.toUserLedgerRow().toDto() }
.map(ResultRow::toUserLedgerRow)
val usageTypes = loadLedgerUsageTypes(ledger)
ledger.map { it.toDto(usageTypes[it.referenceId]) }
}
override suspend fun listLatestLedger(
@@ -193,18 +195,21 @@ class ExposedAdminUsersRepository(
)
}
}
query.orderBy(
val ledger = query.orderBy(
AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC,
AdminUsersCreditLedgerTable.id to SortOrder.DESC,
)
.limit(limit)
.map { it.toUserLedgerRow().toDto() }
.map(ResultRow::toUserLedgerRow)
val usageTypes = loadLedgerUsageTypes(ledger)
ledger.map { it.toDto(usageTypes[it.referenceId]) }
}
}
private data class UserSupportRows(
val balances: Map<UUID, Long>,
val ledger: List<UserLedgerRow>,
val ledgerUsageTypes: Map<UUID, String>,
val usage: List<UserUsageRow>,
val bindings: List<UserReferralBindingRow>,
)
@@ -236,16 +241,20 @@ private data class UserReferralBindingRow(
)
private fun loadSupport(userIds: Set<UUID>): UserSupportRows {
if (userIds.isEmpty()) return UserSupportRows(emptyMap(), emptyList(), emptyList(), emptyList())
if (userIds.isEmpty()) {
return UserSupportRows(emptyMap(), emptyList(), emptyMap(), emptyList(), emptyList())
}
val ids = userIds.map(UUID::toString)
val ledger = AdminUsersCreditLedgerTable.selectAll()
.where { AdminUsersCreditLedgerTable.userId inList ids }
.map(ResultRow::toUserLedgerRow)
return UserSupportRows(
balances = AdminUsersCreditAccountsTable.selectAll()
.where { AdminUsersCreditAccountsTable.userId inList ids }
.map { UUID.fromString(it[AdminUsersCreditAccountsTable.userId]) to it[AdminUsersCreditAccountsTable.balance] }
.toMap(),
ledger = AdminUsersCreditLedgerTable.selectAll()
.where { AdminUsersCreditLedgerTable.userId inList ids }
.map(ResultRow::toUserLedgerRow),
ledger = ledger,
ledgerUsageTypes = loadLedgerUsageTypes(ledger),
usage = AdminUsersCreditUsageTable.selectAll()
.where { AdminUsersCreditUsageTable.userId inList ids }
.map(ResultRow::toUserUsageRow),
@@ -258,6 +267,23 @@ private fun loadSupport(userIds: Set<UUID>): UserSupportRows {
)
}
private fun loadLedgerUsageTypes(ledger: List<UserLedgerRow>): Map<UUID, String> {
val reservationIds = ledger.mapNotNull(UserLedgerRow::referenceId).distinct()
if (reservationIds.isEmpty()) return emptyMap()
return AdminUsersProviderRequestsTable.selectAll()
.where {
AdminUsersProviderRequestsTable.reservationId inList
reservationIds.map(UUID::toString)
}
.associate { row ->
UUID.fromString(requireNotNull(row[AdminUsersProviderRequestsTable.reservationId])) to
(
row[AdminUsersProviderRequestsTable.requestSource]
?: row[AdminUsersProviderRequestsTable.capability]
).lowercase()
}
}
private fun findReferralCode(userId: UUID): String? =
AdminUsersReferralCodesTable.selectAll()
.where { AdminUsersReferralCodesTable.ownerUserId eq userId.toString() }
@@ -314,6 +340,15 @@ private object AdminUsersCreditLedgerTable : Table("credit_ledger") {
val createdAt = timestamp("created_at")
}
private object AdminUsersProviderRequestsTable : Table("provider_requests") {
val accountId = varchar("account_id", 36)
val requestId = varchar("request_id", 64)
val reservationId = varchar("reservation_id", 36).nullable()
val capability = varchar("capability", 32)
val requestSource = varchar("request_source", 32).nullable()
override val primaryKey = PrimaryKey(accountId, requestId)
}
private object AdminUsersCreditUsageTable : Table("credit_usage_records") {
val userId = varchar("user_id", 36)
val usageKind = enumerationByName<UsageKind>("usage_kind", 8)
@@ -349,13 +384,14 @@ private fun ResultRow.toUserLedgerRow() = UserLedgerRow(
createdAt = this[AdminUsersCreditLedgerTable.createdAt],
)
private fun UserLedgerRow.toDto() = AdminUserLedgerEntryDto(
private fun UserLedgerRow.toDto(usageType: String?) = AdminUserLedgerEntryDto(
id = id.toString(),
userId = userId.toString(),
type = type.name,
amountDelta = amountDelta,
balanceAfter = balanceAfter,
referenceId = referenceId?.toString(),
usageType = usageType,
createdAt = createdAt.toString(),
)
@@ -19,6 +19,12 @@ enum class GatewayCapability {
ASR,
}
@Serializable
enum class GatewayRequestSource {
@SerialName("hotword")
HOTWORD,
}
@Serializable
enum class UsageMeter {
@SerialName("llm_token")
@@ -53,6 +59,7 @@ data class TextGatewayRequest(
val maxOutputTokens: Int = 512,
val temperature: Double = 0.2,
val stream: Boolean = false,
val requestSource: GatewayRequestSource? = null,
)
@Serializable
@@ -69,6 +76,8 @@ data class AsrGatewayOptions(
sealed interface ProviderRequest {
val requestId: String
val capability: GatewayCapability
val requestSource: GatewayRequestSource?
get() = null
}
data class TextProviderRequest(
@@ -79,6 +88,7 @@ data class TextProviderRequest(
val maxOutputTokens: Int,
val temperature: Double,
val stream: Boolean,
override val requestSource: GatewayRequestSource? = null,
) : ProviderRequest
data class AsrProviderRequest(
@@ -135,6 +145,9 @@ object TextRequestPolicy {
require(capability != GatewayCapability.AGENT || !request.stream) {
"agent requests must be non-streaming so the structured result can be validated"
}
require(request.requestSource == null || capability == GatewayCapability.AI) {
"requestSource is only supported for AI requests"
}
}
}
@@ -3,6 +3,7 @@ package com.osglab.account.features.gateway.ports
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayGrant
import com.osglab.account.features.gateway.models.GatewayPrincipal
import com.osglab.account.features.gateway.models.GatewayRequestSource
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.UsageMeter
import io.ktor.server.application.ApplicationCall
@@ -139,6 +140,7 @@ data class ProviderRequestMetadata(
val reservationId: String,
val providerId: String,
val capability: GatewayCapability,
val requestSource: GatewayRequestSource?,
)
data class ProviderRefund(
@@ -33,6 +33,7 @@ private object ProviderRequestsTable : Table("provider_requests") {
val reservationId = varchar("reservation_id", 36).nullable()
val providerId = varchar("provider_id", 64)
val capability = varchar("capability", 32)
val requestSource = varchar("request_source", 32).nullable()
val status = varchar("status", 24)
val providerRequestId = varchar("provider_request_id", 128).nullable()
val usageMeter = varchar("usage_meter", 32).nullable()
@@ -279,6 +280,7 @@ class ExposedGatewayRepository(
it[reservationId] = metadata.reservationId
it[providerId] = metadata.providerId
it[capability] = metadata.capability.name
it[requestSource] = metadata.requestSource?.name
it[status] = ProviderRequestState.CLAIMED.name
it[createdAt] = clock.instant()
}.insertedCount == 1
@@ -264,6 +264,7 @@ fun Route.configureGatewayRoutes(
maxOutputTokens = body.maxOutputTokens,
temperature = body.temperature,
stream = body.stream,
requestSource = body.requestSource,
)
if (body.stream) {
@@ -74,6 +74,7 @@ class GatewayService(
reservationId = reservation.id,
providerId = provider.descriptor.id,
capability = request.capability,
requestSource = request.requestSource,
),
)
} catch (replay: GatewayRequestAlreadyClaimedException) {