Enforce deterministic gateway task policies

Make the server authoritative for thinking, model, search, tools, retry, and output budgets while preserving legacy client behavior.
This commit is contained in:
Rocky
2026-08-19 20:55:05 +08:00
parent 3edc86a9a0
commit 11ec34dacb
17 changed files with 667 additions and 44 deletions
@@ -609,6 +609,7 @@ private fun configuredProviders(config: AppConfig, client: HttpClient): List<Gat
endpoint = config.providers.deepSeek.endpoint,
apiKey = apiKey,
model = config.providers.deepSeek.model,
reasoningModel = config.providers.deepSeek.reasoningModel,
),
),
)
@@ -104,6 +104,10 @@ data class AppConfig(
"app.storeKit.products is required when StoreKit is enabled"
}
}
val deepSeekModel = config.valueOrDefault(
"app.providers.deepseek.model",
"deepseek-v4-flash",
)
val providers = ProvidersConfig(
volcengine = VolcengineConfig(
endpoint = config.valueOrDefault(
@@ -124,7 +128,9 @@ data class AppConfig(
"https://api.deepseek.com/v1",
),
apiKey = config.optionalValue("app.providers.deepseek.apiKey"),
model = config.valueOrDefault("app.providers.deepseek.model", "deepseek-v4-flash"),
model = deepSeekModel,
reasoningModel = config.optionalValue("app.providers.deepseek.reasoningModel")
?: deepSeekModel,
),
)
val integrity = IntegrityConfig(
@@ -411,6 +417,7 @@ data class DeepSeekConfig(
val endpoint: String,
val apiKey: String?,
val model: String,
val reasoningModel: String = model,
) {
val credentialsAvailable: Boolean
get() = !apiKey.isNullOrBlank()
@@ -60,8 +60,84 @@ data class TextGatewayRequest(
val temperature: Double = 0.2,
val stream: Boolean = false,
val requestSource: GatewayRequestSource? = null,
val taskKind: GatewayTaskKind? = null,
)
@Serializable
enum class GatewayTaskKind {
@SerialName("dictation_polish")
DICTATION_POLISH,
@SerialName("translation")
TRANSLATION,
@SerialName("edit_last_input")
EDIT_LAST_INPUT,
@SerialName("ai_question")
AI_QUESTION,
@SerialName("clipboard_transform")
CLIPBOARD_TRANSFORM,
@SerialName("custom_skill")
CUSTOM_SKILL,
@SerialName("agent_planning")
AGENT_PLANNING,
}
enum class GatewayThinkingMode {
DISABLED,
ENABLED,
}
enum class GatewayReasoningEffort {
LOW,
HIGH,
MAX,
}
enum class GatewayWebSearchMode {
DISABLED,
ALLOWED,
REQUIRED,
}
enum class GatewayToolsMode {
DISABLED,
ALLOWED,
}
enum class GatewayModelProfile {
LOW_LATENCY,
REASONING,
}
/**
* Provider-independent policy selected exclusively from trusted server rules.
* Provider-specific request fields must be derived from this value.
*/
data class GatewayTaskExecutionPolicy(
val taskKind: GatewayTaskKind,
val modelProfile: GatewayModelProfile,
val thinking: GatewayThinkingMode,
val reasoningEffort: GatewayReasoningEffort?,
val webSearch: GatewayWebSearchMode,
val tools: GatewayToolsMode,
val allowEmptyContentRetry: Boolean,
val maxOutputTokens: Int,
) {
init {
require(maxOutputTokens in 1..GatewayLimits.MAX_OUTPUT_TOKENS)
require(
(thinking == GatewayThinkingMode.ENABLED) == (reasoningEffort != null),
) {
"reasoning effort must be explicit exactly when thinking is enabled"
}
}
}
@Serializable
data class AsrGatewayOptions(
val format: String = "pcm",
@@ -83,6 +159,7 @@ sealed interface ProviderRequest {
data class TextProviderRequest(
override val requestId: String,
override val capability: GatewayCapability,
val executionPolicy: GatewayTaskExecutionPolicy,
val input: String,
val context: String?,
val maxOutputTokens: Int,
@@ -3,6 +3,11 @@ package com.osglab.account.features.gateway.providers.deepseek
import com.osglab.account.features.gateway.agent.AgentPlan
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.GatewayThinkingMode
import com.osglab.account.features.gateway.models.GatewayToolsMode
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
@@ -40,6 +45,7 @@ data class DeepSeekConfig(
val endpoint: String,
val apiKey: String,
val model: String,
val reasoningModel: String = model,
) {
init {
val url = runCatching { Url(endpoint) }
@@ -53,7 +59,16 @@ data class DeepSeekConfig(
if (model.isBlank()) {
throw DeepSeekConfigurationException("DEEPSEEK_MODEL must not be blank")
}
if (reasoningModel.isBlank()) {
throw DeepSeekConfigurationException("DEEPSEEK_REASONING_MODEL must not be blank")
}
}
fun modelFor(profile: GatewayModelProfile): String =
when (profile) {
GatewayModelProfile.LOW_LATENCY -> model
GatewayModelProfile.REASONING -> reasoningModel
}
}
fun interface DeepSeekClient {
@@ -107,7 +122,12 @@ class DeepSeekProvider(
failure.reasoningContentPresent,
failure.usagePresent,
)
if (request.stream || attempt >= MAX_BUFFERED_ATTEMPTS) throw failure
if (request.stream ||
!request.executionPolicy.allowEmptyContentRetry ||
attempt >= MAX_BUFFERED_ATTEMPTS
) {
throw failure
}
attempt += 1
}
}
@@ -122,6 +142,15 @@ class DeepSeekProvider(
require(request.maxOutputTokens in 1..GatewayLimits.MAX_OUTPUT_TOKENS) {
"maxOutputTokens is out of range"
}
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.tools == GatewayToolsMode.DISABLED) {
"DeepSeek tools are not configured"
}
require(request.temperature in 0.0..1.0 && request.temperature.isFinite()) {
"temperature is out of range"
}
@@ -153,11 +182,18 @@ class KtorDeepSeekClient(
output: ProviderOutput,
): ProviderUsage {
val payload = DeepSeekChatRequest(
model = config.model,
model = config.modelFor(request.executionPolicy.modelProfile),
messages = controlledMessages(request),
maxTokens = request.maxOutputTokens,
temperature = request.temperature,
stream = request.stream,
thinking = DeepSeekThinking(
type = when (request.executionPolicy.thinking) {
GatewayThinkingMode.DISABLED -> DeepSeekThinkingType.DISABLED
GatewayThinkingMode.ENABLED -> DeepSeekThinkingType.ENABLED
},
),
reasoningEffort = request.executionPolicy.reasoningEffort?.toDeepSeekReasoningEffort(),
streamOptions = if (request.stream) StreamOptions(includeUsage = true) else null,
responseFormat = if (request.capability == GatewayCapability.AGENT) {
ResponseFormat(type = "json_object")
@@ -484,6 +520,13 @@ class KtorDeepSeekClient(
return listOf(ChatMessage("system", system), ChatMessage("user", userText))
}
private fun GatewayReasoningEffort.toDeepSeekReasoningEffort(): DeepSeekReasoningEffort =
when (this) {
GatewayReasoningEffort.LOW -> DeepSeekReasoningEffort.LOW
GatewayReasoningEffort.HIGH -> DeepSeekReasoningEffort.HIGH
GatewayReasoningEffort.MAX -> DeepSeekReasoningEffort.MAX
}
private companion object {
const val MAX_AGENT_ID_CHARS = 128
const val MAX_AGENT_FIELD_CHARS = 4_096
@@ -504,12 +547,41 @@ private data class DeepSeekChatRequest(
val maxTokens: Int,
val temperature: Double,
val stream: Boolean,
val thinking: DeepSeekThinking,
@SerialName("reasoning_effort")
val reasoningEffort: DeepSeekReasoningEffort?,
@SerialName("stream_options")
val streamOptions: StreamOptions?,
@SerialName("response_format")
val responseFormat: ResponseFormat?,
)
@Serializable
private data class DeepSeekThinking(
val type: DeepSeekThinkingType,
)
@Serializable
private enum class DeepSeekThinkingType {
@SerialName("disabled")
DISABLED,
@SerialName("enabled")
ENABLED,
}
@Serializable
private enum class DeepSeekReasoningEffort {
@SerialName("low")
LOW,
@SerialName("high")
HIGH,
@SerialName("max")
MAX,
}
@Serializable
private data class ChatMessage(
val role: String,
@@ -29,6 +29,7 @@ import com.osglab.account.features.gateway.services.GatewayGrantService
import com.osglab.account.features.gateway.services.GatewayRefreshTokenInvalidException
import com.osglab.account.features.gateway.services.GatewayRefreshTokenReuseException
import com.osglab.account.features.gateway.services.GatewayService
import com.osglab.account.features.gateway.services.GatewayTaskPolicyResolver
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
@@ -67,6 +68,7 @@ fun Route.configureGatewayRoutes(
gatewayIdentity: GatewayAccessTokenPort,
grantService: GatewayGrantService? = null,
asrStreaming: AsrStreamingService? = null,
taskPolicyResolver: GatewayTaskPolicyResolver = GatewayTaskPolicyResolver(),
) {
route("/v1/gateway") {
if (grantService != null) {
@@ -240,10 +242,16 @@ fun Route.configureGatewayRoutes(
"Only polish, ai and agent are supported",
requestId,
)
val body = runCatching {
ROUTE_JSON.decodeFromString<TextGatewayRequest>(
val (body, executionPolicy) = runCatching {
val request = ROUTE_JSON.decodeFromString<TextGatewayRequest>(
call.receiveBounded(GatewayLimits.MAX_JSON_BODY_BYTES).decodeToString(),
).also { TextRequestPolicy.validate(it, capability) }
)
TextRequestPolicy.validate(request, capability)
request to taskPolicyResolver.resolve(
capability = capability,
requestedTaskKind = request.taskKind,
requestedMaxOutputTokens = request.maxOutputTokens,
)
}
.getOrElse {
if (it is GatewayBodyTooLargeException || it is GatewayRequestTimeoutException) {
@@ -259,9 +267,10 @@ fun Route.configureGatewayRoutes(
val providerRequest = TextProviderRequest(
requestId = requestId,
capability = capability,
executionPolicy = executionPolicy,
input = body.input,
context = body.context,
maxOutputTokens = body.maxOutputTokens,
maxOutputTokens = executionPolicy.maxOutputTokens,
temperature = body.temperature,
stream = body.stream,
requestSource = body.requestSource,
@@ -0,0 +1,133 @@
package com.osglab.account.features.gateway.services
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.GatewayTaskExecutionPolicy
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
data class GatewayTaskPolicyConfig(
val polishMaxOutputTokens: Int = 512,
val transformMaxOutputTokens: Int = 2_048,
val reasoningMaxOutputTokens: Int = GatewayLimits.MAX_OUTPUT_TOKENS,
val aiReasoningEffort: GatewayReasoningEffort = GatewayReasoningEffort.HIGH,
val agentReasoningEffort: GatewayReasoningEffort = GatewayReasoningEffort.HIGH,
) {
init {
require(polishMaxOutputTokens in 1..GatewayLimits.MAX_OUTPUT_TOKENS)
require(transformMaxOutputTokens in 1..GatewayLimits.MAX_OUTPUT_TOKENS)
require(reasoningMaxOutputTokens in 1..GatewayLimits.MAX_OUTPUT_TOKENS)
}
}
/**
* Deterministic server-side task policy. It never inspects user content and
* never accepts provider parameters from the client.
*/
class GatewayTaskPolicyResolver(
private val config: GatewayTaskPolicyConfig = GatewayTaskPolicyConfig(),
) {
fun resolve(
capability: GatewayCapability,
requestedTaskKind: GatewayTaskKind?,
requestedMaxOutputTokens: Int,
): GatewayTaskExecutionPolicy {
require(requestedMaxOutputTokens in 1..GatewayLimits.MAX_OUTPUT_TOKENS) {
"maxOutputTokens is out of range"
}
val taskKind = requestedTaskKind ?: defaultTaskKind(capability)
require(taskKind in allowedTaskKinds(capability)) {
"taskKind is not supported for this capability"
}
return when (taskKind) {
GatewayTaskKind.DICTATION_POLISH,
GatewayTaskKind.EDIT_LAST_INPUT -> nonThinkingPolicy(
taskKind = taskKind,
maxOutputTokens = minOf(requestedMaxOutputTokens, config.polishMaxOutputTokens),
)
GatewayTaskKind.TRANSLATION,
GatewayTaskKind.CLIPBOARD_TRANSFORM,
GatewayTaskKind.CUSTOM_SKILL -> nonThinkingPolicy(
taskKind = taskKind,
maxOutputTokens = minOf(requestedMaxOutputTokens, config.transformMaxOutputTokens),
)
GatewayTaskKind.AI_QUESTION -> reasoningPolicy(
taskKind = taskKind,
effort = config.aiReasoningEffort,
maxOutputTokens = minOf(requestedMaxOutputTokens, config.reasoningMaxOutputTokens),
)
GatewayTaskKind.AGENT_PLANNING -> reasoningPolicy(
taskKind = taskKind,
effort = config.agentReasoningEffort,
maxOutputTokens = minOf(requestedMaxOutputTokens, config.reasoningMaxOutputTokens),
)
}
}
private fun nonThinkingPolicy(
taskKind: GatewayTaskKind,
maxOutputTokens: Int,
) = GatewayTaskExecutionPolicy(
taskKind = taskKind,
modelProfile = GatewayModelProfile.LOW_LATENCY,
thinking = GatewayThinkingMode.DISABLED,
reasoningEffort = null,
webSearch = GatewayWebSearchMode.DISABLED,
tools = GatewayToolsMode.DISABLED,
allowEmptyContentRetry = false,
maxOutputTokens = maxOutputTokens,
)
private fun reasoningPolicy(
taskKind: GatewayTaskKind,
effort: GatewayReasoningEffort,
maxOutputTokens: Int,
) = GatewayTaskExecutionPolicy(
taskKind = taskKind,
modelProfile = GatewayModelProfile.REASONING,
thinking = GatewayThinkingMode.ENABLED,
reasoningEffort = effort,
webSearch = GatewayWebSearchMode.DISABLED,
tools = GatewayToolsMode.DISABLED,
allowEmptyContentRetry = true,
maxOutputTokens = maxOutputTokens,
)
private fun defaultTaskKind(capability: GatewayCapability): GatewayTaskKind =
when (capability) {
GatewayCapability.POLISH -> GatewayTaskKind.DICTATION_POLISH
GatewayCapability.AI -> GatewayTaskKind.AI_QUESTION
GatewayCapability.AGENT -> GatewayTaskKind.AGENT_PLANNING
GatewayCapability.ASR -> throw IllegalArgumentException("ASR does not support text tasks")
}
private fun allowedTaskKinds(capability: GatewayCapability): Set<GatewayTaskKind> =
when (capability) {
GatewayCapability.POLISH -> POLISH_TASKS
GatewayCapability.AI -> AI_TASKS
GatewayCapability.AGENT -> AGENT_TASKS
GatewayCapability.ASR -> emptySet()
}
private companion object {
val POLISH_TASKS = setOf(
GatewayTaskKind.DICTATION_POLISH,
GatewayTaskKind.TRANSLATION,
GatewayTaskKind.EDIT_LAST_INPUT,
)
val AI_TASKS = setOf(
GatewayTaskKind.AI_QUESTION,
GatewayTaskKind.CLIPBOARD_TRANSFORM,
GatewayTaskKind.CUSTOM_SKILL,
)
val AGENT_TASKS = setOf(GatewayTaskKind.AGENT_PLANNING)
}
}
+1
View File
@@ -68,6 +68,7 @@ app:
endpoint: "$DEEPSEEK_ENDPOINT:https://api.deepseek.com/v1"
apiKey: "$DEEPSEEK_API_KEY:"
model: "$DEEPSEEK_MODEL:deepseek-v4-flash"
reasoningModel: "$DEEPSEEK_REASONING_MODEL:"
integrity:
enforceDeviceCheck: "$ENFORCE_DEVICE_CHECK:false"
enforceAppAttest: "$ENFORCE_APP_ATTEST:false"
@@ -2,6 +2,8 @@ package com.osglab.account.features.gateway.models
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import kotlinx.serialization.json.Json
class TextRequestPolicyTest : StringSpec({
"rejects blank and oversized input" {
@@ -54,4 +56,20 @@ class TextRequestPolicyTest : StringSpec({
TextRequestPolicy.validate(request, GatewayCapability.POLISH)
}
}
"keeps taskKind wire values stable" {
val values = mapOf(
"dictation_polish" to GatewayTaskKind.DICTATION_POLISH,
"translation" to GatewayTaskKind.TRANSLATION,
"edit_last_input" to GatewayTaskKind.EDIT_LAST_INPUT,
"ai_question" to GatewayTaskKind.AI_QUESTION,
"clipboard_transform" to GatewayTaskKind.CLIPBOARD_TRANSFORM,
"custom_skill" to GatewayTaskKind.CUSTOM_SKILL,
"agent_planning" to GatewayTaskKind.AGENT_PLANNING,
)
values.forEach { (wireValue, taskKind) ->
Json.decodeFromString<GatewayTaskKind>("\"$wireValue\"") shouldBe taskKind
}
}
})
@@ -1,14 +1,17 @@
package com.osglab.account.features.gateway.providers.deepseek
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayTaskKind
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.shouldBe
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.engine.mock.toByteArray
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
@@ -16,6 +19,8 @@ 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.jsonObject
import kotlinx.serialization.json.jsonPrimitive
class DeepSeekClientTest : StringSpec({
"prefers provider token usage" {
@@ -79,6 +84,55 @@ class DeepSeekClientTest : StringSpec({
}
}
"serializes disabled thinking for polish without search or tools" {
var requestBody = ""
val client = client(
"""{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":10,"completion_tokens":3,"total_tokens":13}}""",
onRequest = { requestBody = it },
)
try {
KtorDeepSeekClient(client, CONFIG).complete(
request(capability = GatewayCapability.POLISH),
DISCARD_OUTPUT,
)
val payload = Json.parseToJsonElement(requestBody).jsonObject
payload.getValue("model").jsonPrimitive.content shouldBe "configured-fast-model"
payload.getValue("thinking").jsonObject.getValue("type").jsonPrimitive.content shouldBe
"disabled"
payload["reasoning_effort"] shouldBe null
payload["web_search"] shouldBe null
payload["enable_search"] shouldBe null
payload["tools"] shouldBe null
payload["tool_choice"] shouldBe null
} finally {
client.close()
}
}
"serializes explicit high-effort thinking and the reasoning model for AI" {
var requestBody = ""
val client = client(
"""{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":10,"completion_tokens":3,"total_tokens":13}}""",
onRequest = { requestBody = it },
)
try {
KtorDeepSeekClient(client, CONFIG).complete(request(), DISCARD_OUTPUT)
val payload = Json.parseToJsonElement(requestBody).jsonObject
payload.getValue("model").jsonPrimitive.content shouldBe "configured-reasoning-model"
payload.getValue("thinking").jsonObject.getValue("type").jsonPrimitive.content shouldBe
"enabled"
payload.getValue("reasoning_effort").jsonPrimitive.content shouldBe "high"
payload["web_search"] shouldBe null
payload["enable_search"] shouldBe null
payload["tools"] shouldBe null
payload["tool_choice"] shouldBe null
} finally {
client.close()
}
}
"retries one buffered empty result and returns the successful retry" {
var attempts = 0
val provider = DeepSeekProvider(
@@ -117,6 +171,22 @@ class DeepSeekClientTest : StringSpec({
attempts shouldBe 2
}
"does not retry an empty buffered polish result" {
var attempts = 0
val provider = DeepSeekProvider(
DeepSeekClient { _, _ ->
attempts += 1
throw DeepSeekEmptyResultException()
},
)
shouldThrow<DeepSeekEmptyResultException> {
provider.execute(request(capability = GatewayCapability.POLISH), DISCARD_OUTPUT)
}
attempts shouldBe 1
}
"does not retry an empty streaming result after output may have started" {
var attempts = 0
val provider = DeepSeekProvider(
@@ -235,8 +305,10 @@ class DeepSeekClientTest : StringSpec({
private fun client(
responseBody: String,
contentType: ContentType = ContentType.Application.Json,
onRequest: suspend (String) -> Unit = {},
) = HttpClient(
MockEngine {
MockEngine { request ->
onRequest(request.body.toByteArray().decodeToString())
respond(
content = responseBody,
status = HttpStatusCode.OK,
@@ -249,21 +321,30 @@ private fun client(
}
}
private fun request(capability: GatewayCapability = GatewayCapability.AI) = TextProviderRequest(
requestId = "deepseek-request",
capability = capability,
input = "hello",
context = null,
maxOutputTokens = 32,
temperature = 0.2,
stream = false,
)
private fun request(
capability: GatewayCapability = GatewayCapability.AI,
taskKind: GatewayTaskKind? = null,
): TextProviderRequest {
val executionPolicy = TASK_POLICY.resolve(capability, taskKind, 32)
return TextProviderRequest(
requestId = "deepseek-request",
capability = capability,
executionPolicy = executionPolicy,
input = "hello",
context = null,
maxOutputTokens = executionPolicy.maxOutputTokens,
temperature = 0.2,
stream = false,
)
}
private val CONFIG = DeepSeekConfig(
endpoint = "https://api.deepseek.com/v1",
apiKey = "test-key",
model = "configured-model",
model = "configured-fast-model",
reasoningModel = "configured-reasoning-model",
)
private val TASK_POLICY = GatewayTaskPolicyResolver()
private val SUCCESS_USAGE = com.osglab.account.features.gateway.models.ProviderUsage(
meter = com.osglab.account.features.gateway.models.UsageMeter.LLM_TOKEN,
units = 13,
@@ -3,10 +3,13 @@ package com.osglab.account.features.gateway.routes
import com.osglab.account.features.gateway.models.GatewayCapability
import com.osglab.account.features.gateway.models.GatewayPrincipal
import com.osglab.account.features.gateway.models.GatewayRequestSource
import com.osglab.account.features.gateway.models.GatewayTaskKind
import com.osglab.account.features.gateway.models.GatewayThinkingMode
import com.osglab.account.features.gateway.models.ProviderDescriptor
import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderRequest
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 com.osglab.account.features.gateway.ports.CreditReservation
import com.osglab.account.features.gateway.ports.CreditReservationPort
@@ -17,7 +20,9 @@ import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCatalog
import com.osglab.account.features.gateway.services.GatewayService
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.shouldBe
import io.ktor.client.statement.bodyAsText
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
@@ -71,6 +76,65 @@ class GatewayRequestIdTest : StringSpec({
provider.lastRequestSource shouldBe GatewayRequestSource.HOTWORD
}
}
"defaults legacy polish requests to the server-controlled low-latency policy" {
val provider = RequestIdProvider()
testApplication {
application { gatewayTestApplication(provider) }
val response = client.post("/v1/gateway/llm/polish") {
header("X-Request-ID", "legacy-polish-123")
contentType(ContentType.Application.Json)
setBody("""{"input":"hello","maxOutputTokens":4096}""")
}
response.status shouldBe HttpStatusCode.OK
provider.lastTextRequest?.executionPolicy?.taskKind shouldBe
GatewayTaskKind.DICTATION_POLISH
provider.lastTextRequest?.executionPolicy?.thinking shouldBe
GatewayThinkingMode.DISABLED
provider.lastTextRequest?.maxOutputTokens shouldBe 512
}
}
"rejects a capability and taskKind mismatch before billing or provider execution" {
val provider = RequestIdProvider()
testApplication {
application { gatewayTestApplication(provider) }
val response = client.post("/v1/gateway/llm/polish") {
header("X-Request-ID", "invalid-task-123")
contentType(ContentType.Application.Json)
setBody("""{"input":"hello","taskKind":"agent_planning"}""")
}
response.status shouldBe HttpStatusCode.BadRequest
response.bodyAsText() shouldContain """"code":"invalid_request""""
provider.calls shouldBe 0
}
}
"applies an explicit AI transform task without allowing thinking" {
val provider = RequestIdProvider()
testApplication {
application { gatewayTestApplication(provider) }
val response = client.post("/v1/gateway/llm/ai") {
header("X-Request-ID", "clipboard-task-123")
contentType(ContentType.Application.Json)
setBody("""{"input":"hello","taskKind":"clipboard_transform"}""")
}
response.status shouldBe HttpStatusCode.OK
provider.lastTextRequest?.executionPolicy?.taskKind shouldBe
GatewayTaskKind.CLIPBOARD_TRANSFORM
provider.lastTextRequest?.executionPolicy?.thinking shouldBe
GatewayThinkingMode.DISABLED
}
}
})
private fun io.ktor.server.application.Application.gatewayTestApplication(provider: RequestIdProvider) {
@@ -96,10 +160,15 @@ private class RequestIdProvider : GatewayProvider {
var calls = 0
var lastRequestId: String? = null
var lastRequestSource: GatewayRequestSource? = null
var lastTextRequest: TextProviderRequest? = null
override val descriptor = ProviderDescriptor(
id = "request-id-provider",
capabilities = setOf(GatewayCapability.AI),
capabilities = setOf(
GatewayCapability.POLISH,
GatewayCapability.AI,
GatewayCapability.AGENT,
),
streaming = true,
usageMeter = UsageMeter.LLM_TOKEN,
)
@@ -108,6 +177,7 @@ private class RequestIdProvider : GatewayProvider {
calls += 1
lastRequestId = request.requestId
lastRequestSource = request.requestSource
lastTextRequest = request as? TextProviderRequest
output.emit("""{"result":"ok"}""".encodeToByteArray())
return ProviderUsage(
meter = UsageMeter.LLM_TOKEN,
@@ -158,5 +228,9 @@ private object RequestIdUsage : GatewayUsagePort {
private val REQUEST_ID_PRINCIPAL = GatewayPrincipal(
userId = "00000000-0000-0000-0000-000000000001",
grantId = "00000000-0000-0000-0000-000000000003",
scopes = setOf(GatewayCapability.AI),
scopes = setOf(
GatewayCapability.POLISH,
GatewayCapability.AI,
GatewayCapability.AGENT,
),
)
@@ -114,15 +114,23 @@ private fun replayService(
asrProviderTimeoutMillis = timeoutMillis,
)
private fun replayRequest() = TextProviderRequest(
requestId = REPLAY_ID,
capability = GatewayCapability.AI,
input = "hello",
context = null,
maxOutputTokens = 32,
temperature = 0.2,
stream = false,
)
private fun replayRequest(): TextProviderRequest {
val executionPolicy = GatewayTaskPolicyResolver().resolve(
GatewayCapability.AI,
requestedTaskKind = null,
requestedMaxOutputTokens = 32,
)
return TextProviderRequest(
requestId = REPLAY_ID,
capability = GatewayCapability.AI,
executionPolicy = executionPolicy,
input = "hello",
context = null,
maxOutputTokens = executionPolicy.maxOutputTokens,
temperature = 0.2,
stream = false,
)
}
private class ReplayCredits(
private val failRelease: Boolean = false,
@@ -217,16 +217,24 @@ private fun service(
usageRecords = usageRecords,
)
private fun request(requestSource: GatewayRequestSource? = null) = TextProviderRequest(
requestId = "request-123",
capability = GatewayCapability.AI,
input = "hello",
context = null,
maxOutputTokens = 32,
temperature = 0.2,
stream = false,
requestSource = requestSource,
)
private fun request(requestSource: GatewayRequestSource? = null): TextProviderRequest {
val executionPolicy = GatewayTaskPolicyResolver().resolve(
GatewayCapability.AI,
requestedTaskKind = null,
requestedMaxOutputTokens = 32,
)
return TextProviderRequest(
requestId = "request-123",
capability = GatewayCapability.AI,
executionPolicy = executionPolicy,
input = "hello",
context = null,
maxOutputTokens = executionPolicy.maxOutputTokens,
temperature = 0.2,
stream = false,
requestSource = requestSource,
)
}
private class FakeCredits(
private val failSettle: Boolean = false,
@@ -0,0 +1,92 @@
package com.osglab.account.features.gateway.services
import com.osglab.account.features.gateway.models.GatewayCapability
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
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
class GatewayTaskPolicyResolverTest : StringSpec({
val resolver = GatewayTaskPolicyResolver()
"uses backward-compatible task defaults" {
resolver.resolve(GatewayCapability.POLISH, null, 512).taskKind shouldBe
GatewayTaskKind.DICTATION_POLISH
resolver.resolve(GatewayCapability.AI, null, 512).taskKind shouldBe
GatewayTaskKind.AI_QUESTION
resolver.resolve(GatewayCapability.AGENT, null, 512).taskKind shouldBe
GatewayTaskKind.AGENT_PLANNING
}
"forces all low-latency transform tasks to disable costly capabilities" {
val tasks = listOf(
GatewayCapability.POLISH to GatewayTaskKind.DICTATION_POLISH,
GatewayCapability.POLISH to GatewayTaskKind.TRANSLATION,
GatewayCapability.POLISH to GatewayTaskKind.EDIT_LAST_INPUT,
GatewayCapability.AI to GatewayTaskKind.CLIPBOARD_TRANSFORM,
GatewayCapability.AI to GatewayTaskKind.CUSTOM_SKILL,
)
tasks.forEach { (capability, taskKind) ->
val policy = resolver.resolve(capability, taskKind, 512)
policy.modelProfile shouldBe GatewayModelProfile.LOW_LATENCY
policy.thinking shouldBe GatewayThinkingMode.DISABLED
policy.reasoningEffort shouldBe null
policy.webSearch shouldBe GatewayWebSearchMode.DISABLED
policy.tools shouldBe GatewayToolsMode.DISABLED
policy.allowEmptyContentRetry shouldBe false
}
}
"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)
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" {
shouldThrow<IllegalArgumentException> {
resolver.resolve(
GatewayCapability.POLISH,
GatewayTaskKind.AGENT_PLANNING,
512,
)
}
shouldThrow<IllegalArgumentException> {
resolver.resolve(
GatewayCapability.AI,
GatewayTaskKind.TRANSLATION,
512,
)
}
}
"applies server output budgets" {
resolver.resolve(
GatewayCapability.POLISH,
GatewayTaskKind.DICTATION_POLISH,
4_096,
).maxOutputTokens shouldBe 512
resolver.resolve(
GatewayCapability.AI,
GatewayTaskKind.CLIPBOARD_TRANSFORM,
4_096,
).maxOutputTokens shouldBe 2_048
}
})