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
+3
View File
@@ -98,6 +98,8 @@ export type LedgerEntryType =
| "refund" | "refund"
| "adjustment"; | "adjustment";
export type UsageType = "polish" | "asr" | "ai" | "agent" | "hotword";
export interface LedgerEntry { export interface LedgerEntry {
entryId: string; entryId: string;
userId: string; userId: string;
@@ -105,6 +107,7 @@ export interface LedgerEntry {
amount: number; amount: number;
balanceAfter: number; balanceAfter: number;
reasonCode: string; reasonCode: string;
usageType?: UsageType;
createdAt: string; createdAt: string;
} }
+7 -1
View File
@@ -1,6 +1,12 @@
import type WaButton from "@awesome.me/webawesome/dist/components/button/button.js"; import type WaButton from "@awesome.me/webawesome/dist/components/button/button.js";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { escapeHtml } from "../lib/format"; import type { UsageType } from "../api/types";
import { escapeHtml, usageTypeLabel } from "../lib/format";
export function renderUsageTypeBadge(usageType?: UsageType): string {
if (!usageType) return '<span class="muted">—</span>';
return `<span class="badge usage-badge usage-badge--${escapeHtml(usageType)}">${escapeHtml(usageTypeLabel(usageType))}</span>`;
}
export function renderLoading(container: HTMLElement, label = "正在加载"): void { export function renderLoading(container: HTMLElement, label = "正在加载"): void {
container.innerHTML = ` container.innerHTML = `
+11
View File
@@ -48,6 +48,17 @@ export function statusLabel(status: string): string {
return labels[status] ?? status; return labels[status] ?? status;
} }
export function usageTypeLabel(usageType?: string): string {
const labels: Record<string, string> = {
polish: "润色",
asr: "ASR",
ai: "AI",
agent: "Agent",
hotword: "热词",
};
return usageType ? (labels[usageType] ?? usageType) : "—";
}
export function createIdempotencyKey(): string { export function createIdempotencyKey(): string {
return globalThis.crypto?.randomUUID?.() ?? return globalThis.crypto?.randomUUID?.() ??
`grant-${Date.now()}-${Math.random().toString(36).slice(2)}`; `grant-${Date.now()}-${Math.random().toString(36).slice(2)}`;
+3 -1
View File
@@ -4,6 +4,7 @@ import {
renderEmpty, renderEmpty,
renderError, renderError,
renderLoading, renderLoading,
renderUsageTypeBadge,
setButtonBusy, setButtonBusy,
showToast, showToast,
} from "../components/ui"; } from "../components/ui";
@@ -69,7 +70,7 @@ async function loadLedger(
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<caption class="sr-only">${userId ? `用户 ${escapeHtml(userId)}` : "最新"}积分流水</caption> <caption class="sr-only">${userId ? `用户 ${escapeHtml(userId)}` : "最新"}积分流水</caption>
<thead><tr><th scope="col">时间</th><th scope="col">用户 ID</th><th scope="col">流水号</th><th scope="col">类型</th><th scope="col">变动</th><th scope="col">结余</th><th scope="col">原因</th></tr></thead> <thead><tr><th scope="col">时间</th><th scope="col">用户 ID</th><th scope="col">流水号</th><th scope="col">类型</th><th scope="col">消费类型</th><th scope="col">变动</th><th scope="col">结余</th><th scope="col">原因</th></tr></thead>
<tbody data-ledger-body>${ledgerRows(page.items)}</tbody> <tbody data-ledger-body>${ledgerRows(page.items)}</tbody>
</table> </table>
</div> </div>
@@ -96,6 +97,7 @@ function ledgerRows(entries: LedgerEntry[]): string {
<td class="mono">${escapeHtml(entry.userId)}</td> <td class="mono">${escapeHtml(entry.userId)}</td>
<td class="mono">${escapeHtml(entry.entryId)}</td> <td class="mono">${escapeHtml(entry.entryId)}</td>
<td>${statusLabel(entry.type)}</td> <td>${statusLabel(entry.type)}</td>
<td>${renderUsageTypeBadge(entry.usageType)}</td>
<td class="${entry.amount >= 0 ? "positive" : "negative"}">${formatSignedCredits(entry.amount)}</td> <td class="${entry.amount >= 0 ? "positive" : "negative"}">${formatSignedCredits(entry.amount)}</td>
<td>${formatNumber(entry.balanceAfter)}</td> <td>${formatNumber(entry.balanceAfter)}</td>
<td>${escapeHtml(entry.reasonCode)}</td> <td>${escapeHtml(entry.reasonCode)}</td>
+3 -1
View File
@@ -11,6 +11,7 @@ import {
renderEmpty, renderEmpty,
renderError, renderError,
renderLoading, renderLoading,
renderUsageTypeBadge,
setButtonBusy, setButtonBusy,
showToast, showToast,
} from "../components/ui"; } from "../components/ui";
@@ -255,7 +256,7 @@ function detailTemplate(
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<caption class="sr-only">${escapeHtml(user.displayName || user.userId)} 的积分流水</caption> <caption class="sr-only">${escapeHtml(user.displayName || user.userId)} 的积分流水</caption>
<thead><tr><th scope="col">时间</th><th scope="col">类型</th><th scope="col">变动</th><th scope="col">结余</th><th scope="col">原因</th></tr></thead> <thead><tr><th scope="col">时间</th><th scope="col">类型</th><th scope="col">消费类型</th><th scope="col">变动</th><th scope="col">结余</th><th scope="col">原因</th></tr></thead>
<tbody data-ledger-body>${ledgerRows(ledger.items)}</tbody> <tbody data-ledger-body>${ledgerRows(ledger.items)}</tbody>
</table> </table>
</div> </div>
@@ -281,6 +282,7 @@ function ledgerRows(entries: LedgerEntry[]): string {
<tr> <tr>
<td>${formatDateTime(entry.createdAt)}</td> <td>${formatDateTime(entry.createdAt)}</td>
<td>${escapeHtml(statusLabel(entry.type))}</td> <td>${escapeHtml(statusLabel(entry.type))}</td>
<td>${renderUsageTypeBadge(entry.usageType)}</td>
<td class="${entry.amount >= 0 ? "positive" : "negative"}">${formatSignedCredits(entry.amount)}</td> <td class="${entry.amount >= 0 ? "positive" : "negative"}">${formatSignedCredits(entry.amount)}</td>
<td>${formatNumber(entry.balanceAfter)}</td> <td>${formatNumber(entry.balanceAfter)}</td>
<td>${escapeHtml(entry.reasonCode)}</td> <td>${escapeHtml(entry.reasonCode)}</td>
+46
View File
@@ -938,6 +938,31 @@ td strong {
background: #eaecf0; background: #eaecf0;
} }
.usage-badge--polish {
color: #6941c6;
background: #f4f3ff;
}
.usage-badge--asr {
color: #026aa2;
background: #f0f9ff;
}
.usage-badge--ai {
color: #175cd3;
background: #eff8ff;
}
.usage-badge--agent {
color: #067647;
background: #ecfdf3;
}
.usage-badge--hotword {
color: #b54708;
background: #fffaeb;
}
.search-form { .search-form {
display: flex; display: flex;
max-width: 760px; max-width: 760px;
@@ -1302,6 +1327,27 @@ code {
background: var(--surface-muted); background: var(--surface-muted);
} }
.usage-badge--polish {
color: #bf5af2;
background: rgb(191 90 242 / 14%);
}
.usage-badge--asr,
.usage-badge--ai {
color: #64d2ff;
background: rgb(100 210 255 / 14%);
}
.usage-badge--agent {
color: #30d158;
background: rgb(48 209 88 / 14%);
}
.usage-badge--hotword {
color: #ffd60a;
background: rgb(255 214 10 / 14%);
}
.rank--1 { .rank--1 {
color: #ffd60a; color: #ffd60a;
background: rgb(255 214 10 / 14%); background: rgb(255 214 10 / 14%);
+7
View File
@@ -4,6 +4,7 @@ import {
formatDateTime, formatDateTime,
formatSignedCredits, formatSignedCredits,
statusLabel, statusLabel,
usageTypeLabel,
} from "../lib/format"; } from "../lib/format";
describe("format helpers", () => { describe("format helpers", () => {
@@ -26,4 +27,10 @@ describe("format helpers", () => {
it("未知状态保持原值", () => { it("未知状态保持原值", () => {
expect(statusLabel("custom")).toBe("custom"); expect(statusLabel("custom")).toBe("custom");
}); });
it("消费类型使用清晰的中文标签", () => {
expect(usageTypeLabel("polish")).toBe("润色");
expect(usageTypeLabel("hotword")).toBe("热词");
expect(usageTypeLabel()).toBe("—");
});
}); });
+4
View File
@@ -116,6 +116,7 @@ describe("用户页", () => {
amount: -18, amount: -18,
balanceAfter: 82, balanceAfter: 82,
reasonCode: "USAGE_SETTLE", reasonCode: "USAGE_SETTLE",
usageType: "asr",
createdAt: "2026-08-02T08:00:00Z", createdAt: "2026-08-02T08:00:00Z",
}, },
], ],
@@ -143,6 +144,7 @@ describe("用户页", () => {
); );
expect(container.querySelectorAll("[data-ledger-body] tr")).toHaveLength(2); expect(container.querySelectorAll("[data-ledger-body] tr")).toHaveLength(2);
}); });
expect(container.textContent).toContain("ASR");
expect(container.querySelector("[data-ledger-more]")).toBeNull(); expect(container.querySelector("[data-ledger-more]")).toBeNull();
expect(container.querySelector("[data-ledger-status]")?.textContent).toContain( expect(container.querySelector("[data-ledger-status]")?.textContent).toContain(
"全部记录已加载", "全部记录已加载",
@@ -162,6 +164,7 @@ describe("积分流水页", () => {
amount: -18, amount: -18,
balanceAfter: 102, balanceAfter: 102,
reasonCode: "USAGE_SETTLE", reasonCode: "USAGE_SETTLE",
usageType: "hotword",
createdAt: "2026-08-19T09:00:00Z", createdAt: "2026-08-19T09:00:00Z",
}, },
], ],
@@ -189,6 +192,7 @@ describe("积分流水页", () => {
}); });
expect(container.textContent).toContain(userId); expect(container.textContent).toContain(userId);
expect(container.textContent).toContain("USAGE_SETTLE"); expect(container.textContent).toContain("USAGE_SETTLE");
expect(container.textContent).toContain("热词");
container.querySelector<HTMLButtonElement>("[data-ledger-more]")?.click(); container.querySelector<HTMLButtonElement>("[data-ledger-more]")?.click();
await vi.waitFor(() => { await vi.waitFor(() => {
+8
View File
@@ -1042,6 +1042,10 @@ components:
amount: { type: integer, format: int64 } amount: { type: integer, format: int64 }
balanceAfter: { type: integer, format: int64, minimum: 0 } balanceAfter: { type: integer, format: int64, minimum: 0 }
reasonCode: { type: string } reasonCode: { type: string }
usageType:
type: ["string", "null"]
enum: [polish, asr, ai, agent, hotword, null]
description: Product usage associated with this ledger operation
createdAt: { type: string, format: date-time } createdAt: { type: string, format: date-time }
AdminLedgerPage: AdminLedgerPage:
type: object type: object
@@ -1373,6 +1377,10 @@ components:
maxOutputTokens: { type: integer, minimum: 1, maximum: 4096, default: 512 } maxOutputTokens: { type: integer, minimum: 1, maximum: 4096, default: 512 }
temperature: { type: number, minimum: 0, maximum: 1, default: 0.2 } temperature: { type: number, minimum: 0, maximum: 1, default: 0.2 }
stream: { type: boolean, default: false } stream: { type: boolean, default: false }
requestSource:
type: ["string", "null"]
enum: [hotword, null]
description: Optional product entry point; hotword is accepted only for AI requests
CreateGatewayGrantRequest: CreateGatewayGrantRequest:
type: object type: object
additionalProperties: false additionalProperties: false
@@ -706,6 +706,7 @@ private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse =
amount = amountDelta, amount = amountDelta,
balanceAfter = balanceAfter, balanceAfter = balanceAfter,
reasonCode = type, reasonCode = type,
usageType = usageType,
createdAt = createdAt, createdAt = createdAt,
) )
@@ -897,6 +898,7 @@ private data class AdminLedgerResponse(
val amount: Long, val amount: Long,
val balanceAfter: Long, val balanceAfter: Long,
val reasonCode: String, val reasonCode: String,
val usageType: String?,
val createdAt: String, val createdAt: String,
) )
@@ -32,6 +32,7 @@ data class AdminUserLedgerEntryDto(
val balanceAfter: Long, val balanceAfter: Long,
val referenceId: String?, val referenceId: String?,
val createdAt: String, val createdAt: String,
val usageType: String? = null,
) )
@Serializable @Serializable
@@ -135,7 +135,7 @@ class ExposedAdminUsersRepository(
.thenByDescending { it.id.toString() }, .thenByDescending { it.id.toString() },
) )
.take(ledgerLimit) .take(ledgerLimit)
.map(UserLedgerRow::toDto) .map { it.toDto(support.ledgerUsageTypes[it.referenceId]) }
AdminUserDetailDto( AdminUserDetailDto(
summary = account.toSummary(support), summary = account.toSummary(support),
referralCode = findReferralCode(userId), referralCode = findReferralCode(userId),
@@ -171,12 +171,14 @@ class ExposedAdminUsersRepository(
) )
} }
} }
query.orderBy( val ledger = query.orderBy(
AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC, AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC,
AdminUsersCreditLedgerTable.id to SortOrder.DESC, AdminUsersCreditLedgerTable.id to SortOrder.DESC,
) )
.limit(limit) .limit(limit)
.map { it.toUserLedgerRow().toDto() } .map(ResultRow::toUserLedgerRow)
val usageTypes = loadLedgerUsageTypes(ledger)
ledger.map { it.toDto(usageTypes[it.referenceId]) }
} }
override suspend fun listLatestLedger( override suspend fun listLatestLedger(
@@ -193,18 +195,21 @@ class ExposedAdminUsersRepository(
) )
} }
} }
query.orderBy( val ledger = query.orderBy(
AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC, AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC,
AdminUsersCreditLedgerTable.id to SortOrder.DESC, AdminUsersCreditLedgerTable.id to SortOrder.DESC,
) )
.limit(limit) .limit(limit)
.map { it.toUserLedgerRow().toDto() } .map(ResultRow::toUserLedgerRow)
val usageTypes = loadLedgerUsageTypes(ledger)
ledger.map { it.toDto(usageTypes[it.referenceId]) }
} }
} }
private data class UserSupportRows( private data class UserSupportRows(
val balances: Map<UUID, Long>, val balances: Map<UUID, Long>,
val ledger: List<UserLedgerRow>, val ledger: List<UserLedgerRow>,
val ledgerUsageTypes: Map<UUID, String>,
val usage: List<UserUsageRow>, val usage: List<UserUsageRow>,
val bindings: List<UserReferralBindingRow>, val bindings: List<UserReferralBindingRow>,
) )
@@ -236,16 +241,20 @@ private data class UserReferralBindingRow(
) )
private fun loadSupport(userIds: Set<UUID>): UserSupportRows { 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 ids = userIds.map(UUID::toString)
val ledger = AdminUsersCreditLedgerTable.selectAll()
.where { AdminUsersCreditLedgerTable.userId inList ids }
.map(ResultRow::toUserLedgerRow)
return UserSupportRows( return UserSupportRows(
balances = AdminUsersCreditAccountsTable.selectAll() balances = AdminUsersCreditAccountsTable.selectAll()
.where { AdminUsersCreditAccountsTable.userId inList ids } .where { AdminUsersCreditAccountsTable.userId inList ids }
.map { UUID.fromString(it[AdminUsersCreditAccountsTable.userId]) to it[AdminUsersCreditAccountsTable.balance] } .map { UUID.fromString(it[AdminUsersCreditAccountsTable.userId]) to it[AdminUsersCreditAccountsTable.balance] }
.toMap(), .toMap(),
ledger = AdminUsersCreditLedgerTable.selectAll() ledger = ledger,
.where { AdminUsersCreditLedgerTable.userId inList ids } ledgerUsageTypes = loadLedgerUsageTypes(ledger),
.map(ResultRow::toUserLedgerRow),
usage = AdminUsersCreditUsageTable.selectAll() usage = AdminUsersCreditUsageTable.selectAll()
.where { AdminUsersCreditUsageTable.userId inList ids } .where { AdminUsersCreditUsageTable.userId inList ids }
.map(ResultRow::toUserUsageRow), .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? = private fun findReferralCode(userId: UUID): String? =
AdminUsersReferralCodesTable.selectAll() AdminUsersReferralCodesTable.selectAll()
.where { AdminUsersReferralCodesTable.ownerUserId eq userId.toString() } .where { AdminUsersReferralCodesTable.ownerUserId eq userId.toString() }
@@ -314,6 +340,15 @@ private object AdminUsersCreditLedgerTable : Table("credit_ledger") {
val createdAt = timestamp("created_at") 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") { private object AdminUsersCreditUsageTable : Table("credit_usage_records") {
val userId = varchar("user_id", 36) val userId = varchar("user_id", 36)
val usageKind = enumerationByName<UsageKind>("usage_kind", 8) val usageKind = enumerationByName<UsageKind>("usage_kind", 8)
@@ -349,13 +384,14 @@ private fun ResultRow.toUserLedgerRow() = UserLedgerRow(
createdAt = this[AdminUsersCreditLedgerTable.createdAt], createdAt = this[AdminUsersCreditLedgerTable.createdAt],
) )
private fun UserLedgerRow.toDto() = AdminUserLedgerEntryDto( private fun UserLedgerRow.toDto(usageType: String?) = AdminUserLedgerEntryDto(
id = id.toString(), id = id.toString(),
userId = userId.toString(), userId = userId.toString(),
type = type.name, type = type.name,
amountDelta = amountDelta, amountDelta = amountDelta,
balanceAfter = balanceAfter, balanceAfter = balanceAfter,
referenceId = referenceId?.toString(), referenceId = referenceId?.toString(),
usageType = usageType,
createdAt = createdAt.toString(), createdAt = createdAt.toString(),
) )
@@ -19,6 +19,12 @@ enum class GatewayCapability {
ASR, ASR,
} }
@Serializable
enum class GatewayRequestSource {
@SerialName("hotword")
HOTWORD,
}
@Serializable @Serializable
enum class UsageMeter { enum class UsageMeter {
@SerialName("llm_token") @SerialName("llm_token")
@@ -53,6 +59,7 @@ data class TextGatewayRequest(
val maxOutputTokens: Int = 512, val maxOutputTokens: Int = 512,
val temperature: Double = 0.2, val temperature: Double = 0.2,
val stream: Boolean = false, val stream: Boolean = false,
val requestSource: GatewayRequestSource? = null,
) )
@Serializable @Serializable
@@ -69,6 +76,8 @@ data class AsrGatewayOptions(
sealed interface ProviderRequest { sealed interface ProviderRequest {
val requestId: String val requestId: String
val capability: GatewayCapability val capability: GatewayCapability
val requestSource: GatewayRequestSource?
get() = null
} }
data class TextProviderRequest( data class TextProviderRequest(
@@ -79,6 +88,7 @@ data class TextProviderRequest(
val maxOutputTokens: Int, val maxOutputTokens: Int,
val temperature: Double, val temperature: Double,
val stream: Boolean, val stream: Boolean,
override val requestSource: GatewayRequestSource? = null,
) : ProviderRequest ) : ProviderRequest
data class AsrProviderRequest( data class AsrProviderRequest(
@@ -135,6 +145,9 @@ object TextRequestPolicy {
require(capability != GatewayCapability.AGENT || !request.stream) { require(capability != GatewayCapability.AGENT || !request.stream) {
"agent requests must be non-streaming so the structured result can be validated" "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.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayGrant import com.osglab.account.features.gateway.models.GatewayGrant
import com.osglab.account.features.gateway.models.GatewayPrincipal 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.ProviderUsage
import com.osglab.account.features.gateway.models.UsageMeter import com.osglab.account.features.gateway.models.UsageMeter
import io.ktor.server.application.ApplicationCall import io.ktor.server.application.ApplicationCall
@@ -139,6 +140,7 @@ data class ProviderRequestMetadata(
val reservationId: String, val reservationId: String,
val providerId: String, val providerId: String,
val capability: GatewayCapability, val capability: GatewayCapability,
val requestSource: GatewayRequestSource?,
) )
data class ProviderRefund( data class ProviderRefund(
@@ -33,6 +33,7 @@ private object ProviderRequestsTable : Table("provider_requests") {
val reservationId = varchar("reservation_id", 36).nullable() val reservationId = varchar("reservation_id", 36).nullable()
val providerId = varchar("provider_id", 64) val providerId = varchar("provider_id", 64)
val capability = varchar("capability", 32) val capability = varchar("capability", 32)
val requestSource = varchar("request_source", 32).nullable()
val status = varchar("status", 24) val status = varchar("status", 24)
val providerRequestId = varchar("provider_request_id", 128).nullable() val providerRequestId = varchar("provider_request_id", 128).nullable()
val usageMeter = varchar("usage_meter", 32).nullable() val usageMeter = varchar("usage_meter", 32).nullable()
@@ -279,6 +280,7 @@ class ExposedGatewayRepository(
it[reservationId] = metadata.reservationId it[reservationId] = metadata.reservationId
it[providerId] = metadata.providerId it[providerId] = metadata.providerId
it[capability] = metadata.capability.name it[capability] = metadata.capability.name
it[requestSource] = metadata.requestSource?.name
it[status] = ProviderRequestState.CLAIMED.name it[status] = ProviderRequestState.CLAIMED.name
it[createdAt] = clock.instant() it[createdAt] = clock.instant()
}.insertedCount == 1 }.insertedCount == 1
@@ -264,6 +264,7 @@ fun Route.configureGatewayRoutes(
maxOutputTokens = body.maxOutputTokens, maxOutputTokens = body.maxOutputTokens,
temperature = body.temperature, temperature = body.temperature,
stream = body.stream, stream = body.stream,
requestSource = body.requestSource,
) )
if (body.stream) { if (body.stream) {
@@ -74,6 +74,7 @@ class GatewayService(
reservationId = reservation.id, reservationId = reservation.id,
providerId = provider.descriptor.id, providerId = provider.descriptor.id,
capability = request.capability, capability = request.capability,
requestSource = request.requestSource,
), ),
) )
} catch (replay: GatewayRequestAlreadyClaimedException) { } catch (replay: GatewayRequestAlreadyClaimedException) {
@@ -0,0 +1,5 @@
ALTER TABLE provider_requests
ADD COLUMN request_source VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL AFTER capability,
ADD CONSTRAINT chk_provider_request_source CHECK (
request_source IS NULL OR request_source IN ('HOTWORD')
);
@@ -214,6 +214,7 @@ class AdminRoutesTest {
balanceAfter = 102, balanceAfter = 102,
referenceId = null, referenceId = null,
createdAt = "2026-08-19T09:00:00Z", createdAt = "2026-08-19T09:00:00Z",
usageType = "hotword",
), ),
), ),
nextCursor = null, nextCursor = null,
@@ -233,6 +234,7 @@ class AdminRoutesTest {
assertEquals(HttpStatusCode.OK, response.status) assertEquals(HttpStatusCode.OK, response.status)
response.bodyAsText() shouldContain """"userId":"11111111-1111-4111-8111-111111111111"""" response.bodyAsText() shouldContain """"userId":"11111111-1111-4111-8111-111111111111""""
response.bodyAsText() shouldContain """"reasonCode":"USAGE_SETTLE"""" response.bodyAsText() shouldContain """"reasonCode":"USAGE_SETTLE""""
response.bodyAsText() shouldContain """"usageType":"hotword""""
} }
@Test @Test
@@ -42,4 +42,16 @@ class TextRequestPolicyTest : StringSpec({
) )
} }
} }
"accepts hotword source only for AI requests" {
val request = TextGatewayRequest(
input = "latest news",
requestSource = GatewayRequestSource.HOTWORD,
)
TextRequestPolicy.validate(request, GatewayCapability.AI)
shouldThrow<IllegalArgumentException> {
TextRequestPolicy.validate(request, GatewayCapability.POLISH)
}
}
}) })
@@ -2,6 +2,7 @@ package com.osglab.account.features.gateway.routes
import com.osglab.account.features.gateway.models.GatewayCapability import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayPrincipal import com.osglab.account.features.gateway.models.GatewayPrincipal
import com.osglab.account.features.gateway.models.GatewayRequestSource
import com.osglab.account.features.gateway.models.ProviderDescriptor import com.osglab.account.features.gateway.models.ProviderDescriptor
import com.osglab.account.features.gateway.models.ProviderOutput import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderRequest import com.osglab.account.features.gateway.models.ProviderRequest
@@ -62,11 +63,12 @@ class GatewayRequestIdTest : StringSpec({
val response = client.post("/v1/gateway/llm/ai") { val response = client.post("/v1/gateway/llm/ai") {
header("X-Request-ID", "request-route-123") header("X-Request-ID", "request-route-123")
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody("""{"input":"hello","maxOutputTokens":8}""") setBody("""{"input":"hello","maxOutputTokens":8,"requestSource":"hotword"}""")
} }
response.status shouldBe HttpStatusCode.OK response.status shouldBe HttpStatusCode.OK
provider.lastRequestId shouldBe "request-route-123" provider.lastRequestId shouldBe "request-route-123"
provider.lastRequestSource shouldBe GatewayRequestSource.HOTWORD
} }
} }
}) })
@@ -93,6 +95,7 @@ private fun io.ktor.server.application.Application.gatewayTestApplication(provid
private class RequestIdProvider : GatewayProvider { private class RequestIdProvider : GatewayProvider {
var calls = 0 var calls = 0
var lastRequestId: String? = null var lastRequestId: String? = null
var lastRequestSource: GatewayRequestSource? = null
override val descriptor = ProviderDescriptor( override val descriptor = ProviderDescriptor(
id = "request-id-provider", id = "request-id-provider",
@@ -104,6 +107,7 @@ private class RequestIdProvider : GatewayProvider {
override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage { override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage {
calls += 1 calls += 1
lastRequestId = request.requestId lastRequestId = request.requestId
lastRequestSource = request.requestSource
output.emit("""{"result":"ok"}""".encodeToByteArray()) output.emit("""{"result":"ok"}""".encodeToByteArray())
return ProviderUsage( return ProviderUsage(
meter = UsageMeter.LLM_TOKEN, meter = UsageMeter.LLM_TOKEN,
@@ -2,6 +2,7 @@ package com.osglab.account.features.gateway.services
import com.osglab.account.features.gateway.models.GatewayCapability import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayPrincipal import com.osglab.account.features.gateway.models.GatewayPrincipal
import com.osglab.account.features.gateway.models.GatewayRequestSource
import com.osglab.account.features.gateway.models.ProviderDescriptor import com.osglab.account.features.gateway.models.ProviderDescriptor
import com.osglab.account.features.gateway.models.ProviderOutput import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderRequest import com.osglab.account.features.gateway.models.ProviderRequest
@@ -66,6 +67,19 @@ class GatewayServiceBillingTest : StringSpec({
credits.lastEstimate?.outputUnits shouldBe 32L credits.lastEstimate?.outputUnits shouldBe 32L
} }
"records hotword request source with provider metadata" {
val usageRecords = FakeUsageRecords()
val service = service(FakeCredits(), FakeProvider(), usageRecords)
service.execute(
PRINCIPAL,
request(GatewayRequestSource.HOTWORD),
DISCARD_OUTPUT,
)
usageRecords.lastClaim?.requestSource shouldBe GatewayRequestSource.HOTWORD
}
"uses one reservation when a buffered DeepSeek empty result succeeds on retry" { "uses one reservation when a buffered DeepSeek empty result succeeds on retry" {
val credits = FakeCredits() val credits = FakeCredits()
var attempts = 0 var attempts = 0
@@ -203,7 +217,7 @@ private fun service(
usageRecords = usageRecords, usageRecords = usageRecords,
) )
private fun request() = TextProviderRequest( private fun request(requestSource: GatewayRequestSource? = null) = TextProviderRequest(
requestId = "request-123", requestId = "request-123",
capability = GatewayCapability.AI, capability = GatewayCapability.AI,
input = "hello", input = "hello",
@@ -211,6 +225,7 @@ private fun request() = TextProviderRequest(
maxOutputTokens = 32, maxOutputTokens = 32,
temperature = 0.2, temperature = 0.2,
stream = false, stream = false,
requestSource = requestSource,
) )
private class FakeCredits( private class FakeCredits(
@@ -293,7 +308,11 @@ private class EmptyResultProvider : GatewayProvider {
private class FakeUsageRecords( private class FakeUsageRecords(
private val pending: MutableList<PendingSettlement> = mutableListOf(), private val pending: MutableList<PendingSettlement> = mutableListOf(),
) : GatewayUsagePort { ) : GatewayUsagePort {
override suspend fun claim(metadata: ProviderRequestMetadata) = Unit var lastClaim: ProviderRequestMetadata? = null
override suspend fun claim(metadata: ProviderRequestMetadata) {
lastClaim = metadata
}
override suspend fun markStarted(accountId: String, requestId: String) = Unit override suspend fun markStarted(accountId: String, requestId: String) = Unit
override suspend fun markSettlementPending( override suspend fun markSettlementPending(
accountId: String, accountId: String,