From 3edc86a9a01a38b2df69775341225c47f897b507 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:47:12 +0800 Subject: [PATCH] Show product usage types in credit ledger Persist hotword request origin so every reservation lifecycle entry can be classified without mutating the immutable ledger. --- admin-web/src/api/types.ts | 3 + admin-web/src/components/ui.ts | 8 ++- admin-web/src/lib/format.ts | 11 ++++ admin-web/src/pages/credits.ts | 4 +- admin-web/src/pages/users.ts | 4 +- admin-web/src/styles.css | 46 +++++++++++++++ admin-web/src/test/format.test.ts | 7 +++ admin-web/src/test/pages.test.ts | 4 ++ docs/openapi.yaml | 8 +++ .../features/admin/routes/AdminRoutes.kt | 2 + .../admin/users/models/AdminUserDtos.kt | 1 + .../repositories/AdminUsersRepository.kt | 56 +++++++++++++++---- .../features/gateway/models/GatewayModels.kt | 13 +++++ .../features/gateway/ports/GatewayPorts.kt | 2 + .../repositories/ExposedGatewayRepository.kt | 2 + .../features/gateway/routes/GatewayRoutes.kt | 1 + .../gateway/services/GatewayService.kt | 1 + .../V14__provider_request_source.sql | 5 ++ .../features/admin/routes/AdminRoutesTest.kt | 2 + .../gateway/models/TextRequestPolicyTest.kt | 12 ++++ .../gateway/routes/GatewayRequestIdTest.kt | 6 +- .../services/GatewayServiceBillingTest.kt | 23 +++++++- 22 files changed, 205 insertions(+), 16 deletions(-) create mode 100644 src/main/resources/db/migration/V14__provider_request_source.sql diff --git a/admin-web/src/api/types.ts b/admin-web/src/api/types.ts index a22912c..0ff6a4e 100644 --- a/admin-web/src/api/types.ts +++ b/admin-web/src/api/types.ts @@ -98,6 +98,8 @@ export type LedgerEntryType = | "refund" | "adjustment"; +export type UsageType = "polish" | "asr" | "ai" | "agent" | "hotword"; + export interface LedgerEntry { entryId: string; userId: string; @@ -105,6 +107,7 @@ export interface LedgerEntry { amount: number; balanceAfter: number; reasonCode: string; + usageType?: UsageType; createdAt: string; } diff --git a/admin-web/src/components/ui.ts b/admin-web/src/components/ui.ts index f544af9..355d3d8 100644 --- a/admin-web/src/components/ui.ts +++ b/admin-web/src/components/ui.ts @@ -1,6 +1,12 @@ import type WaButton from "@awesome.me/webawesome/dist/components/button/button.js"; 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 ''; + return `${escapeHtml(usageTypeLabel(usageType))}`; +} export function renderLoading(container: HTMLElement, label = "正在加载"): void { container.innerHTML = ` diff --git a/admin-web/src/lib/format.ts b/admin-web/src/lib/format.ts index 0cbe780..940cc8f 100644 --- a/admin-web/src/lib/format.ts +++ b/admin-web/src/lib/format.ts @@ -48,6 +48,17 @@ export function statusLabel(status: string): string { return labels[status] ?? status; } +export function usageTypeLabel(usageType?: string): string { + const labels: Record = { + polish: "润色", + asr: "ASR", + ai: "AI", + agent: "Agent", + hotword: "热词", + }; + return usageType ? (labels[usageType] ?? usageType) : "—"; +} + export function createIdempotencyKey(): string { return globalThis.crypto?.randomUUID?.() ?? `grant-${Date.now()}-${Math.random().toString(36).slice(2)}`; diff --git a/admin-web/src/pages/credits.ts b/admin-web/src/pages/credits.ts index e4b3b21..6706402 100644 --- a/admin-web/src/pages/credits.ts +++ b/admin-web/src/pages/credits.ts @@ -4,6 +4,7 @@ import { renderEmpty, renderError, renderLoading, + renderUsageTypeBadge, setButtonBusy, showToast, } from "../components/ui"; @@ -69,7 +70,7 @@ async function loadLedger(
- + ${ledgerRows(page.items)}
${userId ? `用户 ${escapeHtml(userId)} 的` : "最新"}积分流水
时间用户 ID流水号类型变动结余原因
时间用户 ID流水号类型消费类型变动结余原因
@@ -96,6 +97,7 @@ function ledgerRows(entries: LedgerEntry[]): string { ${escapeHtml(entry.userId)} ${escapeHtml(entry.entryId)} ${statusLabel(entry.type)} + ${renderUsageTypeBadge(entry.usageType)} ${formatSignedCredits(entry.amount)} ${formatNumber(entry.balanceAfter)} ${escapeHtml(entry.reasonCode)} diff --git a/admin-web/src/pages/users.ts b/admin-web/src/pages/users.ts index a50d19f..902587a 100644 --- a/admin-web/src/pages/users.ts +++ b/admin-web/src/pages/users.ts @@ -11,6 +11,7 @@ import { renderEmpty, renderError, renderLoading, + renderUsageTypeBadge, setButtonBusy, showToast, } from "../components/ui"; @@ -255,7 +256,7 @@ function detailTemplate(
- + ${ledgerRows(ledger.items)}
${escapeHtml(user.displayName || user.userId)} 的积分流水
时间类型变动结余原因
时间类型消费类型变动结余原因
@@ -281,6 +282,7 @@ function ledgerRows(entries: LedgerEntry[]): string { ${formatDateTime(entry.createdAt)} ${escapeHtml(statusLabel(entry.type))} + ${renderUsageTypeBadge(entry.usageType)} ${formatSignedCredits(entry.amount)} ${formatNumber(entry.balanceAfter)} ${escapeHtml(entry.reasonCode)} diff --git a/admin-web/src/styles.css b/admin-web/src/styles.css index 302baf2..9ffeec0 100644 --- a/admin-web/src/styles.css +++ b/admin-web/src/styles.css @@ -938,6 +938,31 @@ td strong { 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 { display: flex; max-width: 760px; @@ -1302,6 +1327,27 @@ code { 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 { color: #ffd60a; background: rgb(255 214 10 / 14%); diff --git a/admin-web/src/test/format.test.ts b/admin-web/src/test/format.test.ts index a21d97e..1843b3b 100644 --- a/admin-web/src/test/format.test.ts +++ b/admin-web/src/test/format.test.ts @@ -4,6 +4,7 @@ import { formatDateTime, formatSignedCredits, statusLabel, + usageTypeLabel, } from "../lib/format"; describe("format helpers", () => { @@ -26,4 +27,10 @@ describe("format helpers", () => { it("未知状态保持原值", () => { expect(statusLabel("custom")).toBe("custom"); }); + + it("消费类型使用清晰的中文标签", () => { + expect(usageTypeLabel("polish")).toBe("润色"); + expect(usageTypeLabel("hotword")).toBe("热词"); + expect(usageTypeLabel()).toBe("—"); + }); }); diff --git a/admin-web/src/test/pages.test.ts b/admin-web/src/test/pages.test.ts index dd0c3c4..244bf77 100644 --- a/admin-web/src/test/pages.test.ts +++ b/admin-web/src/test/pages.test.ts @@ -116,6 +116,7 @@ describe("用户页", () => { amount: -18, balanceAfter: 82, reasonCode: "USAGE_SETTLE", + usageType: "asr", createdAt: "2026-08-02T08:00:00Z", }, ], @@ -143,6 +144,7 @@ describe("用户页", () => { ); 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-status]")?.textContent).toContain( "全部记录已加载", @@ -162,6 +164,7 @@ describe("积分流水页", () => { amount: -18, balanceAfter: 102, reasonCode: "USAGE_SETTLE", + usageType: "hotword", createdAt: "2026-08-19T09:00:00Z", }, ], @@ -189,6 +192,7 @@ describe("积分流水页", () => { }); expect(container.textContent).toContain(userId); expect(container.textContent).toContain("USAGE_SETTLE"); + expect(container.textContent).toContain("热词"); container.querySelector("[data-ledger-more]")?.click(); await vi.waitFor(() => { diff --git a/docs/openapi.yaml b/docs/openapi.yaml index ecc83f3..8fc3d06 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1042,6 +1042,10 @@ components: amount: { type: integer, format: int64 } balanceAfter: { type: integer, format: int64, minimum: 0 } 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 } AdminLedgerPage: type: object @@ -1373,6 +1377,10 @@ components: maxOutputTokens: { type: integer, minimum: 1, maximum: 4096, default: 512 } temperature: { type: number, minimum: 0, maximum: 1, default: 0.2 } 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: type: object additionalProperties: false diff --git a/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt b/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt index 8ffe73c..e659f54 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt @@ -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, ) diff --git a/src/main/kotlin/com/osglab/account/features/admin/users/models/AdminUserDtos.kt b/src/main/kotlin/com/osglab/account/features/admin/users/models/AdminUserDtos.kt index 5959b24..d65d91b 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/users/models/AdminUserDtos.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/users/models/AdminUserDtos.kt @@ -32,6 +32,7 @@ data class AdminUserLedgerEntryDto( val balanceAfter: Long, val referenceId: String?, val createdAt: String, + val usageType: String? = null, ) @Serializable diff --git a/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt b/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt index e25c086..34c2268 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt @@ -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, val ledger: List, + val ledgerUsageTypes: Map, val usage: List, val bindings: List, ) @@ -236,16 +241,20 @@ private data class UserReferralBindingRow( ) private fun loadSupport(userIds: Set): 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): UserSupportRows { ) } +private fun loadLedgerUsageTypes(ledger: List): Map { + 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("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(), ) diff --git a/src/main/kotlin/com/osglab/account/features/gateway/models/GatewayModels.kt b/src/main/kotlin/com/osglab/account/features/gateway/models/GatewayModels.kt index d0c2965..7fa7e03 100644 --- a/src/main/kotlin/com/osglab/account/features/gateway/models/GatewayModels.kt +++ b/src/main/kotlin/com/osglab/account/features/gateway/models/GatewayModels.kt @@ -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" + } } } diff --git a/src/main/kotlin/com/osglab/account/features/gateway/ports/GatewayPorts.kt b/src/main/kotlin/com/osglab/account/features/gateway/ports/GatewayPorts.kt index 8e9d995..e736c32 100644 --- a/src/main/kotlin/com/osglab/account/features/gateway/ports/GatewayPorts.kt +++ b/src/main/kotlin/com/osglab/account/features/gateway/ports/GatewayPorts.kt @@ -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( diff --git a/src/main/kotlin/com/osglab/account/features/gateway/repositories/ExposedGatewayRepository.kt b/src/main/kotlin/com/osglab/account/features/gateway/repositories/ExposedGatewayRepository.kt index 0ba02c6..7a9e433 100644 --- a/src/main/kotlin/com/osglab/account/features/gateway/repositories/ExposedGatewayRepository.kt +++ b/src/main/kotlin/com/osglab/account/features/gateway/repositories/ExposedGatewayRepository.kt @@ -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 diff --git a/src/main/kotlin/com/osglab/account/features/gateway/routes/GatewayRoutes.kt b/src/main/kotlin/com/osglab/account/features/gateway/routes/GatewayRoutes.kt index 42034e2..7aadc26 100644 --- a/src/main/kotlin/com/osglab/account/features/gateway/routes/GatewayRoutes.kt +++ b/src/main/kotlin/com/osglab/account/features/gateway/routes/GatewayRoutes.kt @@ -264,6 +264,7 @@ fun Route.configureGatewayRoutes( maxOutputTokens = body.maxOutputTokens, temperature = body.temperature, stream = body.stream, + requestSource = body.requestSource, ) if (body.stream) { diff --git a/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayService.kt b/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayService.kt index e2260bd..4bf8d17 100644 --- a/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayService.kt +++ b/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayService.kt @@ -74,6 +74,7 @@ class GatewayService( reservationId = reservation.id, providerId = provider.descriptor.id, capability = request.capability, + requestSource = request.requestSource, ), ) } catch (replay: GatewayRequestAlreadyClaimedException) { diff --git a/src/main/resources/db/migration/V14__provider_request_source.sql b/src/main/resources/db/migration/V14__provider_request_source.sql new file mode 100644 index 0000000..b9e23bf --- /dev/null +++ b/src/main/resources/db/migration/V14__provider_request_source.sql @@ -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') + ); diff --git a/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt b/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt index 5c3fbef..7986ed9 100644 --- a/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt +++ b/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt @@ -214,6 +214,7 @@ class AdminRoutesTest { balanceAfter = 102, referenceId = null, createdAt = "2026-08-19T09:00:00Z", + usageType = "hotword", ), ), nextCursor = null, @@ -233,6 +234,7 @@ class AdminRoutesTest { assertEquals(HttpStatusCode.OK, response.status) response.bodyAsText() shouldContain """"userId":"11111111-1111-4111-8111-111111111111"""" response.bodyAsText() shouldContain """"reasonCode":"USAGE_SETTLE"""" + response.bodyAsText() shouldContain """"usageType":"hotword"""" } @Test diff --git a/src/test/kotlin/com/osglab/account/features/gateway/models/TextRequestPolicyTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/models/TextRequestPolicyTest.kt index db4232b..d45ea25 100644 --- a/src/test/kotlin/com/osglab/account/features/gateway/models/TextRequestPolicyTest.kt +++ b/src/test/kotlin/com/osglab/account/features/gateway/models/TextRequestPolicyTest.kt @@ -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 { + TextRequestPolicy.validate(request, GatewayCapability.POLISH) + } + } }) diff --git a/src/test/kotlin/com/osglab/account/features/gateway/routes/GatewayRequestIdTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/routes/GatewayRequestIdTest.kt index be9d2ac..b757a2a 100644 --- a/src/test/kotlin/com/osglab/account/features/gateway/routes/GatewayRequestIdTest.kt +++ b/src/test/kotlin/com/osglab/account/features/gateway/routes/GatewayRequestIdTest.kt @@ -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.GatewayPrincipal +import com.osglab.account.features.gateway.models.GatewayRequestSource import com.osglab.account.features.gateway.models.ProviderDescriptor import com.osglab.account.features.gateway.models.ProviderOutput import com.osglab.account.features.gateway.models.ProviderRequest @@ -62,11 +63,12 @@ class GatewayRequestIdTest : StringSpec({ val response = client.post("/v1/gateway/llm/ai") { header("X-Request-ID", "request-route-123") contentType(ContentType.Application.Json) - setBody("""{"input":"hello","maxOutputTokens":8}""") + setBody("""{"input":"hello","maxOutputTokens":8,"requestSource":"hotword"}""") } response.status shouldBe HttpStatusCode.OK 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 { var calls = 0 var lastRequestId: String? = null + var lastRequestSource: GatewayRequestSource? = null override val descriptor = ProviderDescriptor( id = "request-id-provider", @@ -104,6 +107,7 @@ private class RequestIdProvider : GatewayProvider { override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage { calls += 1 lastRequestId = request.requestId + lastRequestSource = request.requestSource output.emit("""{"result":"ok"}""".encodeToByteArray()) return ProviderUsage( meter = UsageMeter.LLM_TOKEN, diff --git a/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayServiceBillingTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayServiceBillingTest.kt index 05cca29..de348ce 100644 --- a/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayServiceBillingTest.kt +++ b/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayServiceBillingTest.kt @@ -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.GatewayPrincipal +import com.osglab.account.features.gateway.models.GatewayRequestSource import com.osglab.account.features.gateway.models.ProviderDescriptor import com.osglab.account.features.gateway.models.ProviderOutput import com.osglab.account.features.gateway.models.ProviderRequest @@ -66,6 +67,19 @@ class GatewayServiceBillingTest : StringSpec({ 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" { val credits = FakeCredits() var attempts = 0 @@ -203,7 +217,7 @@ private fun service( usageRecords = usageRecords, ) -private fun request() = TextProviderRequest( +private fun request(requestSource: GatewayRequestSource? = null) = TextProviderRequest( requestId = "request-123", capability = GatewayCapability.AI, input = "hello", @@ -211,6 +225,7 @@ private fun request() = TextProviderRequest( maxOutputTokens = 32, temperature = 0.2, stream = false, + requestSource = requestSource, ) private class FakeCredits( @@ -293,7 +308,11 @@ private class EmptyResultProvider : GatewayProvider { private class FakeUsageRecords( private val pending: MutableList = mutableListOf(), ) : 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 markSettlementPending( accountId: String,