Add runtime provider controls and searchable AI routing
Manage provider keys at runtime, route current-information questions through server-side search with safe fallback, and scope OOBE usage claims to grants.
This commit is contained in:
@@ -88,6 +88,12 @@ import com.osglab.account.features.gateway.adapters.CreditReservationAdapter
|
||||
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.credentials.DatabaseProviderApiKeyResolver
|
||||
import com.osglab.account.features.gateway.credentials.EnvironmentProviderCredentials
|
||||
import com.osglab.account.features.gateway.credentials.ExposedGatewayCredentialRepository
|
||||
import com.osglab.account.features.gateway.credentials.GatewayCredentialRepository
|
||||
import com.osglab.account.features.gateway.credentials.GatewayCredentialService
|
||||
import com.osglab.account.features.gateway.credentials.ProviderApiKeyResolver
|
||||
import com.osglab.account.features.gateway.ports.CreditReservationPort
|
||||
import com.osglab.account.features.gateway.ports.ComplimentaryRequestPort
|
||||
import com.osglab.account.features.gateway.ports.GatewayAccessTokenPort
|
||||
@@ -272,7 +278,11 @@ fun Application.module() {
|
||||
val providerConfig = appConfig.providers.volcengine.toProviderConfig()
|
||||
AsrStreamingService(
|
||||
gateway = koin.get(),
|
||||
upstream = KtorVolcengineAsrTransport(koin.get(), providerConfig),
|
||||
upstream = KtorVolcengineAsrTransport(
|
||||
client = koin.get(),
|
||||
config = providerConfig,
|
||||
credentialResolver = koin.get(),
|
||||
),
|
||||
scope = this,
|
||||
)
|
||||
} else {
|
||||
@@ -367,6 +377,7 @@ fun Application.module() {
|
||||
grantService = koin.get(),
|
||||
operatorService = koin.get(),
|
||||
auditService = koin.get(),
|
||||
credentialService = koin.get(),
|
||||
contentService = koin.get(),
|
||||
hintFeedService = koin.get(),
|
||||
)
|
||||
@@ -399,6 +410,19 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
single { SessionJwt(config.session) }
|
||||
single { FieldEncryptor(config.encryption.key) }
|
||||
single { IdentityFingerprint(config.antiAbuse.identityHmacKey) }
|
||||
single<GatewayCredentialRepository> { ExposedGatewayCredentialRepository(get()) }
|
||||
single {
|
||||
EnvironmentProviderCredentials(
|
||||
deepSeekApiKey = config.providers.deepSeek.apiKey,
|
||||
volcengineApiKey = config.providers.volcengine.apiKey,
|
||||
volcengineLegacyConfigured =
|
||||
!config.providers.volcengine.appId.isNullOrBlank() &&
|
||||
!config.providers.volcengine.accessToken.isNullOrBlank(),
|
||||
)
|
||||
}
|
||||
single { DatabaseProviderApiKeyResolver(get(), get(), get()) }
|
||||
single<ProviderApiKeyResolver> { get<DatabaseProviderApiKeyResolver>() }
|
||||
single { GatewayCredentialService(get(), get(), get()) }
|
||||
single<AdminRepository> { ExposedAdminRepository(get()) }
|
||||
single<AdminPasswordHasher> { BouncyCastleArgon2idPasswordHasher() }
|
||||
single<AdminTotpVerifier> { HmacTotpVerifier() }
|
||||
@@ -667,7 +691,7 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
)
|
||||
}
|
||||
single {
|
||||
ProviderCatalog(configuredProviders(config, get()))
|
||||
ProviderCatalog(configuredProviders(config, get(), get()))
|
||||
}
|
||||
single { GatewayService(get(), get(), get(), get(), get(), get()) }
|
||||
single { GatewayReconciliationService(get(), get()) }
|
||||
@@ -680,7 +704,11 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
}
|
||||
}
|
||||
|
||||
private fun configuredProviders(config: AppConfig, client: HttpClient): List<GatewayProvider> =
|
||||
private fun configuredProviders(
|
||||
config: AppConfig,
|
||||
client: HttpClient,
|
||||
credentialResolver: ProviderApiKeyResolver,
|
||||
): List<GatewayProvider> =
|
||||
buildList {
|
||||
config.providers.deepSeek.apiKey?.let { apiKey ->
|
||||
add(
|
||||
@@ -692,12 +720,21 @@ private fun configuredProviders(config: AppConfig, client: HttpClient): List<Gat
|
||||
model = config.providers.deepSeek.model,
|
||||
reasoningModel = config.providers.deepSeek.reasoningModel,
|
||||
),
|
||||
credentialResolver = credentialResolver,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (config.providers.volcengine.credentialsAvailable) {
|
||||
val providerConfig = config.providers.volcengine.toProviderConfig()
|
||||
add(VolcengineAsrProvider(KtorVolcengineAsrTransport(client, providerConfig)))
|
||||
add(
|
||||
VolcengineAsrProvider(
|
||||
KtorVolcengineAsrTransport(
|
||||
client = client,
|
||||
config = providerConfig,
|
||||
credentialResolver = credentialResolver,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ enum class AdminAuditAction {
|
||||
CONTENT_HINT_PACK_SAVED,
|
||||
CONTENT_HINT_FEED_SETTINGS_UPDATED,
|
||||
CONTENT_HINT_FEED_GENERATED,
|
||||
PROVIDER_API_KEY_UPDATED,
|
||||
}
|
||||
|
||||
enum class AdminAuditOutcome {
|
||||
|
||||
@@ -47,6 +47,9 @@ import com.osglab.account.features.credits.domain.InvalidCreditRequest
|
||||
import com.osglab.account.features.credits.domain.LedgerEntryType
|
||||
import com.osglab.account.features.content.services.ContentService
|
||||
import com.osglab.account.features.content.feed.HintFeedService
|
||||
import com.osglab.account.features.gateway.credentials.GatewayCredentialProvider
|
||||
import com.osglab.account.features.gateway.credentials.GatewayCredentialService
|
||||
import com.osglab.account.features.gateway.credentials.InvalidProviderApiKeyException
|
||||
import io.ktor.http.Cookie
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
@@ -65,6 +68,7 @@ import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.delete
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.put
|
||||
import io.ktor.server.routing.route
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.Clock
|
||||
@@ -93,6 +97,7 @@ fun Route.adminApiRoutes(
|
||||
grantService: AdminGrantService,
|
||||
operatorService: AdminOperatorService,
|
||||
auditService: AdminAuditService,
|
||||
credentialService: GatewayCredentialService? = null,
|
||||
contentService: ContentService? = null,
|
||||
hintFeedService: HintFeedService? = null,
|
||||
clock: Clock = Clock.systemUTC(),
|
||||
@@ -174,6 +179,46 @@ fun Route.adminApiRoutes(
|
||||
adminContentRoutes(config, sessionService, it, hintFeedService)
|
||||
}
|
||||
|
||||
credentialService?.let { service ->
|
||||
get("/providers") {
|
||||
if (
|
||||
call.requireRole(
|
||||
config,
|
||||
sessionService,
|
||||
setOf(AdminRole.SUPER_ADMIN),
|
||||
) == null
|
||||
) return@get
|
||||
call.respond(service.listStatuses())
|
||||
}
|
||||
|
||||
put("/providers/{providerId}/api-key") {
|
||||
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@put
|
||||
if (principal.role != AdminRole.SUPER_ADMIN) {
|
||||
call.respond(HttpStatusCode.Forbidden, AdminErrorResponse("INSUFFICIENT_PERMISSION"))
|
||||
return@put
|
||||
}
|
||||
val provider = GatewayCredentialProvider.fromProviderId(
|
||||
call.parameters["providerId"],
|
||||
) ?: run {
|
||||
call.respond(HttpStatusCode.NotFound, AdminErrorResponse("PROVIDER_NOT_FOUND"))
|
||||
return@put
|
||||
}
|
||||
val request = call.receiveAdminRequest<ProviderApiKeyUpdateRequest>() ?: return@put
|
||||
val status = try {
|
||||
service.updateApiKey(
|
||||
provider = provider,
|
||||
apiKey = request.apiKey,
|
||||
operatorId = principal.operatorId,
|
||||
requestId = call.request.header("X-Request-ID"),
|
||||
)
|
||||
} catch (_: InvalidProviderApiKeyException) {
|
||||
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
|
||||
return@put
|
||||
}
|
||||
call.respond(status)
|
||||
}
|
||||
}
|
||||
|
||||
get("/overview") {
|
||||
if (call.requirePrincipal(config, sessionService) == null) return@get
|
||||
val stats = statsService.getRange(call.request.queryParameters["range"], clock)
|
||||
@@ -1098,6 +1143,9 @@ private data class AdminGrantRequest(val userId: String, val amount: Long, val r
|
||||
@Serializable
|
||||
private data class AdminGrantResponse(val transactionId: String, val balanceAfter: Long)
|
||||
|
||||
@Serializable
|
||||
private data class ProviderApiKeyUpdateRequest(val apiKey: String)
|
||||
|
||||
@Serializable
|
||||
private data class AdminOperatorCreateRequest(
|
||||
val username: String,
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.osglab.account.features.gateway.credentials
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
enum class GatewayCredentialProvider(val providerId: String) {
|
||||
DEEPSEEK("deepseek"),
|
||||
VOLCENGINE("volcengine");
|
||||
|
||||
companion object {
|
||||
fun fromProviderId(value: String?): GatewayCredentialProvider? =
|
||||
entries.firstOrNull { it.providerId == value }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class GatewayCredentialSource {
|
||||
ENVIRONMENT,
|
||||
RUNTIME_OVERRIDE,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class GatewayCredentialStatus(
|
||||
val providerId: String,
|
||||
val configured: Boolean,
|
||||
val source: GatewayCredentialSource,
|
||||
val updatedAt: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Persistence-only encrypted value. Its string representation deliberately
|
||||
* excludes ciphertext so authenticated encryption material cannot reach logs.
|
||||
*/
|
||||
class ProviderApiKeyOverride(
|
||||
val provider: GatewayCredentialProvider,
|
||||
val encryptedApiKey: String,
|
||||
val updatedAt: Instant,
|
||||
val updatedByOperatorId: UUID,
|
||||
) {
|
||||
override fun toString(): String =
|
||||
"ProviderApiKeyOverride(provider=${provider.providerId}, updatedAt=$updatedAt, " +
|
||||
"updatedByOperatorId=$updatedByOperatorId)"
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.osglab.account.features.gateway.credentials
|
||||
|
||||
import com.osglab.account.config.DatabaseFactory
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.admin.repositories.AdminAuditLogTable
|
||||
import org.jetbrains.exposed.v1.core.ResultRow
|
||||
import org.jetbrains.exposed.v1.core.Table
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.javatime.timestamp
|
||||
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.util.UUID
|
||||
|
||||
internal object GatewayProviderCredentialsTable : Table("gateway_provider_credentials") {
|
||||
val providerId = varchar("provider_id", 32)
|
||||
val encryptedApiKey = text("encrypted_api_key")
|
||||
val updatedAt = timestamp("updated_at")
|
||||
val updatedByOperatorId = varchar("updated_by_operator_id", 36)
|
||||
override val primaryKey = PrimaryKey(providerId)
|
||||
}
|
||||
|
||||
interface GatewayCredentialRepository {
|
||||
suspend fun findOverride(provider: GatewayCredentialProvider): ProviderApiKeyOverride?
|
||||
|
||||
/**
|
||||
* Stores the current override and its successful audit event atomically.
|
||||
*/
|
||||
suspend fun upsertOverride(
|
||||
credentialOverride: ProviderApiKeyOverride,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
)
|
||||
}
|
||||
|
||||
class ExposedGatewayCredentialRepository(
|
||||
private val databaseFactory: DatabaseFactory,
|
||||
) : GatewayCredentialRepository {
|
||||
override suspend fun findOverride(
|
||||
provider: GatewayCredentialProvider,
|
||||
): ProviderApiKeyOverride? = databaseFactory.query {
|
||||
GatewayProviderCredentialsTable.selectAll()
|
||||
.where { GatewayProviderCredentialsTable.providerId eq provider.providerId }
|
||||
.limit(1)
|
||||
.singleOrNull()
|
||||
?.toOverride()
|
||||
}
|
||||
|
||||
override suspend fun upsertOverride(
|
||||
credentialOverride: ProviderApiKeyOverride,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
) {
|
||||
databaseFactory.query {
|
||||
val inserted = GatewayProviderCredentialsTable.insertIgnore {
|
||||
it[providerId] = credentialOverride.provider.providerId
|
||||
it[encryptedApiKey] = credentialOverride.encryptedApiKey
|
||||
it[updatedAt] = credentialOverride.updatedAt
|
||||
it[updatedByOperatorId] = credentialOverride.updatedByOperatorId.toString()
|
||||
}.insertedCount > 0
|
||||
if (!inserted) {
|
||||
GatewayProviderCredentialsTable.update({
|
||||
GatewayProviderCredentialsTable.providerId eq credentialOverride.provider.providerId
|
||||
}) {
|
||||
it[encryptedApiKey] = credentialOverride.encryptedApiKey
|
||||
it[updatedAt] = credentialOverride.updatedAt
|
||||
it[updatedByOperatorId] = credentialOverride.updatedByOperatorId.toString()
|
||||
}
|
||||
}
|
||||
AdminAuditLogTable.insert {
|
||||
it[id] = auditEvent.id.toString()
|
||||
it[actorOperatorId] = auditEvent.actorOperatorId?.toString()
|
||||
it[action] = auditEvent.action.name
|
||||
it[outcome] = auditEvent.outcome.name
|
||||
it[targetType] = auditEvent.targetType
|
||||
it[targetId] = auditEvent.targetId
|
||||
it[requestId] = auditEvent.requestId
|
||||
it[occurredAt] = auditEvent.occurredAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ResultRow.toOverride(): ProviderApiKeyOverride = ProviderApiKeyOverride(
|
||||
provider = requireNotNull(
|
||||
GatewayCredentialProvider.fromProviderId(this[GatewayProviderCredentialsTable.providerId]),
|
||||
),
|
||||
encryptedApiKey = this[GatewayProviderCredentialsTable.encryptedApiKey],
|
||||
updatedAt = this[GatewayProviderCredentialsTable.updatedAt],
|
||||
updatedByOperatorId = UUID.fromString(this[GatewayProviderCredentialsTable.updatedByOperatorId]),
|
||||
)
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.osglab.account.features.gateway.credentials
|
||||
|
||||
import com.osglab.account.common.security.FieldEncryptor
|
||||
import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import java.time.Clock
|
||||
import java.util.UUID
|
||||
|
||||
class InvalidProviderApiKeyException : IllegalArgumentException("Invalid provider API key")
|
||||
|
||||
class GatewayCredentialService(
|
||||
private val repository: GatewayCredentialRepository,
|
||||
private val resolver: DatabaseProviderApiKeyResolver,
|
||||
private val fieldEncryptor: FieldEncryptor,
|
||||
private val clock: Clock = Clock.systemUTC(),
|
||||
) {
|
||||
suspend fun listStatuses(): List<GatewayCredentialStatus> =
|
||||
GatewayCredentialProvider.entries.map { resolver.status(it) }
|
||||
|
||||
suspend fun updateApiKey(
|
||||
provider: GatewayCredentialProvider,
|
||||
apiKey: String,
|
||||
operatorId: UUID,
|
||||
requestId: String?,
|
||||
): GatewayCredentialStatus {
|
||||
val normalized = apiKey.trim()
|
||||
if (
|
||||
normalized.isEmpty() ||
|
||||
normalized.length > MAX_API_KEY_LENGTH ||
|
||||
apiKey.contains('\r') ||
|
||||
apiKey.contains('\n')
|
||||
) {
|
||||
throw InvalidProviderApiKeyException()
|
||||
}
|
||||
val now = clock.instant()
|
||||
repository.upsertOverride(
|
||||
credentialOverride = ProviderApiKeyOverride(
|
||||
provider = provider,
|
||||
encryptedApiKey = fieldEncryptor.encrypt(normalized, encryptionContext(provider)),
|
||||
updatedAt = now,
|
||||
updatedByOperatorId = operatorId,
|
||||
),
|
||||
auditEvent = NewAdminAuditEvent(
|
||||
actorOperatorId = operatorId,
|
||||
action = AdminAuditAction.PROVIDER_API_KEY_UPDATED,
|
||||
outcome = AdminAuditOutcome.SUCCESS,
|
||||
targetType = "PROVIDER",
|
||||
targetId = provider.providerId,
|
||||
requestId = requestId,
|
||||
occurredAt = now,
|
||||
),
|
||||
)
|
||||
return resolver.status(provider)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_API_KEY_LENGTH = 4_096
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.osglab.account.features.gateway.credentials
|
||||
|
||||
import com.osglab.account.common.security.FieldEncryptor
|
||||
|
||||
fun interface ProviderApiKeyResolver {
|
||||
/**
|
||||
* Resolves the effective API key at the start of a new upstream request.
|
||||
*/
|
||||
suspend fun resolve(provider: GatewayCredentialProvider): String?
|
||||
}
|
||||
|
||||
class EnvironmentProviderCredentials(
|
||||
val deepSeekApiKey: String?,
|
||||
val volcengineApiKey: String?,
|
||||
val volcengineLegacyConfigured: Boolean,
|
||||
) {
|
||||
fun apiKey(provider: GatewayCredentialProvider): String? =
|
||||
when (provider) {
|
||||
GatewayCredentialProvider.DEEPSEEK -> deepSeekApiKey
|
||||
GatewayCredentialProvider.VOLCENGINE -> volcengineApiKey
|
||||
}?.trim()?.takeIf(String::isNotEmpty)
|
||||
|
||||
fun configured(provider: GatewayCredentialProvider): Boolean =
|
||||
apiKey(provider) != null ||
|
||||
(provider == GatewayCredentialProvider.VOLCENGINE && volcengineLegacyConfigured)
|
||||
|
||||
override fun toString(): String =
|
||||
"EnvironmentProviderCredentials(deepSeekConfigured=${!deepSeekApiKey.isNullOrBlank()}, " +
|
||||
"volcengineApiKeyConfigured=${!volcengineApiKey.isNullOrBlank()}, " +
|
||||
"volcengineLegacyConfigured=$volcengineLegacyConfigured)"
|
||||
}
|
||||
|
||||
class DatabaseProviderApiKeyResolver(
|
||||
private val repository: GatewayCredentialRepository,
|
||||
private val fieldEncryptor: FieldEncryptor,
|
||||
private val environment: EnvironmentProviderCredentials,
|
||||
) : ProviderApiKeyResolver {
|
||||
override suspend fun resolve(provider: GatewayCredentialProvider): String? =
|
||||
repository.findOverride(provider)?.let {
|
||||
fieldEncryptor.decrypt(it.encryptedApiKey, encryptionContext(provider))
|
||||
} ?: environment.apiKey(provider)
|
||||
|
||||
suspend fun status(provider: GatewayCredentialProvider): GatewayCredentialStatus {
|
||||
val runtimeOverride = repository.findOverride(provider)
|
||||
return if (runtimeOverride != null) {
|
||||
GatewayCredentialStatus(
|
||||
providerId = provider.providerId,
|
||||
configured = true,
|
||||
source = GatewayCredentialSource.RUNTIME_OVERRIDE,
|
||||
updatedAt = runtimeOverride.updatedAt.toString(),
|
||||
)
|
||||
} else {
|
||||
GatewayCredentialStatus(
|
||||
providerId = provider.providerId,
|
||||
configured = environment.configured(provider),
|
||||
source = GatewayCredentialSource.ENVIRONMENT,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class StaticProviderApiKeyResolver(
|
||||
deepSeekApiKey: String? = null,
|
||||
volcengineApiKey: String? = null,
|
||||
) : ProviderApiKeyResolver {
|
||||
private val apiKeys = mapOf(
|
||||
GatewayCredentialProvider.DEEPSEEK to deepSeekApiKey,
|
||||
GatewayCredentialProvider.VOLCENGINE to volcengineApiKey,
|
||||
)
|
||||
|
||||
override suspend fun resolve(provider: GatewayCredentialProvider): String? =
|
||||
apiKeys[provider]?.trim()?.takeIf(String::isNotEmpty)
|
||||
}
|
||||
|
||||
internal fun encryptionContext(provider: GatewayCredentialProvider): String =
|
||||
"gateway-provider-api-key:${provider.providerId}"
|
||||
@@ -109,6 +109,9 @@ enum class GatewayTaskKind {
|
||||
@SerialName("ai_question")
|
||||
AI_QUESTION,
|
||||
|
||||
@SerialName("current_information_question")
|
||||
CURRENT_INFORMATION_QUESTION,
|
||||
|
||||
@SerialName("clipboard_transform")
|
||||
CLIPBOARD_TRANSFORM,
|
||||
|
||||
@@ -167,6 +170,12 @@ data class GatewayTaskExecutionPolicy(
|
||||
) {
|
||||
"reasoning effort must be explicit exactly when thinking is enabled"
|
||||
}
|
||||
require(
|
||||
webSearch == GatewayWebSearchMode.DISABLED ||
|
||||
thinking == GatewayThinkingMode.ENABLED,
|
||||
) {
|
||||
"web search requires an explicit thinking policy"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+85
-32
@@ -1,10 +1,14 @@
|
||||
package com.osglab.account.features.gateway.providers.deepseek
|
||||
|
||||
import com.osglab.account.features.gateway.agent.AgentPlan
|
||||
import com.osglab.account.features.gateway.credentials.GatewayCredentialProvider
|
||||
import com.osglab.account.features.gateway.credentials.ProviderApiKeyResolver
|
||||
import com.osglab.account.features.gateway.credentials.StaticProviderApiKeyResolver
|
||||
import com.osglab.account.features.gateway.models.GatewayCapability
|
||||
import com.osglab.account.features.gateway.models.GatewayLimits
|
||||
import com.osglab.account.features.gateway.models.GatewayModelProfile
|
||||
import com.osglab.account.features.gateway.models.GatewayReasoningEffort
|
||||
import com.osglab.account.features.gateway.models.GatewayTaskKind
|
||||
import com.osglab.account.features.gateway.models.GatewayThinkingMode
|
||||
import com.osglab.account.features.gateway.models.GatewayToolsMode
|
||||
import com.osglab.account.features.gateway.models.GatewayWebSearchMode
|
||||
@@ -75,6 +79,19 @@ fun interface DeepSeekClient {
|
||||
suspend fun complete(request: TextProviderRequest, output: ProviderOutput): ProviderUsage
|
||||
}
|
||||
|
||||
private fun createDeepSeekClient(
|
||||
client: HttpClient,
|
||||
config: DeepSeekConfig,
|
||||
json: Json,
|
||||
credentialResolver: ProviderApiKeyResolver,
|
||||
): DeepSeekClient {
|
||||
val chat = KtorDeepSeekClient(client, config, json, credentialResolver)
|
||||
return DeepSeekSearchFallbackClient(
|
||||
search = KtorDeepSeekResponsesClient(client, config, json, credentialResolver),
|
||||
fallback = chat,
|
||||
)
|
||||
}
|
||||
|
||||
class DeepSeekProvider(
|
||||
private val upstream: DeepSeekClient,
|
||||
) : GatewayProvider {
|
||||
@@ -85,7 +102,9 @@ class DeepSeekProvider(
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
},
|
||||
) : this(KtorDeepSeekClient(client, config, json))
|
||||
credentialResolver: ProviderApiKeyResolver =
|
||||
StaticProviderApiKeyResolver(deepSeekApiKey = config.apiKey),
|
||||
) : this(createDeepSeekClient(client, config, json, credentialResolver))
|
||||
|
||||
override val descriptor = ProviderDescriptor(
|
||||
id = "deepseek",
|
||||
@@ -145,8 +164,14 @@ class DeepSeekProvider(
|
||||
require(request.maxOutputTokens == request.executionPolicy.maxOutputTokens) {
|
||||
"maxOutputTokens must match the server execution policy"
|
||||
}
|
||||
require(request.executionPolicy.webSearch == GatewayWebSearchMode.DISABLED) {
|
||||
"DeepSeek web search is not configured"
|
||||
require(
|
||||
request.executionPolicy.webSearch == GatewayWebSearchMode.DISABLED ||
|
||||
(
|
||||
request.capability == GatewayCapability.AI &&
|
||||
request.executionPolicy.thinking == GatewayThinkingMode.ENABLED
|
||||
)
|
||||
) {
|
||||
"DeepSeek web search is supported only for reasoning AI requests"
|
||||
}
|
||||
require(request.executionPolicy.tools == GatewayToolsMode.DISABLED) {
|
||||
"DeepSeek tools are not configured"
|
||||
@@ -176,11 +201,18 @@ class KtorDeepSeekClient(
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
},
|
||||
private val credentialResolver: ProviderApiKeyResolver =
|
||||
StaticProviderApiKeyResolver(deepSeekApiKey = config.apiKey),
|
||||
) : DeepSeekClient {
|
||||
override suspend fun complete(
|
||||
request: TextProviderRequest,
|
||||
output: ProviderOutput,
|
||||
): ProviderUsage {
|
||||
require(request.executionPolicy.webSearch == GatewayWebSearchMode.DISABLED) {
|
||||
"Chat Completions cannot execute server-side web search"
|
||||
}
|
||||
val apiKey = credentialResolver.resolve(GatewayCredentialProvider.DEEPSEEK)
|
||||
?: throw DeepSeekConfigurationException("DeepSeek API key is not configured")
|
||||
val payload = DeepSeekChatRequest(
|
||||
model = config.modelFor(request.executionPolicy.modelProfile),
|
||||
messages = controlledMessages(request),
|
||||
@@ -203,7 +235,7 @@ class KtorDeepSeekClient(
|
||||
)
|
||||
|
||||
return client.preparePost("${config.endpoint.trimEnd('/')}/chat/completions") {
|
||||
bearerAuth(config.apiKey)
|
||||
bearerAuth(apiKey)
|
||||
contentType(ContentType.Application.Json)
|
||||
header(HttpHeaders.Accept, if (request.stream) ContentType.Text.EventStream else ContentType.Application.Json)
|
||||
header("X-Request-ID", request.requestId)
|
||||
@@ -491,34 +523,11 @@ class KtorDeepSeekClient(
|
||||
return bytes
|
||||
}
|
||||
|
||||
private fun controlledMessages(request: TextProviderRequest): List<ChatMessage> {
|
||||
val system = when (request.capability) {
|
||||
GatewayCapability.POLISH ->
|
||||
"Polish the user's text while preserving meaning. Return only the polished text."
|
||||
|
||||
GatewayCapability.AI ->
|
||||
"Answer the user's question accurately and concisely. Do not claim actions you did not perform."
|
||||
|
||||
GatewayCapability.AGENT ->
|
||||
"""
|
||||
Return only JSON with this schema:
|
||||
{"summary":"string","steps":[{"id":"string","title":"string","description":"string"}],"warnings":["string"]}.
|
||||
Produce a declarative plan only. Never execute actions, invoke tools, include commands or URLs,
|
||||
or claim that any client-side or external side effect occurred.
|
||||
""".trimIndent()
|
||||
|
||||
GatewayCapability.ASR -> error("ASR is not a DeepSeek capability")
|
||||
}
|
||||
val userText = buildString {
|
||||
request.context?.takeIf(String::isNotBlank)?.let {
|
||||
append("Context:\n")
|
||||
append(it)
|
||||
append("\n\n")
|
||||
}
|
||||
append(request.input)
|
||||
}
|
||||
return listOf(ChatMessage("system", system), ChatMessage("user", userText))
|
||||
}
|
||||
private fun controlledMessages(request: TextProviderRequest): List<ChatMessage> =
|
||||
listOf(
|
||||
ChatMessage("system", deepSeekSystemInstruction(request)),
|
||||
ChatMessage("user", deepSeekUserText(request)),
|
||||
)
|
||||
|
||||
private fun GatewayReasoningEffort.toDeepSeekReasoningEffort(): DeepSeekReasoningEffort =
|
||||
when (this) {
|
||||
@@ -539,6 +548,50 @@ class KtorDeepSeekClient(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun deepSeekSystemInstruction(request: TextProviderRequest): String =
|
||||
when (request.capability) {
|
||||
GatewayCapability.POLISH ->
|
||||
"Polish the user's text while preserving meaning. Return only the polished text."
|
||||
|
||||
GatewayCapability.AI -> buildString {
|
||||
append("Answer the user's question accurately and concisely. ")
|
||||
append("Do not claim actions you did not perform.")
|
||||
if (request.executionPolicy.webSearch == GatewayWebSearchMode.DISABLED &&
|
||||
request.executionPolicy.taskKind in SEARCHABLE_AI_TASKS
|
||||
) {
|
||||
append(
|
||||
" Web search is temporarily unavailable. Do not present time-sensitive " +
|
||||
"information as current; clearly state that it could not be verified.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
GatewayCapability.AGENT ->
|
||||
"""
|
||||
Return only JSON with this schema:
|
||||
{"summary":"string","steps":[{"id":"string","title":"string","description":"string"}],"warnings":["string"]}.
|
||||
Produce a declarative plan only. Never execute actions, invoke tools, include commands or URLs,
|
||||
or claim that any client-side or external side effect occurred.
|
||||
""".trimIndent()
|
||||
|
||||
GatewayCapability.ASR -> error("ASR is not a DeepSeek capability")
|
||||
}
|
||||
|
||||
internal fun deepSeekUserText(request: TextProviderRequest): String =
|
||||
buildString {
|
||||
request.context?.takeIf(String::isNotBlank)?.let {
|
||||
append("Context:\n")
|
||||
append(it)
|
||||
append("\n\n")
|
||||
}
|
||||
append(request.input)
|
||||
}
|
||||
|
||||
private val SEARCHABLE_AI_TASKS = setOf(
|
||||
GatewayTaskKind.AI_QUESTION,
|
||||
GatewayTaskKind.CURRENT_INFORMATION_QUESTION,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class DeepSeekChatRequest(
|
||||
val model: String,
|
||||
|
||||
+356
@@ -0,0 +1,356 @@
|
||||
package com.osglab.account.features.gateway.providers.deepseek
|
||||
|
||||
import com.osglab.account.features.gateway.credentials.GatewayCredentialProvider
|
||||
import com.osglab.account.features.gateway.credentials.ProviderApiKeyResolver
|
||||
import com.osglab.account.features.gateway.credentials.StaticProviderApiKeyResolver
|
||||
import com.osglab.account.features.gateway.models.GatewayLimits
|
||||
import com.osglab.account.features.gateway.models.GatewayWebSearchMode
|
||||
import com.osglab.account.features.gateway.models.ProviderOutput
|
||||
import com.osglab.account.features.gateway.models.ProviderUsage
|
||||
import com.osglab.account.features.gateway.models.TextProviderRequest
|
||||
import com.osglab.account.features.gateway.models.UsageMeter
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.bearerAuth
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.preparePost
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.utils.io.ByteReadChannel
|
||||
import io.ktor.utils.io.readRemaining
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.io.readByteArray
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
/**
|
||||
* Uses DeepSeek Responses only when server policy permits web search. The
|
||||
* search attempt is fully buffered so a failure can safely fall back without
|
||||
* mixing two answers in a downstream stream.
|
||||
*/
|
||||
internal class DeepSeekSearchFallbackClient(
|
||||
private val search: DeepSeekClient,
|
||||
private val fallback: DeepSeekClient,
|
||||
) : DeepSeekClient {
|
||||
override suspend fun complete(
|
||||
request: TextProviderRequest,
|
||||
output: ProviderOutput,
|
||||
): ProviderUsage {
|
||||
if (request.executionPolicy.webSearch == GatewayWebSearchMode.DISABLED) {
|
||||
return fallback.complete(request, output)
|
||||
}
|
||||
|
||||
val buffered = mutableListOf<ByteArray>()
|
||||
var bufferedBytes = 0L
|
||||
val usage = try {
|
||||
search.complete(
|
||||
request,
|
||||
ProviderOutput { bytes ->
|
||||
bufferedBytes = Math.addExact(bufferedBytes, bytes.size.toLong())
|
||||
if (bufferedBytes > GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES) {
|
||||
throw DeepSeekProviderException("DeepSeek search output exceeded the gateway limit")
|
||||
}
|
||||
buffered += bytes
|
||||
},
|
||||
)
|
||||
} catch (failure: CancellationException) {
|
||||
throw failure
|
||||
} catch (failure: Exception) {
|
||||
LOG.warn(
|
||||
"DeepSeek search path failed requestId={} taskKind={} searchMode={} failureType={}",
|
||||
request.requestId,
|
||||
request.executionPolicy.taskKind.name,
|
||||
request.executionPolicy.webSearch.name,
|
||||
failure::class.simpleName ?: "Exception",
|
||||
)
|
||||
return fallback.complete(
|
||||
request.copy(
|
||||
executionPolicy = request.executionPolicy.copy(
|
||||
webSearch = GatewayWebSearchMode.DISABLED,
|
||||
),
|
||||
),
|
||||
output,
|
||||
)
|
||||
}
|
||||
|
||||
buffered.forEach { output.emit(it) }
|
||||
return usage
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val LOG = LoggerFactory.getLogger(DeepSeekSearchFallbackClient::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
internal class KtorDeepSeekResponsesClient(
|
||||
private val client: HttpClient,
|
||||
private val config: DeepSeekConfig,
|
||||
private val json: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
},
|
||||
private val credentialResolver: ProviderApiKeyResolver =
|
||||
StaticProviderApiKeyResolver(deepSeekApiKey = config.apiKey),
|
||||
) : DeepSeekClient {
|
||||
override suspend fun complete(
|
||||
request: TextProviderRequest,
|
||||
output: ProviderOutput,
|
||||
): ProviderUsage {
|
||||
val searchMode = request.executionPolicy.webSearch
|
||||
require(searchMode != GatewayWebSearchMode.DISABLED) {
|
||||
"Responses search requires an enabled server search policy"
|
||||
}
|
||||
val reasoningEffort = requireNotNull(request.executionPolicy.reasoningEffort) {
|
||||
"Responses search requires explicit reasoning effort"
|
||||
}
|
||||
val apiKey = credentialResolver.resolve(GatewayCredentialProvider.DEEPSEEK)
|
||||
?: throw DeepSeekConfigurationException("DeepSeek API key is not configured")
|
||||
val payload = DeepSeekResponsesRequest(
|
||||
model = config.modelFor(request.executionPolicy.modelProfile),
|
||||
instructions = deepSeekSystemInstruction(request),
|
||||
input = listOf(DeepSeekResponsesMessage("user", deepSeekUserText(request))),
|
||||
tools = listOf(DeepSeekResponsesTool("web_search")),
|
||||
toolChoice = when (searchMode) {
|
||||
GatewayWebSearchMode.ALLOWED -> JsonPrimitive("auto")
|
||||
GatewayWebSearchMode.REQUIRED -> buildJsonObject { put("type", "web_search") }
|
||||
GatewayWebSearchMode.DISABLED -> error("Search policy changed during request construction")
|
||||
},
|
||||
maxOutputTokens = request.maxOutputTokens,
|
||||
reasoning = DeepSeekResponsesReasoning(reasoningEffort.name.lowercase()),
|
||||
)
|
||||
|
||||
return client.preparePost("${config.endpoint.trimEnd('/')}/responses") {
|
||||
bearerAuth(apiKey)
|
||||
contentType(ContentType.Application.Json)
|
||||
header(HttpHeaders.Accept, ContentType.Application.Json)
|
||||
header("X-Request-ID", request.requestId)
|
||||
setBody(payload)
|
||||
}.execute { response ->
|
||||
if (!response.status.isSuccess()) {
|
||||
runCatching { response.body<ByteReadChannel>().readBounded() }
|
||||
throw DeepSeekProviderException(
|
||||
"DeepSeek Responses returned HTTP ${response.status.value}",
|
||||
)
|
||||
}
|
||||
val responseContentType = response.headers[HttpHeaders.ContentType]
|
||||
?.let { runCatching { ContentType.parse(it) }.getOrNull() }
|
||||
if (responseContentType?.match(ContentType.Application.Json) != true) {
|
||||
runCatching { response.body<ByteReadChannel>().readBounded() }
|
||||
throw DeepSeekProviderException(
|
||||
"DeepSeek Responses returned an unexpected content type",
|
||||
)
|
||||
}
|
||||
|
||||
val result = parseResponse(response.body<ByteReadChannel>().readBounded())
|
||||
if (searchMode == GatewayWebSearchMode.REQUIRED && !result.webSearchUsed) {
|
||||
throw DeepSeekProviderException("DeepSeek omitted required web search")
|
||||
}
|
||||
emitCompatibleResponse(request, result, output)
|
||||
result.usage
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseResponse(bytes: ByteArray): DeepSeekResponsesResult {
|
||||
val root = runCatching { json.parseToJsonElement(bytes.decodeToString()).jsonObject }
|
||||
.getOrElse { throw DeepSeekProviderException("DeepSeek returned malformed Responses JSON") }
|
||||
if (root["error"] != null && root["error"] !is JsonNull) {
|
||||
throw DeepSeekProviderException("DeepSeek Responses returned an error")
|
||||
}
|
||||
val output = runCatching { root["output"]?.jsonArray ?: emptyList() }
|
||||
.getOrElse { throw DeepSeekProviderException("DeepSeek returned invalid Responses output") }
|
||||
val topLevelText = runCatching {
|
||||
root["output_text"]?.jsonPrimitive?.takeIf { it.isString }?.content
|
||||
}.getOrNull()
|
||||
val messageText = buildString {
|
||||
output.forEach { itemElement ->
|
||||
val item = runCatching { itemElement.jsonObject }.getOrNull() ?: return@forEach
|
||||
if (item.string("type") != "message") return@forEach
|
||||
val content = runCatching { item["content"]?.jsonArray ?: emptyList() }
|
||||
.getOrElse {
|
||||
throw DeepSeekProviderException("DeepSeek returned invalid message content")
|
||||
}
|
||||
content.forEach { partElement ->
|
||||
val part = runCatching { partElement.jsonObject }.getOrNull() ?: return@forEach
|
||||
if (part.string("type") in RESPONSE_TEXT_TYPES) {
|
||||
part.string("text")?.let(::append)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val text = topLevelText?.takeIf(String::isNotBlank) ?: messageText
|
||||
if (text.isBlank()) {
|
||||
throw DeepSeekProviderException("DeepSeek Responses omitted output text")
|
||||
}
|
||||
val webSearchUsed = output.any { item ->
|
||||
runCatching { item.jsonObject.string("type") == "web_search_call" }.getOrDefault(false)
|
||||
}
|
||||
val usageObject = runCatching { root["usage"]?.jsonObject }
|
||||
.getOrNull()
|
||||
?: throw DeepSeekProviderException("DeepSeek Responses omitted token usage")
|
||||
val inputTokens = usageObject.long("input_tokens")
|
||||
?: throw DeepSeekProviderException("DeepSeek Responses omitted input token usage")
|
||||
val outputTokens = usageObject.long("output_tokens")
|
||||
?: throw DeepSeekProviderException("DeepSeek Responses omitted output token usage")
|
||||
val computedTotal = runCatching { Math.addExact(inputTokens, outputTokens) }
|
||||
.getOrElse { throw DeepSeekUsageException("DeepSeek Responses token usage overflowed") }
|
||||
val totalTokens = usageObject.long("total_tokens") ?: computedTotal
|
||||
if (inputTokens < 0 || outputTokens < 0 || totalTokens != computedTotal) {
|
||||
throw DeepSeekUsageException("DeepSeek Responses returned inconsistent token usage")
|
||||
}
|
||||
|
||||
return DeepSeekResponsesResult(
|
||||
text = text,
|
||||
webSearchUsed = webSearchUsed,
|
||||
usage = ProviderUsage(
|
||||
meter = UsageMeter.LLM_TOKEN,
|
||||
units = totalTokens,
|
||||
inputUnits = inputTokens,
|
||||
outputUnits = outputTokens,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun emitCompatibleResponse(
|
||||
request: TextProviderRequest,
|
||||
result: DeepSeekResponsesResult,
|
||||
output: ProviderOutput,
|
||||
) {
|
||||
if (!request.stream) {
|
||||
output.emit(bufferedChatPayload(result).encodeToByteArray())
|
||||
return
|
||||
}
|
||||
val content = buildJsonObject {
|
||||
put(
|
||||
"choices",
|
||||
buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put(
|
||||
"delta",
|
||||
buildJsonObject { put("content", result.text) },
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
val terminal = buildJsonObject {
|
||||
put(
|
||||
"choices",
|
||||
buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("delta", buildJsonObject {})
|
||||
put("finish_reason", "stop")
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
val usage = buildJsonObject {
|
||||
put("choices", buildJsonArray {})
|
||||
put("usage", usageJson(result.usage))
|
||||
}
|
||||
output.emit("data: $content\n\n".encodeToByteArray())
|
||||
output.emit("data: $terminal\n\n".encodeToByteArray())
|
||||
output.emit("data: $usage\n\n".encodeToByteArray())
|
||||
output.emit("data: [DONE]\n\n".encodeToByteArray())
|
||||
}
|
||||
|
||||
private fun bufferedChatPayload(result: DeepSeekResponsesResult): String =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"choices",
|
||||
buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put(
|
||||
"message",
|
||||
buildJsonObject { put("content", result.text) },
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
put("usage", usageJson(result.usage))
|
||||
}.toString()
|
||||
|
||||
private fun usageJson(usage: ProviderUsage): JsonObject =
|
||||
buildJsonObject {
|
||||
put("prompt_tokens", requireNotNull(usage.inputUnits))
|
||||
put("completion_tokens", requireNotNull(usage.outputUnits))
|
||||
put("total_tokens", usage.units)
|
||||
}
|
||||
|
||||
private suspend fun ByteReadChannel.readBounded(): ByteArray {
|
||||
val bytes = readRemaining(GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES.toLong() + 1L)
|
||||
.readByteArray()
|
||||
if (bytes.size > GatewayLimits.MAX_UPSTREAM_RESPONSE_BYTES) {
|
||||
throw DeepSeekProviderException("DeepSeek Responses exceeded the gateway limit")
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val RESPONSE_TEXT_TYPES = setOf("output_text", "text")
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.string(name: String): String? =
|
||||
runCatching {
|
||||
get(name)?.jsonPrimitive?.takeIf { it.isString }?.content
|
||||
}.getOrNull()
|
||||
|
||||
private fun JsonObject.long(name: String): Long? =
|
||||
runCatching { get(name)?.jsonPrimitive?.longOrNull }.getOrNull()
|
||||
|
||||
private data class DeepSeekResponsesResult(
|
||||
val text: String,
|
||||
val webSearchUsed: Boolean,
|
||||
val usage: ProviderUsage,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class DeepSeekResponsesRequest(
|
||||
val model: String,
|
||||
val instructions: String,
|
||||
val input: List<DeepSeekResponsesMessage>,
|
||||
val tools: List<DeepSeekResponsesTool>,
|
||||
@SerialName("tool_choice")
|
||||
val toolChoice: JsonElement,
|
||||
@SerialName("max_output_tokens")
|
||||
val maxOutputTokens: Int,
|
||||
val reasoning: DeepSeekResponsesReasoning,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class DeepSeekResponsesMessage(
|
||||
val role: String,
|
||||
val content: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class DeepSeekResponsesTool(
|
||||
val type: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class DeepSeekResponsesReasoning(
|
||||
val effort: String,
|
||||
)
|
||||
+8
-1
@@ -1,5 +1,8 @@
|
||||
package com.osglab.account.features.gateway.providers.volcengine
|
||||
|
||||
import com.osglab.account.features.gateway.credentials.GatewayCredentialProvider
|
||||
import com.osglab.account.features.gateway.credentials.ProviderApiKeyResolver
|
||||
import com.osglab.account.features.gateway.credentials.StaticProviderApiKeyResolver
|
||||
import com.osglab.account.features.gateway.models.AsrGatewayOptions
|
||||
import com.osglab.account.features.gateway.models.AsrProviderRequest
|
||||
import com.osglab.account.features.gateway.models.GatewayCapability
|
||||
@@ -103,6 +106,8 @@ class KtorVolcengineAsrTransport(
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
},
|
||||
private val credentialResolver: ProviderApiKeyResolver =
|
||||
StaticProviderApiKeyResolver(volcengineApiKey = config.apiKey),
|
||||
) : VolcengineAsrTransport, VolcengineStreamingClient {
|
||||
override suspend fun transcribe(
|
||||
request: AsrProviderRequest,
|
||||
@@ -131,6 +136,9 @@ class KtorVolcengineAsrTransport(
|
||||
var outputBytes = 0L
|
||||
var frameCount = 0
|
||||
val sequenceValidator = SaucSequenceValidator()
|
||||
// Resolve once per connection so an in-flight stream keeps the key it started with.
|
||||
val apiKey = credentialResolver.resolve(GatewayCredentialProvider.VOLCENGINE)
|
||||
?.takeIf(String::isNotBlank)
|
||||
|
||||
withTimeout(config.responseTimeoutMillis) {
|
||||
client.webSocket(
|
||||
@@ -140,7 +148,6 @@ class KtorVolcengineAsrTransport(
|
||||
headers.append("X-Api-Request-Id", providerRequestId)
|
||||
headers.append("X-Api-Connect-Id", providerRequestId)
|
||||
headers.append("X-Api-Sequence", "-1")
|
||||
val apiKey = config.apiKey?.takeIf(String::isNotBlank)
|
||||
if (apiKey != null) {
|
||||
headers.append("X-Api-Key", apiKey)
|
||||
} else {
|
||||
|
||||
@@ -458,7 +458,7 @@ private suspend fun ApplicationCall.respondGatewayFailure(
|
||||
is OobeFeatureAlreadyUsedException -> respondGatewayError(
|
||||
HttpStatusCode.Conflict,
|
||||
"oobe_feature_already_used",
|
||||
"This OOBE feature has already been used successfully",
|
||||
"This OOBE feature has already been used successfully in this session",
|
||||
requestId,
|
||||
)
|
||||
|
||||
|
||||
+12
-1
@@ -61,12 +61,21 @@ class GatewayTaskPolicyResolver(
|
||||
GatewayTaskKind.AI_QUESTION -> reasoningPolicy(
|
||||
taskKind = taskKind,
|
||||
effort = config.aiReasoningEffort,
|
||||
webSearch = GatewayWebSearchMode.ALLOWED,
|
||||
maxOutputTokens = minOf(requestedMaxOutputTokens, config.reasoningMaxOutputTokens),
|
||||
)
|
||||
|
||||
GatewayTaskKind.CURRENT_INFORMATION_QUESTION -> reasoningPolicy(
|
||||
taskKind = taskKind,
|
||||
effort = config.aiReasoningEffort,
|
||||
webSearch = GatewayWebSearchMode.REQUIRED,
|
||||
maxOutputTokens = minOf(requestedMaxOutputTokens, config.reasoningMaxOutputTokens),
|
||||
)
|
||||
|
||||
GatewayTaskKind.AGENT_PLANNING -> reasoningPolicy(
|
||||
taskKind = taskKind,
|
||||
effort = config.agentReasoningEffort,
|
||||
webSearch = GatewayWebSearchMode.DISABLED,
|
||||
maxOutputTokens = minOf(requestedMaxOutputTokens, config.reasoningMaxOutputTokens),
|
||||
)
|
||||
}
|
||||
@@ -89,13 +98,14 @@ class GatewayTaskPolicyResolver(
|
||||
private fun reasoningPolicy(
|
||||
taskKind: GatewayTaskKind,
|
||||
effort: GatewayReasoningEffort,
|
||||
webSearch: GatewayWebSearchMode,
|
||||
maxOutputTokens: Int,
|
||||
) = GatewayTaskExecutionPolicy(
|
||||
taskKind = taskKind,
|
||||
modelProfile = GatewayModelProfile.REASONING,
|
||||
thinking = GatewayThinkingMode.ENABLED,
|
||||
reasoningEffort = effort,
|
||||
webSearch = GatewayWebSearchMode.DISABLED,
|
||||
webSearch = webSearch,
|
||||
tools = GatewayToolsMode.DISABLED,
|
||||
allowEmptyContentRetry = true,
|
||||
maxOutputTokens = maxOutputTokens,
|
||||
@@ -125,6 +135,7 @@ class GatewayTaskPolicyResolver(
|
||||
)
|
||||
val AI_TASKS = setOf(
|
||||
GatewayTaskKind.AI_QUESTION,
|
||||
GatewayTaskKind.CURRENT_INFORMATION_QUESTION,
|
||||
GatewayTaskKind.CLIPBOARD_TRANSFORM,
|
||||
GatewayTaskKind.CUSTOM_SKILL,
|
||||
)
|
||||
|
||||
+11
-1
@@ -1,6 +1,7 @@
|
||||
package com.osglab.account.features.gateway.services
|
||||
|
||||
import com.osglab.account.features.gateway.models.AsrProviderRequest
|
||||
import com.osglab.account.features.gateway.models.GatewayWebSearchMode
|
||||
import com.osglab.account.features.gateway.models.ProviderRequest
|
||||
import com.osglab.account.features.gateway.models.TextProviderRequest
|
||||
import com.osglab.account.features.gateway.models.UsageMeter
|
||||
@@ -29,10 +30,18 @@ object ConservativeGatewayUsageEstimator : GatewayUsageEstimator {
|
||||
// covers server-controlled system messages and chat framing.
|
||||
val inputBytes = request.input.encodeToByteArray().size.toLong()
|
||||
val contextBytes = request.context?.encodeToByteArray()?.size?.toLong() ?: 0L
|
||||
val input = Math.addExact(
|
||||
val requestInput = Math.addExact(
|
||||
Math.addExact(inputBytes, contextBytes),
|
||||
LLM_PROMPT_OVERHEAD_TOKENS,
|
||||
)
|
||||
// Responses web search injects provider-controlled result context that
|
||||
// is included in input-token usage. Reserve a bounded allowance so
|
||||
// settlement remains token-based without charging a separate search fee.
|
||||
val input = if (request.executionPolicy.webSearch == GatewayWebSearchMode.DISABLED) {
|
||||
requestInput
|
||||
} else {
|
||||
Math.addExact(requestInput, WEB_SEARCH_INPUT_TOKEN_ALLOWANCE)
|
||||
}
|
||||
val output = request.maxOutputTokens.toLong()
|
||||
return ProviderUsageEstimate(
|
||||
meter = UsageMeter.LLM_TOKEN,
|
||||
@@ -43,4 +52,5 @@ object ConservativeGatewayUsageEstimator : GatewayUsageEstimator {
|
||||
}
|
||||
|
||||
private const val LLM_PROMPT_OVERHEAD_TOKENS = 256L
|
||||
private const val WEB_SEARCH_INPUT_TOKEN_ALLOWANCE = 32_000L
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ private object OobeRefreshTokensTable : Table("oobe_gateway_refresh_tokens") {
|
||||
}
|
||||
|
||||
private object OobeClaimsTable : Table("oobe_gateway_claims") {
|
||||
val grantId = varchar("grant_id", 36)
|
||||
val subjectId = varchar("subject_id", 36)
|
||||
val feature = varchar("feature", 32)
|
||||
val requestId = varchar("request_id", 64)
|
||||
@@ -59,7 +60,7 @@ private object OobeClaimsTable : Table("oobe_gateway_claims") {
|
||||
val expiresAt = timestamp("expires_at")
|
||||
val createdAt = timestamp("created_at")
|
||||
val updatedAt = timestamp("updated_at")
|
||||
override val primaryKey = PrimaryKey(subjectId, feature)
|
||||
override val primaryKey = PrimaryKey(grantId, feature)
|
||||
}
|
||||
|
||||
private object OobeProviderRequestsTable : Table("oobe_provider_requests") {
|
||||
@@ -236,7 +237,7 @@ class ExposedOobeRepository(
|
||||
expiresAt: Instant,
|
||||
now: Instant,
|
||||
): OobeRequestClaim? = databaseFactory.query {
|
||||
val key = claimKey(request.subjectId, request.feature.name)
|
||||
val key = claimKey(request.grantId, request.feature.name)
|
||||
val reclaimed = OobeClaimsTable.update({
|
||||
key and
|
||||
(OobeClaimsTable.status eq CLAIMED) and
|
||||
@@ -247,6 +248,7 @@ class ExposedOobeRepository(
|
||||
it[updatedAt] = now
|
||||
} == 1
|
||||
val inserted = !reclaimed && OobeClaimsTable.insertIgnore {
|
||||
it[grantId] = request.grantId
|
||||
it[subjectId] = request.subjectId
|
||||
it[feature] = request.feature.name
|
||||
it[requestId] = request.requestId
|
||||
@@ -269,7 +271,7 @@ class ExposedOobeRepository(
|
||||
it[createdAt] = now
|
||||
}.insertedCount == 1
|
||||
if (!auditInserted) throw OobeRequestAlreadyClaimedException()
|
||||
OobeRequestClaim(request.subjectId, request.feature, request.requestId)
|
||||
OobeRequestClaim(request.subjectId, request.grantId, request.feature, request.requestId)
|
||||
}
|
||||
|
||||
override suspend fun markStarted(claim: OobeRequestClaim) {
|
||||
@@ -280,7 +282,7 @@ class ExposedOobeRepository(
|
||||
databaseFactory.query {
|
||||
val now = clock.instant()
|
||||
val claimChanged = OobeClaimsTable.update({
|
||||
claimKey(claim.subjectId, claim.feature.name) and
|
||||
claimKey(claim.grantId, claim.feature.name) and
|
||||
(OobeClaimsTable.requestId eq claim.requestId) and
|
||||
(OobeClaimsTable.status eq CLAIMED)
|
||||
}) {
|
||||
@@ -308,7 +310,7 @@ class ExposedOobeRepository(
|
||||
override suspend fun release(claim: OobeRequestClaim, errorCode: String) {
|
||||
databaseFactory.query {
|
||||
OobeClaimsTable.deleteWhere {
|
||||
claimKey(claim.subjectId, claim.feature.name) and
|
||||
claimKey(claim.grantId, claim.feature.name) and
|
||||
(OobeClaimsTable.requestId eq claim.requestId) and
|
||||
(OobeClaimsTable.status eq CLAIMED)
|
||||
}
|
||||
@@ -332,7 +334,7 @@ class ExposedOobeRepository(
|
||||
// Fail closed: an uncertain provider outcome must never become
|
||||
// reclaimable after the temporary claim TTL.
|
||||
OobeClaimsTable.update({
|
||||
claimKey(claim.subjectId, claim.feature.name) and
|
||||
claimKey(claim.grantId, claim.feature.name) and
|
||||
(OobeClaimsTable.requestId eq claim.requestId) and
|
||||
(OobeClaimsTable.status eq CLAIMED)
|
||||
}) {
|
||||
@@ -377,8 +379,8 @@ private fun org.jetbrains.exposed.v1.core.ResultRow.toStoredRefresh(grant: OobeG
|
||||
expiresAt = this[OobeRefreshTokensTable.expiresAt],
|
||||
)
|
||||
|
||||
private fun claimKey(subjectId: String, feature: String) =
|
||||
(OobeClaimsTable.subjectId eq subjectId) and (OobeClaimsTable.feature eq feature)
|
||||
private fun claimKey(grantId: String, feature: String) =
|
||||
(OobeClaimsTable.grantId eq grantId) and (OobeClaimsTable.feature eq feature)
|
||||
|
||||
private fun requestKey(claim: OobeRequestClaim) =
|
||||
(OobeProviderRequestsTable.subjectId eq claim.subjectId) and
|
||||
|
||||
@@ -103,6 +103,7 @@ sealed interface OobeRefreshRotationResult {
|
||||
|
||||
data class OobeRequestClaim(
|
||||
val subjectId: String,
|
||||
val grantId: String,
|
||||
val feature: OobeFeature,
|
||||
val requestId: String,
|
||||
)
|
||||
|
||||
@@ -39,4 +39,4 @@ class OobeRequestAlreadyClaimedException :
|
||||
RuntimeException("The OOBE provider request ID has already been used")
|
||||
|
||||
class OobeFeatureAlreadyUsedException(val feature: com.osglab.account.features.gateway.models.OobeFeature) :
|
||||
RuntimeException("The OOBE feature ${feature.name.lowercase()} has already been used")
|
||||
RuntimeException("The OOBE feature ${feature.name.lowercase()} has already been used in this grant")
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
DROP TABLE oobe_gateway_claims;
|
||||
|
||||
CREATE TABLE oobe_gateway_claims (
|
||||
grant_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
subject_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
feature 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 (grant_id, feature),
|
||||
INDEX idx_oobe_claim_subject (subject_id),
|
||||
INDEX idx_oobe_claim_expiry (status, expires_at),
|
||||
CONSTRAINT fk_oobe_claim_grant
|
||||
FOREIGN KEY (grant_id) REFERENCES oobe_gateway_grants (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_oobe_claim_subject
|
||||
FOREIGN KEY (subject_id) REFERENCES oobe_subjects (id) ON DELETE CASCADE,
|
||||
CONSTRAINT chk_oobe_claim_feature
|
||||
CHECK (feature IN ('VOICE_INPUT', 'CLIPBOARD_TRANSLATE', 'CLIPBOARD_REPLY', 'ASK_AI')),
|
||||
CONSTRAINT chk_oobe_claim_status
|
||||
CHECK (status IN ('CLAIMED', 'CONSUMED'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE gateway_provider_credentials (
|
||||
provider_id VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
encrypted_api_key TEXT NOT NULL,
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updated_by_operator_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
PRIMARY KEY (provider_id),
|
||||
CONSTRAINT fk_gateway_provider_credentials_operator
|
||||
FOREIGN KEY (updated_by_operator_id) REFERENCES admin_operators (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT chk_gateway_provider_credentials_provider
|
||||
CHECK (provider_id IN ('deepseek', 'volcengine')),
|
||||
CONSTRAINT chk_gateway_provider_credentials_ciphertext
|
||||
CHECK (CHAR_LENGTH(encrypted_api_key) > 0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
@@ -238,6 +238,20 @@ class DeploymentConsistencyTest : FunSpec({
|
||||
keyboardSchema shouldNotContain "hostApplication"
|
||||
}
|
||||
|
||||
test("anonymous OOBE feature claims are scoped to each guided session") {
|
||||
val migration = root.read(
|
||||
"src/main/resources/db/migration/V27__scope_oobe_claims_to_grant.sql",
|
||||
)
|
||||
val openApi = root.read("docs/openapi.yaml")
|
||||
|
||||
migration shouldContain "PRIMARY KEY (grant_id, feature)"
|
||||
migration shouldContain
|
||||
"FOREIGN KEY (grant_id) REFERENCES oobe_gateway_grants (id) ON DELETE CASCADE"
|
||||
migration shouldNotContain "PRIMARY KEY (subject_id, feature)"
|
||||
openApi shouldContain "Each feature can"
|
||||
openApi shouldContain "succeed once within this short-lived grant"
|
||||
}
|
||||
|
||||
test("production Compose reuses private MySQL and hardens the application container") {
|
||||
val compose = root.read("compose.yaml")
|
||||
|
||||
@@ -404,6 +418,8 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
|
||||
"/v1/admin/auth/session",
|
||||
"/v1/admin/auth/login",
|
||||
"/v1/admin/auth/logout",
|
||||
"/v1/admin/providers",
|
||||
"/v1/admin/providers/{providerId}/api-key",
|
||||
"/v1/admin/overview",
|
||||
"/v1/admin/referrals",
|
||||
"/v1/admin/analytics",
|
||||
|
||||
@@ -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-V26"
|
||||
runner shouldContain "Flyway history was not exactly successful V1-V27"
|
||||
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"
|
||||
|
||||
@@ -25,12 +25,18 @@ import com.osglab.account.features.credits.domain.CreditConflict
|
||||
import com.osglab.account.features.credits.domain.CreditNotFound
|
||||
import com.osglab.account.features.credits.domain.InvalidCreditRequest
|
||||
import com.osglab.account.features.credits.domain.LedgerEntryType
|
||||
import com.osglab.account.features.gateway.credentials.GatewayCredentialService
|
||||
import com.osglab.account.features.gateway.credentials.GatewayCredentialSource
|
||||
import com.osglab.account.features.gateway.credentials.GatewayCredentialStatus
|
||||
import com.osglab.account.features.gateway.credentials.InvalidProviderApiKeyException
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
import io.kotest.matchers.string.shouldNotContain
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.put
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
@@ -482,6 +488,74 @@ class AdminRoutesTest {
|
||||
assertEquals(HttpStatusCode.BadRequest, response.status)
|
||||
response.bodyAsText() shouldContain """"code":"VALIDATION_ERROR""""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `provider status is super admin only and never exposes API keys`() = testApplication {
|
||||
val credentialService = mockk<GatewayCredentialService>()
|
||||
coEvery { credentialService.listStatuses() } returns listOf(
|
||||
GatewayCredentialStatus(
|
||||
providerId = "deepseek",
|
||||
configured = true,
|
||||
source = GatewayCredentialSource.RUNTIME_OVERRIDE,
|
||||
updatedAt = "2026-08-22T08:00:00Z",
|
||||
),
|
||||
)
|
||||
application {
|
||||
installAdminTestRoutes(
|
||||
sessionService = sessionFixture(AdminRole.SUPER_ADMIN),
|
||||
credentialService = credentialService,
|
||||
)
|
||||
}
|
||||
|
||||
val response = client.get("/v1/admin/providers") {
|
||||
header("X-OSG-mTLS-Verified", "SUCCESS")
|
||||
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
|
||||
}
|
||||
|
||||
response.status shouldBe HttpStatusCode.OK
|
||||
val body = response.bodyAsText()
|
||||
body shouldContain """"providerId":"deepseek""""
|
||||
body shouldContain """"source":"RUNTIME_OVERRIDE""""
|
||||
body shouldNotContain "apiKey"
|
||||
body shouldNotContain "secret-runtime-key"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non super admin cannot update provider API keys`() = testApplication {
|
||||
val credentialService = mockk<GatewayCredentialService>(relaxed = true)
|
||||
application {
|
||||
installAdminTestRoutes(
|
||||
sessionService = sessionFixture(AdminRole.SUPPORT),
|
||||
credentialService = credentialService,
|
||||
)
|
||||
}
|
||||
|
||||
val response = client.putProviderKey("deepseek", "new-secret")
|
||||
|
||||
response.status shouldBe HttpStatusCode.Forbidden
|
||||
response.bodyAsText() shouldContain """"code":"INSUFFICIENT_PERMISSION""""
|
||||
coVerify(exactly = 0) { credentialService.updateApiKey(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `provider API key validation maps to stable error without echoing input`() = testApplication {
|
||||
val credentialService = mockk<GatewayCredentialService>()
|
||||
coEvery {
|
||||
credentialService.updateApiKey(any(), any(), any(), any())
|
||||
} throws InvalidProviderApiKeyException()
|
||||
application {
|
||||
installAdminTestRoutes(
|
||||
sessionService = sessionFixture(AdminRole.SUPER_ADMIN),
|
||||
credentialService = credentialService,
|
||||
)
|
||||
}
|
||||
|
||||
val response = client.putProviderKey("volcengine", "invalid-secret")
|
||||
|
||||
response.status shouldBe HttpStatusCode.BadRequest
|
||||
response.bodyAsText() shouldBe """{"code":"VALIDATION_ERROR"}"""
|
||||
response.bodyAsText() shouldNotContain "invalid-secret"
|
||||
}
|
||||
}
|
||||
|
||||
private fun io.ktor.server.application.Application.installAdminTestRoutes(
|
||||
@@ -491,6 +565,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes(
|
||||
operatorService: AdminOperatorService = mockk(relaxed = true),
|
||||
auditService: AdminAuditService = mockk(relaxed = true),
|
||||
usersService: AdminUsersService = mockk(relaxed = true),
|
||||
credentialService: GatewayCredentialService? = null,
|
||||
mtlsRequired: Boolean = true,
|
||||
) {
|
||||
install(ContentNegotiation) {
|
||||
@@ -513,6 +588,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes(
|
||||
grantService = grantService,
|
||||
operatorService = operatorService,
|
||||
auditService = auditService,
|
||||
credentialService = credentialService,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -568,3 +644,14 @@ private suspend fun io.ktor.client.HttpClient.postGrant() =
|
||||
"""{"userId":"5a33af2f-a878-43c0-8315-31729402b7cd","amount":100,"reason":"support credit"}""",
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun io.ktor.client.HttpClient.putProviderKey(providerId: String, apiKey: String) =
|
||||
put("/v1/admin/providers/$providerId/api-key") {
|
||||
header("X-OSG-mTLS-Verified", "SUCCESS")
|
||||
header(HttpHeaders.Origin, "https://account.osglab.com")
|
||||
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
|
||||
header("X-CSRF-Token", "csrf-token")
|
||||
header("X-Request-ID", "provider-key-update")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"apiKey":"$apiKey"}""")
|
||||
}
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package com.osglab.account.features.gateway.credentials
|
||||
|
||||
import com.osglab.account.common.security.FieldEncryptor
|
||||
import com.osglab.account.config.DatabaseConfig
|
||||
import com.osglab.account.config.DatabaseFactory
|
||||
import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.NewAdminOperator
|
||||
import com.osglab.account.features.admin.repositories.ExposedAdminRepository
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldNotContain
|
||||
import org.opentest4j.TestAbortedException
|
||||
import org.testcontainers.DockerClientFactory
|
||||
import org.testcontainers.containers.MySQLContainer
|
||||
import java.sql.DriverManager
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
class GatewayCredentialRepositoryIntegrationTest : FunSpec({
|
||||
test("MySQL stores encrypted overrides and audit in the same mutation") {
|
||||
withCredentialDatabase { config, databaseFactory ->
|
||||
val now = Instant.parse("2026-08-22T08:00:00Z")
|
||||
val operatorId = UUID.fromString("11111111-1111-4111-8111-111111111111")
|
||||
val adminRepository = ExposedAdminRepository(databaseFactory)
|
||||
adminRepository.createOperatorIfAbsent(
|
||||
NewAdminOperator(
|
||||
id = operatorId,
|
||||
normalizedUsername = "credential-owner",
|
||||
passwordHash = "password-hash",
|
||||
encryptedTotpSecret = "encrypted-totp",
|
||||
role = AdminRole.SUPER_ADMIN,
|
||||
createdAt = now,
|
||||
),
|
||||
)
|
||||
val repository = ExposedGatewayCredentialRepository(databaseFactory)
|
||||
val encryptor = FieldEncryptor(ByteArray(32) { it.toByte() })
|
||||
val resolver = DatabaseProviderApiKeyResolver(
|
||||
repository,
|
||||
encryptor,
|
||||
EnvironmentProviderCredentials("environment-key", null, false),
|
||||
)
|
||||
val service = GatewayCredentialService(
|
||||
repository,
|
||||
resolver,
|
||||
encryptor,
|
||||
Clock.fixed(now, ZoneOffset.UTC),
|
||||
)
|
||||
|
||||
service.updateApiKey(
|
||||
GatewayCredentialProvider.DEEPSEEK,
|
||||
"database-secret-key",
|
||||
operatorId,
|
||||
"credential-request",
|
||||
)
|
||||
|
||||
resolver.resolve(GatewayCredentialProvider.DEEPSEEK) shouldBe "database-secret-key"
|
||||
val rawCiphertext = DriverManager.getConnection(
|
||||
config.jdbcUrl,
|
||||
config.username,
|
||||
config.password,
|
||||
).use { connection ->
|
||||
connection.prepareStatement(
|
||||
"SELECT encrypted_api_key FROM gateway_provider_credentials WHERE provider_id = ?",
|
||||
).use { statement ->
|
||||
statement.setString(1, "deepseek")
|
||||
statement.executeQuery().use { result ->
|
||||
check(result.next())
|
||||
result.getString(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
rawCiphertext shouldNotContain "database-secret-key"
|
||||
adminRepository.listAudit(10).single {
|
||||
it.action == AdminAuditAction.PROVIDER_API_KEY_UPDATED
|
||||
}.run {
|
||||
targetId shouldBe "deepseek"
|
||||
requestId shouldBe "credential-request"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private suspend fun withCredentialDatabase(
|
||||
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) {
|
||||
CredentialMySqlContainer("mysql:8.4")
|
||||
.withDatabaseName("osg_gateway_credential_test")
|
||||
.withUsername("test")
|
||||
.withPassword("test")
|
||||
.also(CredentialMySqlContainer::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 = 4,
|
||||
)
|
||||
val databaseFactory = DatabaseFactory(config)
|
||||
try {
|
||||
databaseFactory.database
|
||||
block(config, databaseFactory)
|
||||
} finally {
|
||||
databaseFactory.close()
|
||||
mysql?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private class CredentialMySqlContainer(image: String) :
|
||||
MySQLContainer<CredentialMySqlContainer>(image)
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package com.osglab.account.features.gateway.credentials
|
||||
|
||||
import com.osglab.account.common.security.FieldEncryptor
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.StringSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldNotContain
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
class GatewayCredentialServiceTest : StringSpec({
|
||||
val encryptionKey = ByteArray(32) { it.toByte() }
|
||||
val operatorId = UUID.fromString("11111111-1111-4111-8111-111111111111")
|
||||
|
||||
"uses environment credentials when no runtime override exists" {
|
||||
val repository = InMemoryGatewayCredentialRepository()
|
||||
val resolver = DatabaseProviderApiKeyResolver(
|
||||
repository,
|
||||
FieldEncryptor(encryptionKey),
|
||||
EnvironmentProviderCredentials(
|
||||
deepSeekApiKey = "environment-deepseek",
|
||||
volcengineApiKey = null,
|
||||
volcengineLegacyConfigured = true,
|
||||
),
|
||||
)
|
||||
|
||||
resolver.resolve(GatewayCredentialProvider.DEEPSEEK) shouldBe "environment-deepseek"
|
||||
resolver.resolve(GatewayCredentialProvider.VOLCENGINE) shouldBe null
|
||||
resolver.status(GatewayCredentialProvider.VOLCENGINE) shouldBe GatewayCredentialStatus(
|
||||
providerId = "volcengine",
|
||||
configured = true,
|
||||
source = GatewayCredentialSource.ENVIRONMENT,
|
||||
)
|
||||
}
|
||||
|
||||
"runtime override takes priority and decrypts only at resolution time" {
|
||||
val repository = InMemoryGatewayCredentialRepository()
|
||||
val encryptor = FieldEncryptor(encryptionKey)
|
||||
repository.credentialOverride = ProviderApiKeyOverride(
|
||||
provider = GatewayCredentialProvider.VOLCENGINE,
|
||||
encryptedApiKey = encryptor.encrypt(
|
||||
"runtime-volcengine",
|
||||
encryptionContext(GatewayCredentialProvider.VOLCENGINE),
|
||||
),
|
||||
updatedAt = Instant.parse("2026-08-22T08:00:00Z"),
|
||||
updatedByOperatorId = operatorId,
|
||||
)
|
||||
val resolver = DatabaseProviderApiKeyResolver(
|
||||
repository,
|
||||
encryptor,
|
||||
EnvironmentProviderCredentials(null, "environment-volcengine", true),
|
||||
)
|
||||
|
||||
resolver.resolve(GatewayCredentialProvider.VOLCENGINE) shouldBe "runtime-volcengine"
|
||||
resolver.status(GatewayCredentialProvider.VOLCENGINE).source shouldBe
|
||||
GatewayCredentialSource.RUNTIME_OVERRIDE
|
||||
}
|
||||
|
||||
"two updates make new resolutions use the latest encrypted key" {
|
||||
val repository = InMemoryGatewayCredentialRepository()
|
||||
val encryptor = FieldEncryptor(encryptionKey)
|
||||
val resolver = DatabaseProviderApiKeyResolver(
|
||||
repository,
|
||||
encryptor,
|
||||
EnvironmentProviderCredentials("environment-deepseek", null, false),
|
||||
)
|
||||
val service = GatewayCredentialService(
|
||||
repository,
|
||||
resolver,
|
||||
encryptor,
|
||||
Clock.fixed(Instant.parse("2026-08-22T08:00:00Z"), ZoneOffset.UTC),
|
||||
)
|
||||
|
||||
service.updateApiKey(
|
||||
GatewayCredentialProvider.DEEPSEEK,
|
||||
" first-runtime-key ",
|
||||
operatorId,
|
||||
"request-one",
|
||||
)
|
||||
resolver.resolve(GatewayCredentialProvider.DEEPSEEK) shouldBe "first-runtime-key"
|
||||
repository.credentialOverride!!.encryptedApiKey shouldNotContain "first-runtime-key"
|
||||
|
||||
service.updateApiKey(
|
||||
GatewayCredentialProvider.DEEPSEEK,
|
||||
"second-runtime-key",
|
||||
operatorId,
|
||||
"request-two",
|
||||
)
|
||||
resolver.resolve(GatewayCredentialProvider.DEEPSEEK) shouldBe "second-runtime-key"
|
||||
repository.auditEvent!!.targetId shouldBe "deepseek"
|
||||
repository.auditEvent!!.requestId shouldBe "request-two"
|
||||
}
|
||||
|
||||
"rejects blank multiline and oversized API keys" {
|
||||
val repository = InMemoryGatewayCredentialRepository()
|
||||
val encryptor = FieldEncryptor(encryptionKey)
|
||||
val resolver = DatabaseProviderApiKeyResolver(
|
||||
repository,
|
||||
encryptor,
|
||||
EnvironmentProviderCredentials(null, null, false),
|
||||
)
|
||||
val service = GatewayCredentialService(repository, resolver, encryptor)
|
||||
|
||||
listOf(" ", "line-one\nline-two", "line-one\rline-two", "x".repeat(4_097)).forEach {
|
||||
shouldThrow<InvalidProviderApiKeyException> {
|
||||
service.updateApiKey(
|
||||
GatewayCredentialProvider.DEEPSEEK,
|
||||
it,
|
||||
operatorId,
|
||||
null,
|
||||
)
|
||||
}
|
||||
}
|
||||
repository.credentialOverride shouldBe null
|
||||
}
|
||||
})
|
||||
|
||||
private class InMemoryGatewayCredentialRepository : GatewayCredentialRepository {
|
||||
var credentialOverride: ProviderApiKeyOverride? = null
|
||||
var auditEvent: NewAdminAuditEvent? = null
|
||||
|
||||
override suspend fun findOverride(
|
||||
provider: GatewayCredentialProvider,
|
||||
): ProviderApiKeyOverride? = credentialOverride?.takeIf { it.provider == provider }
|
||||
|
||||
override suspend fun upsertOverride(
|
||||
credentialOverride: ProviderApiKeyOverride,
|
||||
auditEvent: NewAdminAuditEvent,
|
||||
) {
|
||||
this.credentialOverride = credentialOverride
|
||||
this.auditEvent = auditEvent
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ class TextRequestPolicyTest : StringSpec({
|
||||
"translation" to GatewayTaskKind.TRANSLATION,
|
||||
"edit_last_input" to GatewayTaskKind.EDIT_LAST_INPUT,
|
||||
"ai_question" to GatewayTaskKind.AI_QUESTION,
|
||||
"current_information_question" to GatewayTaskKind.CURRENT_INFORMATION_QUESTION,
|
||||
"clipboard_transform" to GatewayTaskKind.CLIPBOARD_TRANSFORM,
|
||||
"custom_skill" to GatewayTaskKind.CUSTOM_SKILL,
|
||||
"agent_planning" to GatewayTaskKind.AGENT_PLANNING,
|
||||
|
||||
+189
-1
@@ -1,12 +1,16 @@
|
||||
package com.osglab.account.features.gateway.providers.deepseek
|
||||
|
||||
import com.osglab.account.features.gateway.models.GatewayCapability
|
||||
import com.osglab.account.features.gateway.credentials.GatewayCredentialProvider
|
||||
import com.osglab.account.features.gateway.credentials.ProviderApiKeyResolver
|
||||
import com.osglab.account.features.gateway.models.GatewayTaskKind
|
||||
import com.osglab.account.features.gateway.models.GatewayWebSearchMode
|
||||
import com.osglab.account.features.gateway.models.ProviderOutput
|
||||
import com.osglab.account.features.gateway.models.TextProviderRequest
|
||||
import com.osglab.account.features.gateway.services.GatewayTaskPolicyResolver
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.StringSpec
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.mock.MockEngine
|
||||
@@ -19,6 +23,7 @@ import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.headersOf
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
@@ -133,6 +138,159 @@ class DeepSeekClientTest : StringSpec({
|
||||
}
|
||||
}
|
||||
|
||||
"uses Responses web search and normalizes buffered output for ordinary AI" {
|
||||
var requestBody = ""
|
||||
var requestPath = ""
|
||||
val emitted = mutableListOf<ByteArray>()
|
||||
val client = client(
|
||||
"""
|
||||
{
|
||||
"output":[
|
||||
{"type":"web_search_call","id":"search-1"},
|
||||
{"type":"message","content":[{"type":"output_text","text":"今日热点"}]}
|
||||
],
|
||||
"usage":{"input_tokens":12,"output_tokens":5,"total_tokens":17}
|
||||
}
|
||||
""".trimIndent(),
|
||||
onRequest = { requestBody = it },
|
||||
onPath = { requestPath = it },
|
||||
)
|
||||
try {
|
||||
val usage = DeepSeekProvider(client, CONFIG).execute(
|
||||
request(webSearch = GatewayWebSearchMode.ALLOWED),
|
||||
ProviderOutput { emitted += it },
|
||||
)
|
||||
|
||||
requestPath shouldBe "/v1/responses"
|
||||
val payload = Json.parseToJsonElement(requestBody).jsonObject
|
||||
payload.getValue("tool_choice").jsonPrimitive.content shouldBe "auto"
|
||||
payload.getValue("tools").jsonArray.first().jsonObject
|
||||
.getValue("type").jsonPrimitive.content shouldBe "web_search"
|
||||
usage shouldBe com.osglab.account.features.gateway.models.ProviderUsage(
|
||||
meter = com.osglab.account.features.gateway.models.UsageMeter.LLM_TOKEN,
|
||||
units = 17,
|
||||
inputUnits = 12,
|
||||
outputUnits = 5,
|
||||
)
|
||||
val downstream = Json.parseToJsonElement(
|
||||
emitted.joinToString("") { it.decodeToString() },
|
||||
).jsonObject
|
||||
downstream.getValue("choices").jsonArray.first().jsonObject
|
||||
.getValue("message").jsonObject
|
||||
.getValue("content").jsonPrimitive.content shouldBe "今日热点"
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
"normalizes a searched Responses result into managed Chat Completions SSE" {
|
||||
val emitted = mutableListOf<ByteArray>()
|
||||
val client = client(
|
||||
"""
|
||||
{
|
||||
"output":[
|
||||
{"type":"web_search_call","id":"search-1"},
|
||||
{"type":"message","content":[{"type":"output_text","text":"最新结果"}]}
|
||||
],
|
||||
"usage":{"input_tokens":14,"output_tokens":3,"total_tokens":17}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
try {
|
||||
val usage = DeepSeekProvider(client, CONFIG).execute(
|
||||
request(webSearch = GatewayWebSearchMode.ALLOWED).copy(stream = true),
|
||||
ProviderOutput { emitted += it },
|
||||
)
|
||||
|
||||
val downstream = emitted.joinToString("") { it.decodeToString() }
|
||||
downstream shouldContain """"content":"最新结果""""
|
||||
downstream shouldContain """"prompt_tokens":14"""
|
||||
downstream shouldContain "data: [DONE]"
|
||||
usage.units shouldBe 17
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
"forces web search for current-information questions" {
|
||||
var requestBody = ""
|
||||
val client = client(
|
||||
"""
|
||||
{
|
||||
"output":[
|
||||
{"type":"web_search_call","id":"search-1"},
|
||||
{"type":"message","content":[{"type":"output_text","text":"verified"}]}
|
||||
],
|
||||
"usage":{"input_tokens":8,"output_tokens":2,"total_tokens":10}
|
||||
}
|
||||
""".trimIndent(),
|
||||
onRequest = { requestBody = it },
|
||||
)
|
||||
try {
|
||||
DeepSeekProvider(client, CONFIG).execute(
|
||||
request(
|
||||
taskKind = GatewayTaskKind.CURRENT_INFORMATION_QUESTION,
|
||||
webSearch = GatewayWebSearchMode.REQUIRED,
|
||||
),
|
||||
DISCARD_OUTPUT,
|
||||
)
|
||||
|
||||
Json.parseToJsonElement(requestBody).jsonObject
|
||||
.getValue("tool_choice").jsonObject
|
||||
.getValue("type").jsonPrimitive.content shouldBe "web_search"
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
"falls back to Chat Completions when Responses search fails" {
|
||||
val paths = mutableListOf<String>()
|
||||
val requestBodies = mutableListOf<String>()
|
||||
val client = HttpClient(
|
||||
MockEngine { request ->
|
||||
paths += request.url.encodedPath
|
||||
requestBodies += request.body.toByteArray().decodeToString()
|
||||
if (request.url.encodedPath.endsWith("/responses")) {
|
||||
respond(
|
||||
content = """{"error":{"message":"search unavailable"}}""",
|
||||
status = HttpStatusCode.ServiceUnavailable,
|
||||
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
|
||||
)
|
||||
} else {
|
||||
respond(
|
||||
content =
|
||||
"""{"choices":[{"message":{"content":"无法核实实时信息"}}],""" +
|
||||
""""usage":{"prompt_tokens":9,"completion_tokens":4,"total_tokens":13}}""",
|
||||
status = HttpStatusCode.OK,
|
||||
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { explicitNulls = false })
|
||||
}
|
||||
}
|
||||
try {
|
||||
val usage = DeepSeekProvider(client, CONFIG).execute(
|
||||
request(
|
||||
taskKind = GatewayTaskKind.CURRENT_INFORMATION_QUESTION,
|
||||
webSearch = GatewayWebSearchMode.REQUIRED,
|
||||
),
|
||||
DISCARD_OUTPUT,
|
||||
)
|
||||
|
||||
paths shouldBe listOf("/v1/responses", "/v1/chat/completions")
|
||||
val fallbackSystem = Json.parseToJsonElement(requestBodies.last()).jsonObject
|
||||
.getValue("messages").jsonArray.first().jsonObject
|
||||
.getValue("content").jsonPrimitive.content
|
||||
fallbackSystem shouldContain "could not be verified"
|
||||
usage.units shouldBe 13
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
"retries one buffered empty result and returns the successful retry" {
|
||||
var attempts = 0
|
||||
val provider = DeepSeekProvider(
|
||||
@@ -300,14 +458,41 @@ class DeepSeekClientTest : StringSpec({
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
"resolves the bearer token separately for each new upstream request" {
|
||||
val authorizationHeaders = mutableListOf<String?>()
|
||||
var currentKey = "first-key"
|
||||
val client = client(
|
||||
"""{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":10,"completion_tokens":3,"total_tokens":13}}""",
|
||||
onAuthorization = authorizationHeaders::add,
|
||||
)
|
||||
val resolver = ProviderApiKeyResolver { provider ->
|
||||
provider shouldBe GatewayCredentialProvider.DEEPSEEK
|
||||
currentKey
|
||||
}
|
||||
try {
|
||||
val upstream = KtorDeepSeekClient(client, CONFIG, credentialResolver = resolver)
|
||||
upstream.complete(request(), DISCARD_OUTPUT)
|
||||
currentKey = "second-key"
|
||||
upstream.complete(request(), DISCARD_OUTPUT)
|
||||
|
||||
authorizationHeaders shouldBe listOf("Bearer first-key", "Bearer second-key")
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private fun client(
|
||||
responseBody: String,
|
||||
contentType: ContentType = ContentType.Application.Json,
|
||||
onRequest: suspend (String) -> Unit = {},
|
||||
onPath: suspend (String) -> Unit = {},
|
||||
onAuthorization: suspend (String?) -> Unit = {},
|
||||
) = HttpClient(
|
||||
MockEngine { request ->
|
||||
onPath(request.url.encodedPath)
|
||||
onAuthorization(request.headers[HttpHeaders.Authorization])
|
||||
onRequest(request.body.toByteArray().decodeToString())
|
||||
respond(
|
||||
content = responseBody,
|
||||
@@ -324,8 +509,11 @@ private fun client(
|
||||
private fun request(
|
||||
capability: GatewayCapability = GatewayCapability.AI,
|
||||
taskKind: GatewayTaskKind? = null,
|
||||
webSearch: GatewayWebSearchMode = GatewayWebSearchMode.DISABLED,
|
||||
): TextProviderRequest {
|
||||
val executionPolicy = TASK_POLICY.resolve(capability, taskKind, 32)
|
||||
val executionPolicy = TASK_POLICY.resolve(capability, taskKind, 32).copy(
|
||||
webSearch = webSearch,
|
||||
)
|
||||
return TextProviderRequest(
|
||||
requestId = "deepseek-request",
|
||||
capability = capability,
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.osglab.account.features.gateway.models.GatewayPrincipal
|
||||
import com.osglab.account.features.gateway.models.GatewayRequestSource
|
||||
import com.osglab.account.features.gateway.models.GatewayTaskKind
|
||||
import com.osglab.account.features.gateway.models.GatewayThinkingMode
|
||||
import com.osglab.account.features.gateway.models.GatewayWebSearchMode
|
||||
import com.osglab.account.features.gateway.models.ProviderDescriptor
|
||||
import com.osglab.account.features.gateway.models.ProviderOutput
|
||||
import com.osglab.account.features.gateway.models.ProviderRequest
|
||||
@@ -135,6 +136,26 @@ class GatewayRequestIdTest : StringSpec({
|
||||
GatewayThinkingMode.DISABLED
|
||||
}
|
||||
}
|
||||
|
||||
"requires search for an explicit current-information question" {
|
||||
val provider = RequestIdProvider()
|
||||
|
||||
testApplication {
|
||||
application { gatewayTestApplication(provider) }
|
||||
|
||||
val response = client.post("/v1/gateway/llm/ai") {
|
||||
header("X-Request-ID", "current-info-task-123")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"input":"今天的热点","taskKind":"current_information_question"}""")
|
||||
}
|
||||
|
||||
response.status shouldBe HttpStatusCode.OK
|
||||
provider.lastTextRequest?.executionPolicy?.taskKind shouldBe
|
||||
GatewayTaskKind.CURRENT_INFORMATION_QUESTION
|
||||
provider.lastTextRequest?.executionPolicy?.webSearch shouldBe
|
||||
GatewayWebSearchMode.REQUIRED
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private fun io.ktor.server.application.Application.gatewayTestApplication(provider: RequestIdProvider) {
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ class GatewayServiceBillingTest : StringSpec({
|
||||
credits.settled.shouldContainExactly(RESERVATION_ID to 21L)
|
||||
credits.released shouldBe emptyList()
|
||||
credits.lastEstimate?.meter shouldBe UsageMeter.LLM_TOKEN
|
||||
credits.lastEstimate?.inputUnits shouldBe 261L
|
||||
credits.lastEstimate?.inputUnits shouldBe 261L + 32_000L
|
||||
credits.lastEstimate?.outputUnits shouldBe 32L
|
||||
}
|
||||
|
||||
|
||||
+42
-13
@@ -44,20 +44,49 @@ class GatewayTaskPolicyResolverTest : StringSpec({
|
||||
}
|
||||
}
|
||||
|
||||
"enables explicit high-effort reasoning only for question and agent tasks" {
|
||||
listOf(
|
||||
GatewayCapability.AI to GatewayTaskKind.AI_QUESTION,
|
||||
GatewayCapability.AGENT to GatewayTaskKind.AGENT_PLANNING,
|
||||
).forEach { (capability, taskKind) ->
|
||||
val policy = resolver.resolve(capability, taskKind, 512)
|
||||
"allows model-selected search for ordinary AI questions" {
|
||||
val policy = resolver.resolve(
|
||||
GatewayCapability.AI,
|
||||
GatewayTaskKind.AI_QUESTION,
|
||||
512,
|
||||
)
|
||||
|
||||
policy.modelProfile shouldBe GatewayModelProfile.REASONING
|
||||
policy.thinking shouldBe GatewayThinkingMode.ENABLED
|
||||
policy.reasoningEffort shouldBe GatewayReasoningEffort.HIGH
|
||||
policy.webSearch shouldBe GatewayWebSearchMode.DISABLED
|
||||
policy.tools shouldBe GatewayToolsMode.DISABLED
|
||||
policy.allowEmptyContentRetry shouldBe true
|
||||
}
|
||||
policy.modelProfile shouldBe GatewayModelProfile.REASONING
|
||||
policy.thinking shouldBe GatewayThinkingMode.ENABLED
|
||||
policy.reasoningEffort shouldBe GatewayReasoningEffort.HIGH
|
||||
policy.webSearch shouldBe GatewayWebSearchMode.ALLOWED
|
||||
policy.tools shouldBe GatewayToolsMode.DISABLED
|
||||
policy.allowEmptyContentRetry shouldBe true
|
||||
}
|
||||
|
||||
"requires search for explicitly time-sensitive AI questions" {
|
||||
val policy = resolver.resolve(
|
||||
GatewayCapability.AI,
|
||||
GatewayTaskKind.CURRENT_INFORMATION_QUESTION,
|
||||
512,
|
||||
)
|
||||
|
||||
policy.modelProfile shouldBe GatewayModelProfile.REASONING
|
||||
policy.thinking shouldBe GatewayThinkingMode.ENABLED
|
||||
policy.reasoningEffort shouldBe GatewayReasoningEffort.HIGH
|
||||
policy.webSearch shouldBe GatewayWebSearchMode.REQUIRED
|
||||
policy.tools shouldBe GatewayToolsMode.DISABLED
|
||||
policy.allowEmptyContentRetry shouldBe true
|
||||
}
|
||||
|
||||
"keeps agent planning offline while retaining high-effort reasoning" {
|
||||
val policy = resolver.resolve(
|
||||
GatewayCapability.AGENT,
|
||||
GatewayTaskKind.AGENT_PLANNING,
|
||||
512,
|
||||
)
|
||||
|
||||
policy.modelProfile shouldBe GatewayModelProfile.REASONING
|
||||
policy.thinking shouldBe GatewayThinkingMode.ENABLED
|
||||
policy.reasoningEffort shouldBe GatewayReasoningEffort.HIGH
|
||||
policy.webSearch shouldBe GatewayWebSearchMode.DISABLED
|
||||
policy.tools shouldBe GatewayToolsMode.DISABLED
|
||||
policy.allowEmptyContentRetry shouldBe true
|
||||
}
|
||||
|
||||
"rejects capability and task mismatches without inspecting content" {
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.osglab.account.features.gateway.services
|
||||
|
||||
import com.osglab.account.features.gateway.models.GatewayCapability
|
||||
import com.osglab.account.features.gateway.models.GatewayTaskKind
|
||||
import com.osglab.account.features.gateway.models.TextProviderRequest
|
||||
import io.kotest.core.spec.style.StringSpec
|
||||
import io.kotest.matchers.longs.shouldBeGreaterThan
|
||||
import io.kotest.matchers.shouldBe
|
||||
|
||||
class GatewayUsageEstimatorTest : StringSpec({
|
||||
"reserves provider-injected input tokens only for searchable tasks" {
|
||||
val searchable = request(GatewayTaskKind.AI_QUESTION)
|
||||
val offline = request(GatewayTaskKind.CLIPBOARD_TRANSFORM)
|
||||
|
||||
val searchableEstimate = ConservativeGatewayUsageEstimator.estimate(searchable)
|
||||
val offlineEstimate = ConservativeGatewayUsageEstimator.estimate(offline)
|
||||
|
||||
searchableEstimate.inputUnits!! shouldBeGreaterThan offlineEstimate.inputUnits!!
|
||||
searchableEstimate.inputUnits shouldBe offlineEstimate.inputUnits!! + 32_000L
|
||||
searchableEstimate.outputUnits shouldBe offlineEstimate.outputUnits
|
||||
}
|
||||
})
|
||||
|
||||
private fun request(taskKind: GatewayTaskKind): TextProviderRequest {
|
||||
val policy = GatewayTaskPolicyResolver().resolve(
|
||||
capability = GatewayCapability.AI,
|
||||
requestedTaskKind = taskKind,
|
||||
requestedMaxOutputTokens = 32,
|
||||
)
|
||||
return TextProviderRequest(
|
||||
requestId = "usage-estimator-request",
|
||||
capability = GatewayCapability.AI,
|
||||
executionPolicy = policy,
|
||||
input = "hello",
|
||||
context = null,
|
||||
maxOutputTokens = policy.maxOutputTokens,
|
||||
temperature = 0.2,
|
||||
stream = false,
|
||||
)
|
||||
}
|
||||
@@ -61,6 +61,23 @@ class OobeGatewayServiceTest : StringSpec({
|
||||
credits.calls shouldBe 0
|
||||
}
|
||||
|
||||
"allows the same page again under a new OOBE grant" {
|
||||
val credits = CountingCredits()
|
||||
val oobe = FakeOobeExecutionRepository()
|
||||
val service = service(credits, oobe)
|
||||
val feature = OobeFeature.VOICE_INPUT
|
||||
|
||||
service.execute(OOBE_PRINCIPAL, request(feature, "oobe-first-session"), DISCARD)
|
||||
service.execute(
|
||||
OOBE_PRINCIPAL.copy(grantId = "30000000-0000-0000-0000-000000000002"),
|
||||
request(feature, "oobe-replay-session"),
|
||||
DISCARD,
|
||||
)
|
||||
|
||||
oobe.consumed.map(OobeRequestClaim::grantId).toSet().size shouldBe 2
|
||||
credits.calls shouldBe 0
|
||||
}
|
||||
|
||||
"releases the feature claim when the provider fails" {
|
||||
val credits = CountingCredits()
|
||||
val oobe = FakeOobeExecutionRepository()
|
||||
@@ -154,7 +171,7 @@ private class CountingCredits : CreditReservationPort {
|
||||
}
|
||||
|
||||
private class FakeOobeExecutionRepository : OobeRepository {
|
||||
private val claimedFeatures = mutableSetOf<OobeFeature>()
|
||||
private val claimedFeatures = mutableSetOf<Pair<String, OobeFeature>>()
|
||||
val consumed = mutableListOf<OobeRequestClaim>()
|
||||
val released = mutableListOf<OobeRequestClaim>()
|
||||
|
||||
@@ -163,8 +180,9 @@ private class FakeOobeExecutionRepository : OobeRepository {
|
||||
expiresAt: Instant,
|
||||
now: Instant,
|
||||
): OobeRequestClaim? {
|
||||
if (!claimedFeatures.add(request.feature)) return null
|
||||
return OobeRequestClaim(request.subjectId, request.feature, request.requestId)
|
||||
val claimKey = request.grantId to request.feature
|
||||
if (!claimedFeatures.add(claimKey)) return null
|
||||
return OobeRequestClaim(request.subjectId, request.grantId, request.feature, request.requestId)
|
||||
}
|
||||
|
||||
override suspend fun markStarted(claim: OobeRequestClaim) = Unit
|
||||
@@ -174,7 +192,7 @@ private class FakeOobeExecutionRepository : OobeRepository {
|
||||
}
|
||||
|
||||
override suspend fun release(claim: OobeRequestClaim, errorCode: String) {
|
||||
claimedFeatures -= claim.feature
|
||||
claimedFeatures -= (claim.grantId to claim.feature)
|
||||
released += claim
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
class OobeRepositoryIntegrationTest : FunSpec({
|
||||
test("anonymous feature claim is atomic, consumed once, and independent from accounts") {
|
||||
test("anonymous feature claim is atomic per grant and independent from accounts") {
|
||||
withOobeDatabase { config, databaseFactory ->
|
||||
val repository = ExposedOobeRepository(databaseFactory)
|
||||
val now = Instant.parse("2026-08-21T01:00:00Z")
|
||||
@@ -72,6 +72,27 @@ class OobeRepositoryIntegrationTest : FunSpec({
|
||||
now,
|
||||
) shouldBe null
|
||||
|
||||
val replayGrant = OobeGrant(
|
||||
UUID.randomUUID().toString(),
|
||||
subject.id,
|
||||
now.plus(Duration.ofMinutes(30)),
|
||||
)
|
||||
repository.createGrant(
|
||||
NewOobeGrant(
|
||||
grant = replayGrant,
|
||||
refreshTokenId = UUID.randomUUID().toString(),
|
||||
refreshFamilyId = UUID.randomUUID().toString(),
|
||||
refreshTokenHash = "e".repeat(64),
|
||||
refreshExpiresAt = replayGrant.expiresAt,
|
||||
),
|
||||
now,
|
||||
)
|
||||
repository.claim(
|
||||
providerRequest(subject.id, replayGrant.id, "repeat-in-new-oobe-session"),
|
||||
now.plus(Duration.ofMinutes(15)),
|
||||
now,
|
||||
) shouldNotBe null
|
||||
|
||||
databaseCount(config, "accounts") shouldBe 0
|
||||
databaseCount(config, "credit_ledger") shouldBe 0
|
||||
databaseCount(config, "devicecheck_trial_claims") shouldBe 0
|
||||
|
||||
Reference in New Issue
Block a user