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)
}
}