Add complimentary OOBE polish and configurable admin mTLS
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:
@@ -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(
|
||||
|
||||
+93
-1
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user