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:
@@ -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,
|
||||
|
||||
+74
-2
@@ -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,
|
||||
|
||||
+133
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user