Add complimentary OOBE polish and configurable admin mTLS
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Allow one server-audited onboarding polish request without credits and make the certificate gate temporarily reversible while preserving application authentication.
This commit is contained in:
Rocky
2026-08-20 17:05:35 +08:00
parent 0b4acb5978
commit 034a3e8745
25 changed files with 698 additions and 64 deletions
@@ -73,6 +73,7 @@ import com.osglab.account.features.gateway.adapters.SessionIdentityAdapter
import com.osglab.account.features.gateway.GatewaySettings
import com.osglab.account.features.gateway.asr.AsrStreamingService
import com.osglab.account.features.gateway.ports.CreditReservationPort
import com.osglab.account.features.gateway.ports.ComplimentaryRequestPort
import com.osglab.account.features.gateway.ports.GatewayAccessTokenPort
import com.osglab.account.features.gateway.ports.GatewayGrantPort
import com.osglab.account.features.gateway.ports.GatewayGrantRepository
@@ -330,7 +331,7 @@ fun Application.module() {
integrityRoutes(koin.get())
}
if (appConfig.admin.enabled) {
adminWebRoutes()
adminWebRoutes(appConfig)
rateLimit(ADMIN_API_RATE_LIMIT) {
adminApiRoutes(
config = appConfig,
@@ -507,6 +508,7 @@ fun accountServerModule(config: AppConfig): Module = module {
single<GatewayGrantRepository> { get<ExposedGatewayRepository>() }
single<GatewayGrantPort> { get<ExposedGatewayRepository>() }
single<GatewayUsagePort> { get<ExposedGatewayRepository>() }
single<ComplimentaryRequestPort> { get<ExposedGatewayRepository>() }
single<AccountProvisioner> {
AccountProvisioner { accountId, deviceCheckToken, displayName ->
val granted = get<DeviceCheckTrialService>().claimAndGrant(accountId, deviceCheckToken)
@@ -617,7 +619,7 @@ fun accountServerModule(config: AppConfig): Module = module {
single {
ProviderCatalog(configuredProviders(config, get()))
}
single { GatewayService(get(), get(), get(), get()) }
single { GatewayService(get(), get(), get(), get(), get()) }
single { GatewayReconciliationService(get(), get()) }
single {
InviteWebConfig(
@@ -161,6 +161,7 @@ data class AppConfig(
}
val admin = AdminConfig(
enabled = adminEnabled,
mtlsRequired = config.booleanOrDefault("app.admin.mtlsRequired", true),
bootstrapEnabled = adminBootstrapEnabled,
bootstrapOperatorId = config.optionalValue("app.admin.bootstrapOperatorId")
?.let {
@@ -434,6 +435,7 @@ data class IntegrityConfig(
data class AdminConfig(
val enabled: Boolean = false,
val mtlsRequired: Boolean = true,
val bootstrapEnabled: Boolean = false,
val bootstrapOperatorId: UUID? = null,
val bootstrapUsername: String? = null,
@@ -34,6 +34,8 @@ import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call
import io.ktor.server.application.createRouteScopedPlugin
import io.ktor.server.application.install
import io.ktor.server.http.content.staticResources
import io.ktor.server.plugins.BadRequestException
import io.ktor.server.plugins.ratelimit.RateLimitName
@@ -51,8 +53,13 @@ import java.time.Clock
import java.time.Duration
import java.util.UUID
fun Route.adminWebRoutes() {
staticResources("/admin", "admin", index = "index.html")
fun Route.adminWebRoutes(config: AppConfig) {
route("/admin") {
install(RequireVerifiedAdminEdge) {
appConfig = config
}
staticResources("/", "admin", index = "index.html")
}
}
fun Route.adminApiRoutes(
@@ -71,7 +78,7 @@ fun Route.adminApiRoutes(
rateLimit(ADMIN_AUTH_RATE_LIMIT) {
route("/auth") {
get("/session") {
if (!call.requireVerifiedAdminEdge()) return@get
if (!call.requireVerifiedAdminEdge(config)) return@get
val principal = call.currentPrincipal(sessionService)
call.respond(
AdminSessionResponse(
@@ -83,7 +90,7 @@ fun Route.adminApiRoutes(
}
post("/login") {
if (!call.requireVerifiedAdminEdge() || !call.requireSameOrigin(config)) return@post
if (!call.requireVerifiedAdminEdge(config) || !call.requireSameOrigin(config)) return@post
val request = call.receive<AdminLoginRequest>()
val password = request.password.toCharArray()
val result = try {
@@ -119,7 +126,7 @@ fun Route.adminApiRoutes(
}
post("/logout") {
if (!call.requireVerifiedAdminEdge() || !call.requireSameOrigin(config)) return@post
if (!call.requireVerifiedAdminEdge(config) || !call.requireSameOrigin(config)) return@post
val sessionToken = call.request.cookies[SESSION_COOKIE]
val csrfToken = call.request.header(CSRF_HEADER)
if (
@@ -141,7 +148,7 @@ fun Route.adminApiRoutes(
}
get("/overview") {
if (call.requirePrincipal(sessionService) == null) return@get
if (call.requirePrincipal(config, sessionService) == null) return@get
val stats = statsService.getRange(call.request.queryParameters["range"], clock)
?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
@@ -151,7 +158,7 @@ fun Route.adminApiRoutes(
}
get("/referrals") {
if (call.requirePrincipal(sessionService) == null) return@get
if (call.requirePrincipal(config, sessionService) == null) return@get
val stats = statsService.getRange(call.request.queryParameters["range"], clock)
?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
@@ -161,7 +168,7 @@ fun Route.adminApiRoutes(
}
get("/analytics") {
if (call.requirePrincipal(sessionService) == null) return@get
if (call.requirePrincipal(config, sessionService) == null) return@get
val window = parseAdminStatsRange(call.request.queryParameters["range"], clock)
?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
@@ -173,6 +180,7 @@ fun Route.adminApiRoutes(
get("/users") {
if (
call.requireRole(
config,
sessionService,
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
) == null
@@ -206,6 +214,7 @@ fun Route.adminApiRoutes(
get("/users/{userId}") {
if (
call.requireRole(
config,
sessionService,
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
) == null
@@ -221,6 +230,7 @@ fun Route.adminApiRoutes(
get("/users/{userId}/ledger") {
if (
call.requireRole(
config,
sessionService,
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
) == null
@@ -252,6 +262,7 @@ fun Route.adminApiRoutes(
get("/credits/ledger") {
if (
call.requireRole(
config,
sessionService,
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
) == null
@@ -325,7 +336,7 @@ fun Route.adminApiRoutes(
}
get("/operators/summary") {
val principal = call.requirePrincipal(sessionService) ?: return@get
val principal = call.requirePrincipal(config, sessionService) ?: return@get
try {
val summary = operatorService.summary(principal)
call.respond(
@@ -341,7 +352,7 @@ fun Route.adminApiRoutes(
}
get("/operators") {
val principal = call.requirePrincipal(sessionService) ?: return@get
val principal = call.requirePrincipal(config, sessionService) ?: return@get
val limit = call.pageLimit(maximum = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
@@ -468,7 +479,7 @@ fun Route.adminApiRoutes(
}
get("/audit") {
val principal = call.requirePrincipal(sessionService) ?: return@get
val principal = call.requirePrincipal(config, sessionService) ?: return@get
val limit = call.pageLimit(maximum = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
@@ -528,9 +539,10 @@ private fun parseAdminStatsRange(range: String?, clock: Clock): Pair<java.time.I
}
private suspend fun ApplicationCall.requirePrincipal(
config: AppConfig,
sessions: AdminSessionService,
): AdminPrincipal? {
if (!requireVerifiedAdminEdge()) return null
if (!requireVerifiedAdminEdge(config)) return null
val principal = currentPrincipal(sessions)
if (principal == null) {
respond(HttpStatusCode.Unauthorized, AdminErrorResponse("UNAUTHORIZED"))
@@ -539,10 +551,11 @@ private suspend fun ApplicationCall.requirePrincipal(
}
private suspend fun ApplicationCall.requireRole(
config: AppConfig,
sessions: AdminSessionService,
allowedRoles: Set<AdminRole>,
): AdminPrincipal? {
val principal = requirePrincipal(sessions) ?: return null
val principal = requirePrincipal(config, sessions) ?: return null
if (principal.role !in allowedRoles) {
respond(HttpStatusCode.Forbidden, AdminErrorResponse("INSUFFICIENT_PERMISSION"))
return null
@@ -554,7 +567,7 @@ private suspend fun ApplicationCall.requireMutationPrincipal(
config: AppConfig,
sessions: AdminSessionService,
): AdminPrincipal? {
if (!requireVerifiedAdminEdge() || !requireSameOrigin(config)) return null
if (!requireVerifiedAdminEdge(config) || !requireSameOrigin(config)) return null
val sessionToken = request.cookies[SESSION_COOKIE]
val csrfToken = request.header(CSRF_HEADER)
val principal = if (sessionToken != null && csrfToken != null) {
@@ -568,12 +581,31 @@ private suspend fun ApplicationCall.requireMutationPrincipal(
return principal
}
private suspend fun ApplicationCall.requireVerifiedAdminEdge(): Boolean {
if (request.header(MTLS_HEADER) == MTLS_VERIFIED) return true
private suspend fun ApplicationCall.requireVerifiedAdminEdge(config: AppConfig): Boolean {
if (isVerifiedAdminEdge(config)) return true
respond(HttpStatusCode.NotFound)
return false
}
private fun ApplicationCall.isVerifiedAdminEdge(config: AppConfig): Boolean =
!config.admin.mtlsRequired || request.header(MTLS_HEADER) == MTLS_VERIFIED
private class AdminEdgePluginConfig {
lateinit var appConfig: AppConfig
}
private val RequireVerifiedAdminEdge = createRouteScopedPlugin(
name = "RequireVerifiedAdminEdge",
createConfiguration = ::AdminEdgePluginConfig,
) {
val appConfig = pluginConfig.appConfig
onCall { call ->
if (!call.isVerifiedAdminEdge(appConfig)) {
call.respond(HttpStatusCode.NotFound)
}
}
}
private suspend fun ApplicationCall.requireSameOrigin(config: AppConfig): Boolean {
if (request.header(HttpHeaders.Origin) == config.publicBaseUrl) return true
respond(HttpStatusCode.Forbidden, AdminErrorResponse("ORIGIN_INVALID"))
@@ -25,6 +25,12 @@ enum class GatewayRequestSource {
HOTWORD,
}
@Serializable
enum class GatewayRequestPurpose {
@SerialName("oobe")
OOBE,
}
@Serializable
enum class UsageMeter {
@SerialName("llm_token")
@@ -61,6 +67,7 @@ data class TextGatewayRequest(
val stream: Boolean = false,
val requestSource: GatewayRequestSource? = null,
val taskKind: GatewayTaskKind? = null,
val requestPurpose: GatewayRequestPurpose? = null,
)
@Serializable
@@ -154,6 +161,8 @@ sealed interface ProviderRequest {
val capability: GatewayCapability
val requestSource: GatewayRequestSource?
get() = null
val requestPurpose: GatewayRequestPurpose?
get() = null
}
data class TextProviderRequest(
@@ -166,6 +175,7 @@ data class TextProviderRequest(
val temperature: Double,
val stream: Boolean,
override val requestSource: GatewayRequestSource? = null,
override val requestPurpose: GatewayRequestPurpose? = null,
) : ProviderRequest
data class AsrProviderRequest(
@@ -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.GatewayRequestPurpose
import com.osglab.account.features.gateway.models.GatewayRequestSource
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.UsageMeter
@@ -14,6 +15,30 @@ data class CreditReservation(
val reservedUnits: Long,
)
data class ComplimentaryRequestClaim(
val accountId: String,
val purpose: GatewayRequestPurpose,
val capability: GatewayCapability,
val requestId: String,
)
/**
* Atomically grants a bounded complimentary request. Implementations must
* enforce one consumed claim per account, purpose, and capability.
*/
interface ComplimentaryRequestPort {
suspend fun claim(
accountId: String,
purpose: GatewayRequestPurpose,
capability: GatewayCapability,
requestId: String,
): ComplimentaryRequestClaim?
suspend fun consume(claim: ComplimentaryRequestClaim)
suspend fun release(claim: ComplimentaryRequestClaim)
}
data class ProviderUsageEstimate(
val meter: UsageMeter,
val units: Long,
@@ -137,10 +162,11 @@ interface GatewayGrantRepository : GatewayGrantPort {
data class ProviderRequestMetadata(
val requestId: String,
val accountId: String,
val reservationId: String,
val reservationId: String?,
val providerId: String,
val capability: GatewayCapability,
val requestSource: GatewayRequestSource?,
val requestPurpose: GatewayRequestPurpose? = null,
)
data class ProviderRefund(
@@ -3,8 +3,11 @@ package com.osglab.account.features.gateway.repositories
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayGrant
import com.osglab.account.features.gateway.models.GatewayRequestPurpose
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.ports.ComplimentaryRequestClaim
import com.osglab.account.features.gateway.ports.ComplimentaryRequestPort
import com.osglab.account.features.gateway.ports.GatewayGrantRepository
import com.osglab.account.features.gateway.ports.GatewayRefreshRotationResult
import com.osglab.account.features.gateway.ports.GatewayRequestAlreadyClaimedException
@@ -19,13 +22,16 @@ import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.greater
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.core.lessEq
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.Clock
import java.time.Duration
private object ProviderRequestsTable : Table("provider_requests") {
val requestId = varchar("request_id", 64)
@@ -34,6 +40,7 @@ private object ProviderRequestsTable : Table("provider_requests") {
val providerId = varchar("provider_id", 64)
val capability = varchar("capability", 32)
val requestSource = varchar("request_source", 32).nullable()
val requestPurpose = varchar("request_purpose", 32).nullable()
val status = varchar("status", 24)
val providerRequestId = varchar("provider_request_id", 128).nullable()
val usageMeter = varchar("usage_meter", 32).nullable()
@@ -47,6 +54,18 @@ private object ProviderRequestsTable : Table("provider_requests") {
override val primaryKey = PrimaryKey(accountId, requestId)
}
private object ComplimentaryRequestsTable : Table("gateway_complimentary_requests") {
val accountId = varchar("account_id", 36)
val purpose = varchar("purpose", 32)
val capability = varchar("capability", 32)
val requestId = varchar("request_id", 64)
val status = varchar("status", 16)
val expiresAt = timestamp("expires_at")
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(accountId, purpose, capability)
}
private object UsageRecordsTable : Table("usage_records") {
val id = long("id").autoIncrement()
val accountId = varchar("account_id", 36)
@@ -91,7 +110,7 @@ private object GatewayRefreshTokensTable : Table("gateway_refresh_tokens") {
class ExposedGatewayRepository(
private val databaseFactory: DatabaseFactory,
private val clock: Clock = Clock.systemUTC(),
) : GatewayGrantRepository, GatewayUsagePort {
) : GatewayGrantRepository, GatewayUsagePort, ComplimentaryRequestPort {
override suspend fun isAllowed(accountId: String, capability: GatewayCapability): Boolean =
databaseFactory.query {
val now = clock.instant()
@@ -272,6 +291,65 @@ class ExposedGatewayRepository(
?.takeIf { it.scopes == scopes }
}
override suspend fun claim(
accountId: String,
purpose: GatewayRequestPurpose,
capability: GatewayCapability,
requestId: String,
): ComplimentaryRequestClaim? = databaseFactory.query {
val now = clock.instant()
val expiresAt = now.plus(COMPLIMENTARY_CLAIM_TTL)
val inserted = ComplimentaryRequestsTable.insertIgnore {
it[ComplimentaryRequestsTable.accountId] = accountId
it[ComplimentaryRequestsTable.purpose] = purpose.name
it[ComplimentaryRequestsTable.capability] = capability.name
it[ComplimentaryRequestsTable.requestId] = requestId
it[status] = COMPLIMENTARY_CLAIMED
it[ComplimentaryRequestsTable.expiresAt] = expiresAt
it[createdAt] = now
it[updatedAt] = now
}.insertedCount == 1
val reclaimed = if (!inserted) {
ComplimentaryRequestsTable.update({
complimentaryKey(accountId, purpose, capability) and
(ComplimentaryRequestsTable.status eq COMPLIMENTARY_CLAIMED) and
(ComplimentaryRequestsTable.expiresAt lessEq now)
}) {
it[ComplimentaryRequestsTable.requestId] = requestId
it[ComplimentaryRequestsTable.expiresAt] = expiresAt
it[updatedAt] = now
} == 1
} else {
false
}
if (!inserted && !reclaimed) return@query null
ComplimentaryRequestClaim(accountId, purpose, capability, requestId)
}
override suspend fun consume(claim: ComplimentaryRequestClaim) {
databaseFactory.query {
val changed = ComplimentaryRequestsTable.update({
complimentaryKey(claim.accountId, claim.purpose, claim.capability) and
(ComplimentaryRequestsTable.requestId eq claim.requestId) and
(ComplimentaryRequestsTable.status eq COMPLIMENTARY_CLAIMED)
}) {
it[status] = COMPLIMENTARY_CONSUMED
it[updatedAt] = clock.instant()
}
check(changed == 1) { "Complimentary request cannot be consumed" }
}
}
override suspend fun release(claim: ComplimentaryRequestClaim) {
databaseFactory.query {
ComplimentaryRequestsTable.deleteWhere {
complimentaryKey(claim.accountId, claim.purpose, claim.capability) and
(ComplimentaryRequestsTable.requestId eq claim.requestId) and
(ComplimentaryRequestsTable.status eq COMPLIMENTARY_CLAIMED)
}
}
}
override suspend fun claim(metadata: ProviderRequestMetadata) {
databaseFactory.query {
val inserted = ProviderRequestsTable.insertIgnore {
@@ -281,6 +359,7 @@ class ExposedGatewayRepository(
it[providerId] = metadata.providerId
it[capability] = metadata.capability.name
it[requestSource] = metadata.requestSource?.name
it[requestPurpose] = metadata.requestPurpose?.name
it[status] = ProviderRequestState.CLAIMED.name
it[createdAt] = clock.instant()
}.insertedCount == 1
@@ -352,6 +431,7 @@ class ExposedGatewayRepository(
val changed = ProviderRequestsTable.update({
requestKey(accountId, requestId) and
(
(ProviderRequestsTable.status eq ProviderRequestState.STARTED.name) or
(ProviderRequestsTable.status eq ProviderRequestState.SETTLEMENT_PENDING.name) or
(ProviderRequestsTable.status eq ProviderRequestState.SETTLED.name)
)
@@ -482,6 +562,18 @@ private fun requestKey(accountId: String, requestId: String) =
(ProviderRequestsTable.accountId eq accountId) and
(ProviderRequestsTable.requestId eq requestId)
private fun complimentaryKey(
accountId: String,
purpose: GatewayRequestPurpose,
capability: GatewayCapability,
) = (ComplimentaryRequestsTable.accountId eq accountId) and
(ComplimentaryRequestsTable.purpose eq purpose.name) and
(ComplimentaryRequestsTable.capability eq capability.name)
private fun org.jetbrains.exposed.v1.core.ResultRow.requestState(): ProviderRequestState =
runCatching { ProviderRequestState.valueOf(this[ProviderRequestsTable.status]) }
.getOrDefault(ProviderRequestState.MANUAL_REVIEW)
private val COMPLIMENTARY_CLAIM_TTL: Duration = Duration.ofMinutes(15)
private const val COMPLIMENTARY_CLAIMED = "CLAIMED"
private const val COMPLIMENTARY_CONSUMED = "CONSUMED"
@@ -25,6 +25,7 @@ import com.osglab.account.features.gateway.ports.GatewayPrincipalResolver
import com.osglab.account.features.gateway.ports.GatewayRequestAlreadyClaimedException
import com.osglab.account.features.gateway.providers.UnsupportedGatewayCapabilityException
import com.osglab.account.features.gateway.services.GatewayAccessDeniedException
import com.osglab.account.features.gateway.services.ComplimentaryRequestUnavailableException
import com.osglab.account.features.gateway.services.GatewayGrantService
import com.osglab.account.features.gateway.services.GatewayRefreshTokenInvalidException
import com.osglab.account.features.gateway.services.GatewayRefreshTokenReuseException
@@ -274,6 +275,7 @@ fun Route.configureGatewayRoutes(
temperature = body.temperature,
stream = body.stream,
requestSource = body.requestSource,
requestPurpose = body.requestPurpose,
)
if (body.stream) {
@@ -435,6 +437,13 @@ private suspend fun ApplicationCall.respondGatewayFailure(
requestId,
)
is ComplimentaryRequestUnavailableException -> respondGatewayError(
HttpStatusCode.Conflict,
"oobe_already_used",
"The complimentary OOBE request has already been used",
requestId,
)
is GatewayBodyTooLargeException -> respondGatewayError(
HttpStatusCode.PayloadTooLarge,
"request_too_large",
@@ -2,6 +2,8 @@ package com.osglab.account.features.gateway.services
import com.osglab.account.features.gateway.models.AsrProviderRequest
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayRequestPurpose
import com.osglab.account.features.gateway.models.GatewayTaskKind
import com.osglab.account.features.gateway.models.GatewaySubject
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderRequest
@@ -10,6 +12,8 @@ import com.osglab.account.features.gateway.models.TextProviderRequest
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.ports.CreditReservation
import com.osglab.account.features.gateway.ports.CreditReservationPort
import com.osglab.account.features.gateway.ports.ComplimentaryRequestClaim
import com.osglab.account.features.gateway.ports.ComplimentaryRequestPort
import com.osglab.account.features.gateway.ports.GatewayGrantPort
import com.osglab.account.features.gateway.ports.GatewayRequestAlreadyClaimedException
import com.osglab.account.features.gateway.ports.GatewayUsagePort
@@ -28,6 +32,7 @@ class GatewayService(
private val credits: CreditReservationPort,
private val grants: GatewayGrantPort,
private val usageRecords: GatewayUsagePort,
private val complimentaryRequests: ComplimentaryRequestPort = NoComplimentaryRequests,
private val usageEstimator: GatewayUsageEstimator = ConservativeGatewayUsageEstimator,
private val llmProviderTimeoutMillis: Long = 120_000L,
private val asrProviderTimeoutMillis: Long = 360_000L,
@@ -61,28 +66,46 @@ class GatewayService(
val provider = catalog.providerFor(request)
val estimate = usageEstimator.estimate(request)
validateEstimate(request, estimate)
val reservation = credits.reserve(
accountId = subject.accountId,
estimate = estimate,
requestId = request.requestId,
)
val complimentaryClaim = request.requestPurpose?.let { purpose ->
validateComplimentaryRequest(request, purpose)
complimentaryRequests.claim(
accountId = subject.accountId,
purpose = purpose,
capability = request.capability,
requestId = request.requestId,
) ?: throw ComplimentaryRequestUnavailableException(purpose)
}
val reservation = if (complimentaryClaim == null) {
credits.reserve(
accountId = subject.accountId,
estimate = estimate,
requestId = request.requestId,
)
} else {
null
}
try {
usageRecords.claim(
ProviderRequestMetadata(
requestId = request.requestId,
accountId = subject.accountId,
reservationId = reservation.id,
reservationId = reservation?.id,
providerId = provider.descriptor.id,
capability = request.capability,
requestSource = request.requestSource,
requestPurpose = request.requestPurpose,
),
)
} catch (replay: GatewayRequestAlreadyClaimedException) {
// The existing claim owns the reservation. Releasing it here would
// refund an in-flight or completed request.
// refund an in-flight or completed paid request. Complimentary
// claims are newly acquired above and must not remain stranded.
if (complimentaryClaim != null) {
releaseAfterFailure(null, complimentaryClaim, replay)
}
throw replay
} catch (failure: Throwable) {
releaseAfterFailure(reservation, failure)
releaseAfterFailure(reservation, complimentaryClaim, failure)
throw failure
}
@@ -93,12 +116,20 @@ class GatewayService(
subject.accountId,
request.requestId,
reservation,
complimentaryClaim,
failure,
)
throw failure
}
return PreparedGatewayRequest(subject, request, provider, estimate, reservation)
return PreparedGatewayRequest(
subject,
request,
provider,
estimate,
reservation,
complimentaryClaim,
)
}
suspend fun executePrepared(
@@ -141,11 +172,16 @@ class GatewayService(
// Once upstream has completed, cancellation must not interrupt durable
// metering. The reservation remains frozen if any settlement step fails.
withContext(NonCancellable) {
if (prepared.complimentaryClaim != null) {
settleComplimentary(prepared, usage)
return@withContext
}
val billableReservation = requireNotNull(reservation)
val pendingRecorded = runCatching {
usageRecords.markSettlementPending(subject.accountId, request.requestId, usage)
}.isSuccess
val settled = runCatching { credits.settle(reservation.id, usage) }.isSuccess
val settled = runCatching { credits.settle(billableReservation.id, usage) }.isSuccess
if (settled && pendingRecorded) {
// Metadata failure after a successful settlement must not turn a
// successful provider response into a client-visible 502. The
@@ -177,6 +213,7 @@ class GatewayService(
prepared.subject.accountId,
prepared.request.requestId,
prepared.reservation,
prepared.complimentaryClaim,
failure,
)
}
@@ -198,10 +235,15 @@ class GatewayService(
private suspend fun releaseAndRecord(
accountId: String,
requestId: String,
reservation: CreditReservation,
reservation: CreditReservation?,
complimentaryClaim: ComplimentaryRequestClaim?,
failure: Throwable,
): Unit = withContext(NonCancellable) {
val released = runCatching { credits.release(reservation.id) }
val released = if (complimentaryClaim != null) {
runCatching { complimentaryRequests.release(complimentaryClaim) }
} else {
runCatching { credits.release(requireNotNull(reservation).id) }
}
if (released.isSuccess) {
runCatching {
usageRecords.markReleased(
@@ -218,6 +260,30 @@ class GatewayService(
}
}
private suspend fun settleComplimentary(
prepared: PreparedGatewayRequest,
usage: ProviderUsage,
) {
val claim = requireNotNull(prepared.complimentaryClaim)
val consumed = runCatching { complimentaryRequests.consume(claim) }.isSuccess
val recorded = consumed && runCatching {
usageRecords.markSucceeded(
prepared.subject.accountId,
prepared.request.requestId,
usage,
)
}.isSuccess
if (!consumed || !recorded) {
runCatching {
usageRecords.markManualReview(
prepared.subject.accountId,
prepared.request.requestId,
if (consumed) "complimentary_usage_record_pending" else "complimentary_consume_pending",
)
}
}
}
private fun validateUsage(usage: ProviderUsage, estimate: ProviderUsageEstimate) {
if (usage.meter != estimate.meter) {
throw GatewayUsagePolicyException("Provider usage meter differs from the reservation")
@@ -272,11 +338,31 @@ class GatewayService(
}
}
private fun validateComplimentaryRequest(
request: ProviderRequest,
purpose: GatewayRequestPurpose,
) {
require(
purpose == GatewayRequestPurpose.OOBE &&
request is TextProviderRequest &&
request.capability == GatewayCapability.POLISH &&
request.executionPolicy.taskKind == GatewayTaskKind.DICTATION_POLISH,
) {
"OOBE is supported only for dictation polish"
}
}
private suspend fun releaseAfterFailure(
reservation: CreditReservation,
reservation: CreditReservation?,
complimentaryClaim: ComplimentaryRequestClaim?,
failure: Throwable,
): Unit = withContext(NonCancellable) {
runCatching { credits.release(reservation.id) }
val released = if (complimentaryClaim != null) {
runCatching { complimentaryRequests.release(complimentaryClaim) }
} else {
runCatching { credits.release(requireNotNull(reservation).id) }
}
released
.onFailure(failure::addSuppressed)
}
@@ -290,7 +376,8 @@ data class PreparedGatewayRequest(
val request: ProviderRequest,
val provider: GatewayProvider,
val estimate: ProviderUsageEstimate,
val reservation: CreditReservation,
val reservation: CreditReservation?,
val complimentaryClaim: ComplimentaryRequestClaim?,
)
class GatewayReconciliationService(
@@ -333,5 +420,22 @@ class GatewayRefundService(
class GatewayUsagePolicyException(message: String) : RuntimeException(message)
class ComplimentaryRequestUnavailableException(
val purpose: GatewayRequestPurpose,
) : RuntimeException("Complimentary ${purpose.name.lowercase()} request is unavailable")
class GatewayAccessDeniedException(capability: GatewayCapability) :
RuntimeException("Gateway grant does not allow ${capability.name.lowercase()}")
private object NoComplimentaryRequests : ComplimentaryRequestPort {
override suspend fun claim(
accountId: String,
purpose: GatewayRequestPurpose,
capability: GatewayCapability,
requestId: String,
): ComplimentaryRequestClaim? = null
override suspend fun consume(claim: ComplimentaryRequestClaim) = Unit
override suspend fun release(claim: ComplimentaryRequestClaim) = Unit
}