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
}
+1
View File
@@ -32,6 +32,7 @@ app:
tombstoneRetentionDays: "$IDENTITY_TOMBSTONE_RETENTION_DAYS:365"
admin:
enabled: "$ADMIN_ENABLED:false"
mtlsRequired: "$ADMIN_MTLS_REQUIRED:true"
bootstrapEnabled: "$ADMIN_BOOTSTRAP_ENABLED:false"
bootstrapOperatorId: "$ADMIN_BOOTSTRAP_OPERATOR_ID:"
bootstrapUsername: "$ADMIN_BOOTSTRAP_USERNAME:"
@@ -0,0 +1,21 @@
ALTER TABLE provider_requests
ADD COLUMN request_purpose VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NULL
AFTER request_source,
ADD INDEX idx_provider_requests_purpose_created (request_purpose, created_at);
CREATE TABLE gateway_complimentary_requests (
account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
purpose VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
capability VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
request_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
status VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
expires_at TIMESTAMP(6) NOT NULL,
created_at TIMESTAMP(6) NOT NULL,
updated_at TIMESTAMP(6) NOT NULL,
PRIMARY KEY (account_id, purpose, capability),
INDEX idx_gateway_complimentary_expiry (status, expires_at),
CONSTRAINT fk_gateway_complimentary_account
FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE,
CONSTRAINT chk_gateway_complimentary_status
CHECK (status IN ('CLAIMED', 'CONSUMED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -17,6 +17,7 @@ class AppConfigTest : FunSpec({
config.credits.signupTrial shouldBe 1_000
config.credits.referralInviter shouldBe 1_000
config.credits.referralInvitee shouldBe 1_000
config.admin.mtlsRequired shouldBe true
}
test("production rejects placeholder secrets") {
@@ -68,6 +69,14 @@ class AppConfigTest : FunSpec({
admin.bootstrapTotpSecretBase32 shouldBe null
}
test("administrator mTLS can be disabled explicitly") {
val config = validConfig("test").apply {
put("app.admin.mtlsRequired", "false")
}
AppConfig.from(config).admin.mtlsRequired shouldBe false
}
test("admin bootstrap cannot be enabled while admin routes are disabled") {
val config = validProductionConfig().apply {
put("app.admin.enabled", "false")
@@ -169,6 +169,7 @@ class DeploymentConsistencyTest : FunSpec({
val smokePrivileges = root.read("deploy/smoke/runtime-grants.sql")
compose shouldContain "ADMIN_BOOTSTRAP_ENABLED: \${ADMIN_BOOTSTRAP_ENABLED:-false}"
compose shouldContain "ADMIN_MTLS_REQUIRED: \${ADMIN_MTLS_REQUIRED:-true}"
privileges shouldContain "GRANT SELECT ON osg_account.admin_operators"
privileges shouldContain "GRANT INSERT, UPDATE ON osg_account.admin_operators"
privileges shouldContain "GRANT SELECT ON osg_account.admin_sessions"
@@ -177,6 +178,9 @@ class DeploymentConsistencyTest : FunSpec({
privileges shouldContain "GRANT INSERT ON osg_account.gateway_grant_scopes"
privileges shouldContain "GRANT SELECT ON osg_account.gateway_refresh_tokens"
privileges shouldContain "GRANT INSERT, UPDATE ON osg_account.gateway_refresh_tokens"
privileges shouldContain "GRANT SELECT ON osg_account.gateway_complimentary_requests"
privileges shouldContain
"GRANT INSERT, UPDATE, DELETE ON osg_account.gateway_complimentary_requests"
privileges shouldContain "GRANT SELECT ON osg_account.account_profiles"
privileges shouldContain "GRANT INSERT, UPDATE ON osg_account.account_profiles"
privileges shouldContain "GRANT INSERT ON osg_account.admin_audit_log"
@@ -206,6 +210,8 @@ class DeploymentConsistencyTest : FunSpec({
test("OpenResty proxies HTTP WebSocket invitations and both AASA paths safely") {
val openResty = root.read("deploy/openresty-account.conf")
openResty shouldContain "proxy_set_header X-OSG-mTLS-Verified \$ssl_client_verify;"
openResty shouldNotContain "proxy_set_header X-OSG-mTLS-Verified \"SUCCESS\";"
openResty shouldContain "proxy_set_header Upgrade \$http_upgrade;"
openResty shouldContain "proxy_set_header Connection \$connection_upgrade;"
openResty shouldContain "location = /.well-known/apple-app-site-association"
@@ -37,7 +37,7 @@ class SmokeDeploymentTest : FunSpec({
runner shouldContain "APPLE_JWKS_URL=http://127.0.0.1:9/"
runner shouldContain "VOLCENGINE_ASR_ENDPOINT=ws://127.0.0.1:9/"
runner shouldContain "DEEPSEEK_ENDPOINT=http://127.0.0.1:9/"
runner shouldContain "Flyway history was not exactly successful V1-V12"
runner shouldContain "Flyway history was not exactly successful V1-V17"
runner shouldContain "default referral rewards were not 1000 credits for both accounts"
runner shouldContain "active smaller credit rates did not match the V10 contract"
runner shouldContain "first ledger page omitted nextCursor"
@@ -51,7 +51,7 @@ class SmokeDeploymentTest : FunSpec({
test("runtime grants cover every migrated table without mutable history privileges") {
val grants = root.read("deploy/smoke/runtime-grants.sql")
val migrationTables = (1..16)
val migrationTables = (1..17)
.flatMap { version ->
val migration = Files.list(root.resolve("src/main/resources/db/migration")).use { paths ->
paths.filter { it.fileName.toString().startsWith("V${version}__") }
@@ -69,6 +69,8 @@ class SmokeDeploymentTest : FunSpec({
grantedTables.sorted() shouldContainExactly migrationTables.sorted()
grants shouldContain "GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.admin_sessions"
grants shouldContain
"GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.gateway_complimentary_requests"
grants shouldNotContain "UPDATE ON osg_account_smoke.credit_ledger"
grants shouldNotContain "DELETE ON osg_account_smoke.credit_ledger"
grants shouldNotContain "UPDATE ON osg_account_smoke.admin_audit_log"
@@ -68,6 +68,16 @@ class AdminRoutesTest {
response.bodyAsText() shouldContain """"authenticated":false"""
}
@Test
fun `disabled mTLS allows anonymous session check without edge header`() = testApplication {
application { installAdminTestRoutes(mtlsRequired = false) }
val response = client.get("/v1/admin/auth/session")
assertEquals(HttpStatusCode.OK, response.status)
response.bodyAsText() shouldContain """"authenticated":false"""
}
@Test
fun `authenticated session exposes role for client-side capability navigation`() = testApplication {
application {
@@ -103,9 +113,20 @@ class AdminRoutesTest {
}
@Test
fun `admin web resources are embedded`() = testApplication {
fun `admin web resources are hidden when mTLS is required`() = testApplication {
application {
routing { adminWebRoutes() }
routing { adminWebRoutes(adminTestConfig()) }
}
val response = client.get("/admin/")
assertEquals(HttpStatusCode.NotFound, response.status)
}
@Test
fun `admin web resources are embedded when mTLS is disabled`() = testApplication {
application {
routing { adminWebRoutes(adminTestConfig(mtlsRequired = false)) }
}
val response = client.get("/admin/")
@@ -296,6 +317,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes(
operatorService: AdminOperatorService = mockk(relaxed = true),
auditService: AdminAuditService = mockk(relaxed = true),
usersService: AdminUsersService = mockk(relaxed = true),
mtlsRequired: Boolean = true,
) {
install(ContentNegotiation) {
json(Json { explicitNulls = false })
@@ -305,11 +327,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes(
rateLimiter(limit = 20, refillPeriod = 1.minutes)
}
}
val config = mockk<AppConfig> {
every { publicBaseUrl } returns "https://account.osglab.com"
every { isProduction } returns false
every { admin } returns AdminConfig()
}
val config = adminTestConfig(mtlsRequired)
routing {
adminApiRoutes(
config = config,
@@ -325,6 +343,12 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes(
}
}
private fun adminTestConfig(mtlsRequired: Boolean = true) = mockk<AppConfig> {
every { publicBaseUrl } returns "https://account.osglab.com"
every { isProduction } returns false
every { admin } returns AdminConfig(mtlsRequired = mtlsRequired)
}
private fun grantRouteFixture(
failure: RuntimeException,
): Pair<AdminSessionService, AdminGrantService> {
@@ -0,0 +1,135 @@
package com.osglab.account.features.gateway.repositories
import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayRequestPurpose
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import java.sql.DriverManager
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import org.opentest4j.TestAbortedException
import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
class GatewayComplimentaryRepositoryIntegrationTest : FunSpec({
test("complimentary claims are atomic consumed once and releasable before success") {
withGatewayDatabase { config, databaseFactory ->
val now = Instant.parse("2026-08-20T08:00:00Z")
val consumedAccount = UUID.randomUUID()
val releasedAccount = UUID.randomUUID()
insertAccounts(config, listOf(consumedAccount, releasedAccount), now)
val repository = ExposedGatewayRepository(
databaseFactory,
Clock.fixed(now, ZoneOffset.UTC),
)
val concurrentClaims = coroutineScope {
(1..8).map { index ->
async(Dispatchers.Default) {
repository.claim(
accountId = consumedAccount.toString(),
purpose = GatewayRequestPurpose.OOBE,
capability = GatewayCapability.POLISH,
requestId = "concurrent-oobe-$index",
)
}
}.awaitAll()
}
val winningClaim = concurrentClaims.filterNotNull().single()
repository.consume(winningClaim)
repository.release(winningClaim)
repository.claim(
accountId = consumedAccount.toString(),
purpose = GatewayRequestPurpose.OOBE,
capability = GatewayCapability.POLISH,
requestId = "consumed-replay",
) shouldBe null
val releasedClaim = repository.claim(
accountId = releasedAccount.toString(),
purpose = GatewayRequestPurpose.OOBE,
capability = GatewayCapability.POLISH,
requestId = "released-first-attempt",
)
releasedClaim shouldNotBe null
repository.release(requireNotNull(releasedClaim))
repository.claim(
accountId = releasedAccount.toString(),
purpose = GatewayRequestPurpose.OOBE,
capability = GatewayCapability.POLISH,
requestId = "released-retry",
) shouldNotBe null
}
}
})
private suspend fun withGatewayDatabase(
block: suspend (DatabaseConfig, DatabaseFactory) -> Unit,
) {
val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank)
if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) {
throw TestAbortedException("Docker is unavailable; MySQL integration test skipped")
}
val mysql = if (externalJdbcUrl == null) {
GatewayMySqlContainer("mysql:8.4")
.withDatabaseName("osg_gateway_complimentary_test")
.withUsername("test")
.withPassword("test")
.also(GatewayMySqlContainer::start)
} else {
null
}
val config = DatabaseConfig(
jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl,
username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
?: mysql?.username
?: "root",
password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "",
maximumPoolSize = 8,
)
val databaseFactory = DatabaseFactory(config)
try {
databaseFactory.database
block(config, databaseFactory)
} finally {
databaseFactory.close()
mysql?.stop()
}
}
private fun insertAccounts(
config: DatabaseConfig,
accountIds: List<UUID>,
now: Instant,
) {
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
connection.prepareStatement(
"""
INSERT INTO accounts (id, apple_sub, created_at, updated_at)
VALUES (?, ?, ?, ?)
""".trimIndent(),
).use { statement ->
accountIds.forEach { accountId ->
statement.setString(1, accountId.toString())
statement.setString(2, "gateway-test-$accountId")
statement.setTimestamp(3, java.sql.Timestamp.from(now))
statement.setTimestamp(4, java.sql.Timestamp.from(now))
statement.addBatch()
}
statement.executeBatch()
}
}
}
private class GatewayMySqlContainer(image: String) :
MySQLContainer<GatewayMySqlContainer>(image)
@@ -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.GatewayRequestPurpose
import com.osglab.account.features.gateway.models.GatewayRequestSource
import com.osglab.account.features.gateway.models.ProviderDescriptor
import com.osglab.account.features.gateway.models.ProviderOutput
@@ -11,9 +12,13 @@ import com.osglab.account.features.gateway.models.TextProviderRequest
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.ports.CreditMeterPort
import com.osglab.account.features.gateway.ports.CreditReservation
import com.osglab.account.features.gateway.ports.ComplimentaryRequestClaim
import com.osglab.account.features.gateway.ports.ComplimentaryRequestPort
import com.osglab.account.features.gateway.ports.GatewayRequestAlreadyClaimedException
import com.osglab.account.features.gateway.ports.GatewayUsagePort
import com.osglab.account.features.gateway.ports.PendingSettlement
import com.osglab.account.features.gateway.ports.ProviderRequestMetadata
import com.osglab.account.features.gateway.ports.ProviderRequestState
import com.osglab.account.features.gateway.ports.ProviderUsageEstimate
import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCatalog
@@ -80,6 +85,75 @@ class GatewayServiceBillingTest : StringSpec({
usageRecords.lastClaim?.requestSource shouldBe GatewayRequestSource.HOTWORD
}
"executes one OOBE dictation polish without reserving or settling credits" {
val credits = FakeCredits()
val complimentary = FakeComplimentaryRequests()
val usageRecords = FakeUsageRecords()
val service = service(
credits = credits,
provider = FakeProvider(capability = GatewayCapability.POLISH),
usageRecords = usageRecords,
complimentaryRequests = complimentary,
)
service.execute(
PRINCIPAL.copy(scopes = setOf(GatewayCapability.POLISH)),
oobeRequest(),
DISCARD_OUTPUT,
)
credits.reserveCalls shouldBe 0
credits.settled shouldBe emptyList()
credits.released shouldBe emptyList()
complimentary.consumed.size shouldBe 1
complimentary.released shouldBe emptyList()
usageRecords.lastClaim?.reservationId shouldBe null
usageRecords.lastClaim?.requestPurpose shouldBe GatewayRequestPurpose.OOBE
}
"rejects a second OOBE polish without falling through to paid billing" {
val credits = FakeCredits()
val complimentary = FakeComplimentaryRequests(available = false)
val service = service(
credits = credits,
provider = FakeProvider(capability = GatewayCapability.POLISH),
complimentaryRequests = complimentary,
)
shouldThrow<ComplimentaryRequestUnavailableException> {
service.execute(
PRINCIPAL.copy(scopes = setOf(GatewayCapability.POLISH)),
oobeRequest(),
DISCARD_OUTPUT,
)
}
credits.reserveCalls shouldBe 0
}
"releases a newly acquired OOBE claim when the request id is a replay" {
val complimentary = FakeComplimentaryRequests()
val service = service(
credits = FakeCredits(),
provider = FakeProvider(capability = GatewayCapability.POLISH),
usageRecords = FakeUsageRecords(
claimFailure = GatewayRequestAlreadyClaimedException(ProviderRequestState.RELEASED),
),
complimentaryRequests = complimentary,
)
shouldThrow<GatewayRequestAlreadyClaimedException> {
service.execute(
PRINCIPAL.copy(scopes = setOf(GatewayCapability.POLISH)),
oobeRequest(),
DISCARD_OUTPUT,
)
}
complimentary.consumed shouldBe emptyList()
complimentary.released.size shouldBe 1
}
"uses one reservation when a buffered DeepSeek empty result succeeds on retry" {
val credits = FakeCredits()
var attempts = 0
@@ -210,11 +284,13 @@ private fun service(
credits: CreditMeterPort,
provider: GatewayProvider,
usageRecords: GatewayUsagePort = FakeUsageRecords(),
complimentaryRequests: ComplimentaryRequestPort = FakeComplimentaryRequests(available = false),
): GatewayService = GatewayService(
catalog = ProviderCatalog(listOf(provider)),
credits = credits,
grants = { _, _ -> true },
usageRecords = usageRecords,
complimentaryRequests = complimentaryRequests,
)
private fun request(requestSource: GatewayRequestSource? = null): TextProviderRequest {
@@ -236,6 +312,25 @@ private fun request(requestSource: GatewayRequestSource? = null): TextProviderRe
)
}
private fun oobeRequest(): TextProviderRequest {
val executionPolicy = GatewayTaskPolicyResolver().resolve(
GatewayCapability.POLISH,
requestedTaskKind = null,
requestedMaxOutputTokens = 32,
)
return TextProviderRequest(
requestId = "oobe-request-123",
capability = GatewayCapability.POLISH,
executionPolicy = executionPolicy,
input = "hello",
context = null,
maxOutputTokens = executionPolicy.maxOutputTokens,
temperature = 0.2,
stream = false,
requestPurpose = GatewayRequestPurpose.OOBE,
)
}
private class FakeCredits(
private val failSettle: Boolean = false,
private val idempotent: Boolean = false,
@@ -286,10 +381,11 @@ private class FakeCredits(
private class FakeProvider(
private val fail: Boolean = false,
capability: GatewayCapability = GatewayCapability.AI,
) : GatewayProvider {
override val descriptor = ProviderDescriptor(
id = "mock-deepseek",
capabilities = setOf(GatewayCapability.AI),
capabilities = setOf(capability),
streaming = true,
usageMeter = UsageMeter.LLM_TOKEN,
)
@@ -300,6 +396,29 @@ private class FakeProvider(
}
}
private class FakeComplimentaryRequests(
private val available: Boolean = true,
) : ComplimentaryRequestPort {
val consumed = mutableListOf<ComplimentaryRequestClaim>()
val released = mutableListOf<ComplimentaryRequestClaim>()
override suspend fun claim(
accountId: String,
purpose: GatewayRequestPurpose,
capability: GatewayCapability,
requestId: String,
): ComplimentaryRequestClaim? =
if (available) ComplimentaryRequestClaim(accountId, purpose, capability, requestId) else null
override suspend fun consume(claim: ComplimentaryRequestClaim) {
consumed += claim
}
override suspend fun release(claim: ComplimentaryRequestClaim) {
released += claim
}
}
private class EmptyResultProvider : GatewayProvider {
override val descriptor = ProviderDescriptor(
id = "empty-provider",
@@ -315,10 +434,12 @@ private class EmptyResultProvider : GatewayProvider {
private class FakeUsageRecords(
private val pending: MutableList<PendingSettlement> = mutableListOf(),
private val claimFailure: RuntimeException? = null,
) : GatewayUsagePort {
var lastClaim: ProviderRequestMetadata? = null
override suspend fun claim(metadata: ProviderRequestMetadata) {
claimFailure?.let { throw it }
lastClaim = metadata
}
override suspend fun markStarted(accountId: String, requestId: String) = Unit