From 11ec34dacbd5b264650e7d93cecde2b425fdc004 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:55:05 +0800 Subject: [PATCH] Enforce deterministic gateway task policies Make the server authoritative for thinking, model, search, tools, retry, and output budgets while preserving legacy client behavior. --- .env.example | 2 + README.md | 16 ++- compose.yaml | 1 + docs/openapi.yaml | 33 ++++- .../kotlin/com/osglab/account/Application.kt | 1 + .../com/osglab/account/config/AppConfig.kt | 9 +- .../features/gateway/models/GatewayModels.kt | 77 ++++++++++ .../providers/deepseek/DeepSeekProvider.kt | 76 +++++++++- .../features/gateway/routes/GatewayRoutes.kt | 17 ++- .../services/GatewayTaskPolicyResolver.kt | 133 ++++++++++++++++++ src/main/resources/application.yaml | 1 + .../gateway/models/TextRequestPolicyTest.kt | 18 +++ .../providers/deepseek/DeepSeekClientTest.kt | 103 ++++++++++++-- .../gateway/routes/GatewayRequestIdTest.kt | 78 +++++++++- .../services/GatewayReplayStateTest.kt | 26 ++-- .../services/GatewayServiceBillingTest.kt | 28 ++-- .../services/GatewayTaskPolicyResolverTest.kt | 92 ++++++++++++ 17 files changed, 667 insertions(+), 44 deletions(-) create mode 100644 src/main/kotlin/com/osglab/account/features/gateway/services/GatewayTaskPolicyResolver.kt create mode 100644 src/test/kotlin/com/osglab/account/features/gateway/services/GatewayTaskPolicyResolverTest.kt diff --git a/.env.example b/.env.example index c83be15..a6b48f9 100644 --- a/.env.example +++ b/.env.example @@ -61,6 +61,8 @@ VOLCENGINE_ASR_ENDPOINT=wss://openspeech.bytedance.com/api/v3/sauc/bigmodel DEEPSEEK_API_KEY=replace-with-deepseek-api-key DEEPSEEK_MODEL=deepseek-v4-flash +# Optional; defaults to DEEPSEEK_MODEL when omitted. +DEEPSEEK_REASONING_MODEL= DEEPSEEK_ENDPOINT=https://api.deepseek.com/v1 SIGNUP_TRIAL_CREDITS=1000 diff --git a/README.md b/README.md index 2e3c500..ef85d15 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ Gateway execution and settlement rules: Existing `application.yaml` values can be mapped into: -- `DeepSeekConfig(endpoint, apiKey, model)` +- `DeepSeekConfig(endpoint, apiKey, model, reasoningModel)` - `VolcengineAsrConfig(endpoint, resourceId, appId, accessToken)` - `InviteWebConfig(appStoreUrl, appleAppId, universalLinkBaseUrl)` @@ -152,8 +152,16 @@ Configuration ownership: `APPLE_INTEGRITY_ENVIRONMENT`, plus the two integrity enforcement flags. - Volcengine: prefer `VOLCENGINE_API_KEY`; set the SAUC v3 `VOLCENGINE_RESOURCE_ID` and WSS `VOLCENGINE_ASR_ENDPOINT`. The legacy app ID/access token pair remains optional. -- DeepSeek: set `DEEPSEEK_API_KEY`, the provisioned `DEEPSEEK_MODEL`, and HTTPS - `DEEPSEEK_ENDPOINT`. +- DeepSeek: set `DEEPSEEK_API_KEY`, the provisioned low-latency `DEEPSEEK_MODEL`, + and HTTPS `DEEPSEEK_ENDPOINT`. `DEEPSEEK_REASONING_MODEL` is optional and + falls back to `DEEPSEEK_MODEL`. + +Gateway text requests may include the optional stable `taskKind` values documented in +`docs/openapi.yaml`. The server maps `capability + taskKind` to a deterministic execution policy; +it never infers task type from user content. Polish and transform tasks explicitly disable DeepSeek +thinking and do not retry an empty buffered result. AI questions and agent planning explicitly use +high-effort thinking. Search and tools remain disabled for every task because no safe, billable +implementation is configured. Store production values in 1Panel's secret/environment facility. The Compose environment receives them at runtime because this application does not read Docker `/run/secrets/*` files directly. @@ -236,7 +244,7 @@ Internet. - Apple: `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_CLIENT_ID`, `APPLE_PRIVATE_KEY_PEM`, `APPLE_INTEGRITY_ENVIRONMENT`. - Providers: `VOLCENGINE_API_KEY`, `VOLCENGINE_RESOURCE_ID`, `DEEPSEEK_API_KEY`, - `DEEPSEEK_MODEL`. + `DEEPSEEK_MODEL`; optionally `DEEPSEEK_REASONING_MODEL`. - Production controls: `APP_ENV=production`, `ENFORCE_DEVICE_CHECK=true`, `ENFORCE_APP_ATTEST=true`. - Optional tuning: token lifetimes, gateway grant days, credit values, binding window and pool size; diff --git a/compose.yaml b/compose.yaml index 617d7f6..12f1989 100644 --- a/compose.yaml +++ b/compose.yaml @@ -54,6 +54,7 @@ services: VOLCENGINE_ASR_ENDPOINT: ${VOLCENGINE_ASR_ENDPOINT:-wss://openspeech.bytedance.com/api/v3/sauc/bigmodel} DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:?set DeepSeek API key} DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-v4-flash} + DEEPSEEK_REASONING_MODEL: ${DEEPSEEK_REASONING_MODEL:-} DEEPSEEK_ENDPOINT: ${DEEPSEEK_ENDPOINT:-https://api.deepseek.com/v1} SIGNUP_TRIAL_CREDITS: ${SIGNUP_TRIAL_CREDITS:-1000} diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 8fc3d06..3b58cbb 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -356,6 +356,11 @@ paths: /v1/gateway/llm/{capability}: post: summary: Run a metered polish, AI, or agent request + description: | + The server deterministically selects model, thinking, search, tools, retry, + and output-budget policy from `capability` plus optional `taskKind`. It + never infers task type from `input` or `context`, and clients cannot + supply provider parameters. Search and tools are currently disabled. parameters: - $ref: "#/components/parameters/RequestId" - name: capability @@ -1374,9 +1379,35 @@ components: properties: input: { type: string, minLength: 1, maxLength: 32000 } context: { type: ["string", "null"], maxLength: 32000 } - maxOutputTokens: { type: integer, minimum: 1, maximum: 4096, default: 512 } + maxOutputTokens: + type: integer + minimum: 1 + maximum: 4096 + default: 512 + description: | + Requested output budget. The server clamps dictation polish and + edit-last-input to 512 tokens; translation, clipboard transform, + and custom skill to 2,048; and reasoning tasks to 4,096. temperature: { type: number, minimum: 0, maximum: 1, default: 0.2 } stream: { type: boolean, default: false } + taskKind: + type: ["string", "null"] + enum: + - dictation_polish + - translation + - edit_last_input + - ai_question + - clipboard_transform + - custom_skill + - agent_planning + - null + description: | + Optional deterministic task selector. Allowed combinations are: + `polish` with `dictation_polish`, `translation`, or `edit_last_input`; + `ai` with `ai_question`, `clipboard_transform`, or `custom_skill`; + and `agent` with `agent_planning`. Omission defaults respectively to + `dictation_polish`, `ai_question`, and `agent_planning`. A mismatch + returns `400 invalid_request`. requestSource: type: ["string", "null"] enum: [hotword, null] diff --git a/src/main/kotlin/com/osglab/account/Application.kt b/src/main/kotlin/com/osglab/account/Application.kt index a7e0976..861495c 100644 --- a/src/main/kotlin/com/osglab/account/Application.kt +++ b/src/main/kotlin/com/osglab/account/Application.kt @@ -609,6 +609,7 @@ private fun configuredProviders(config: AppConfig, client: HttpClient): List 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, diff --git a/src/main/kotlin/com/osglab/account/features/gateway/routes/GatewayRoutes.kt b/src/main/kotlin/com/osglab/account/features/gateway/routes/GatewayRoutes.kt index 7aadc26..e2e008a 100644 --- a/src/main/kotlin/com/osglab/account/features/gateway/routes/GatewayRoutes.kt +++ b/src/main/kotlin/com/osglab/account/features/gateway/routes/GatewayRoutes.kt @@ -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( + val (body, executionPolicy) = runCatching { + val request = ROUTE_JSON.decodeFromString( 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, diff --git a/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayTaskPolicyResolver.kt b/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayTaskPolicyResolver.kt new file mode 100644 index 0000000..72661a1 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/gateway/services/GatewayTaskPolicyResolver.kt @@ -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 = + 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) + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 28d44d2..0e54202 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -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" diff --git a/src/test/kotlin/com/osglab/account/features/gateway/models/TextRequestPolicyTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/models/TextRequestPolicyTest.kt index d45ea25..d6b78fc 100644 --- a/src/test/kotlin/com/osglab/account/features/gateway/models/TextRequestPolicyTest.kt +++ b/src/test/kotlin/com/osglab/account/features/gateway/models/TextRequestPolicyTest.kt @@ -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("\"$wireValue\"") shouldBe taskKind + } + } }) diff --git a/src/test/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekClientTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekClientTest.kt index 86e644e..22bf17e 100644 --- a/src/test/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekClientTest.kt +++ b/src/test/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekClientTest.kt @@ -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 { + 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, diff --git a/src/test/kotlin/com/osglab/account/features/gateway/routes/GatewayRequestIdTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/routes/GatewayRequestIdTest.kt index b757a2a..7d12726 100644 --- a/src/test/kotlin/com/osglab/account/features/gateway/routes/GatewayRequestIdTest.kt +++ b/src/test/kotlin/com/osglab/account/features/gateway/routes/GatewayRequestIdTest.kt @@ -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, + ), ) diff --git a/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayReplayStateTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayReplayStateTest.kt index c7eeaed..f281d41 100644 --- a/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayReplayStateTest.kt +++ b/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayReplayStateTest.kt @@ -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, diff --git a/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayServiceBillingTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayServiceBillingTest.kt index de348ce..8546e45 100644 --- a/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayServiceBillingTest.kt +++ b/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayServiceBillingTest.kt @@ -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, diff --git a/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayTaskPolicyResolverTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayTaskPolicyResolverTest.kt new file mode 100644 index 0000000..a2002d8 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/gateway/services/GatewayTaskPolicyResolverTest.kt @@ -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 { + resolver.resolve( + GatewayCapability.POLISH, + GatewayTaskKind.AGENT_PLANNING, + 512, + ) + } + shouldThrow { + 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 + } +})