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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+92
-11
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
+17
-9
@@ -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,
|
||||
|
||||
+18
-10
@@ -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,
|
||||
|
||||
+92
@@ -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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user