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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 '<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 {
|
||||
container.innerHTML = `
|
||||
|
||||
@@ -48,6 +48,17 @@ export function statusLabel(status: string): string {
|
||||
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 {
|
||||
return globalThis.crypto?.randomUUID?.() ??
|
||||
`grant-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
renderEmpty,
|
||||
renderError,
|
||||
renderLoading,
|
||||
renderUsageTypeBadge,
|
||||
setButtonBusy,
|
||||
showToast,
|
||||
} from "../components/ui";
|
||||
@@ -69,7 +70,7 @@ async function loadLedger(
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
@@ -96,6 +97,7 @@ function ledgerRows(entries: LedgerEntry[]): string {
|
||||
<td class="mono">${escapeHtml(entry.userId)}</td>
|
||||
<td class="mono">${escapeHtml(entry.entryId)}</td>
|
||||
<td>${statusLabel(entry.type)}</td>
|
||||
<td>${renderUsageTypeBadge(entry.usageType)}</td>
|
||||
<td class="${entry.amount >= 0 ? "positive" : "negative"}">${formatSignedCredits(entry.amount)}</td>
|
||||
<td>${formatNumber(entry.balanceAfter)}</td>
|
||||
<td>${escapeHtml(entry.reasonCode)}</td>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
renderEmpty,
|
||||
renderError,
|
||||
renderLoading,
|
||||
renderUsageTypeBadge,
|
||||
setButtonBusy,
|
||||
showToast,
|
||||
} from "../components/ui";
|
||||
@@ -255,7 +256,7 @@ function detailTemplate(
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
@@ -281,6 +282,7 @@ function ledgerRows(entries: LedgerEntry[]): string {
|
||||
<tr>
|
||||
<td>${formatDateTime(entry.createdAt)}</td>
|
||||
<td>${escapeHtml(statusLabel(entry.type))}</td>
|
||||
<td>${renderUsageTypeBadge(entry.usageType)}</td>
|
||||
<td class="${entry.amount >= 0 ? "positive" : "negative"}">${formatSignedCredits(entry.amount)}</td>
|
||||
<td>${formatNumber(entry.balanceAfter)}</td>
|
||||
<td>${escapeHtml(entry.reasonCode)}</td>
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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("—");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<HTMLButtonElement>("[data-ledger-more]")?.click();
|
||||
await vi.waitFor(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+46
-10
@@ -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(
|
||||
|
||||
+2
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
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
|
||||
|
||||
@@ -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.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,
|
||||
|
||||
+21
-2
@@ -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<PendingSettlement> = 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,
|
||||
|
||||
Reference in New Issue
Block a user