= {
+ 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(
${userId ? `用户 ${escapeHtml(userId)} 的` : "最新"}积分流水
- | 时间 | 用户 ID | 流水号 | 类型 | 变动 | 结余 | 原因 |
+ | 时间 | 用户 ID | 流水号 | 类型 | 消费类型 | 变动 | 结余 | 原因 |
${ledgerRows(page.items)}
@@ -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(
${escapeHtml(user.displayName || user.userId)} 的积分流水
- | 时间 | 类型 | 变动 | 结余 | 原因 |
+ | 时间 | 类型 | 消费类型 | 变动 | 结余 | 原因 |
${ledgerRows(ledger.items)}
@@ -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,