From 03eac719052499de63deb549fc2460be7927413d Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:43:16 +0800 Subject: [PATCH] Harden current-information search failures Preserve required-search semantics for live hints and expose stable gateway error codes without leaking provider details. --- admin-web/src/api/types.ts | 1 + docs/openapi.yaml | 13 +- .../feed/sources/BaselineHintSource.kt | 5 + .../feed/sources/GoogleFeedHintSource.kt | 3 + .../content/feed/sources/TopHubHintSource.kt | 4 + .../content/feed/sources/WeatherHintSource.kt | 2 + .../features/content/models/ContentModels.kt | 10 ++ .../gateway/providers/GatewayProvider.kt | 15 ++ .../providers/deepseek/DeepSeekProvider.kt | 16 ++- .../deepseek/DeepSeekResponsesClient.kt | 4 + .../volcengine/VolcengineAsrProvider.kt | 6 +- .../features/gateway/routes/GatewayRoutes.kt | 135 +++++++++++++----- .../content/feed/HintFeedPolicyTest.kt | 18 +++ .../content/routes/ContentRoutesTest.kt | 3 + .../providers/deepseek/DeepSeekClientTest.kt | 39 ++++- .../routes/GatewayFailureMappingTest.kt | 48 +++++++ 16 files changed, 277 insertions(+), 45 deletions(-) create mode 100644 src/test/kotlin/com/osglab/account/features/gateway/routes/GatewayFailureMappingTest.kt diff --git a/admin-web/src/api/types.ts b/admin-web/src/api/types.ts index c6bc340..c952276 100644 --- a/admin-web/src/api/types.ts +++ b/admin-web/src/api/types.ts @@ -69,6 +69,7 @@ export interface AIHintCard { locale: "zh" | "en"; conditions: string[]; metadata?: Record; + taskKind?: "ai_question" | "current_information_question"; } export interface AdminHintPack { diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 6731e21..c066e23 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -480,8 +480,10 @@ paths: and output-budget policy from `capability` plus optional `taskKind`. It never infers task type from `input` or `context`, and clients cannot supply provider parameters. Ordinary AI questions allow model-selected - server-side web search; current-information questions require it. Other - tools remain disabled. Search has no separate credit fee; settlement + server-side web search and retain thinking if search is unavailable. + Current-information questions require search and never degrade to an + unverified offline answer. Other tools remain disabled. Search has no + separate credit fee; settlement uses the provider-reported LLM input and output Token counts, including any search context charged by the provider. For account grants, `oobe` is accepted only for dictation polish and the @@ -1785,6 +1787,13 @@ components: type: array maxItems: 20 items: { type: string, minLength: 1, maxLength: 64 } + taskKind: + type: string + enum: [ai_question, current_information_question] + default: ai_question + description: | + AI execution intent for this card. Current-information cards require + server-side web search; ordinary cards match hold-to-talk AI policy. metadata: type: object additionalProperties: true diff --git a/src/main/kotlin/com/osglab/account/features/content/feed/sources/BaselineHintSource.kt b/src/main/kotlin/com/osglab/account/features/content/feed/sources/BaselineHintSource.kt index baba574..4bbdb27 100644 --- a/src/main/kotlin/com/osglab/account/features/content/feed/sources/BaselineHintSource.kt +++ b/src/main/kotlin/com/osglab/account/features/content/feed/sources/BaselineHintSource.kt @@ -2,6 +2,7 @@ package com.osglab.account.features.content.feed.sources import com.osglab.account.features.content.feed.HintFeedSettings import com.osglab.account.features.content.models.AIHintCardDto +import com.osglab.account.features.content.models.AIHintTaskKind import kotlinx.serialization.json.buildJsonObject class BaselineHintSource : HintFeedSource { @@ -31,6 +32,7 @@ private val CHINESE = listOf( category = "economy", priority = 42, locale = "zh", + taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION, ), card( id = "cap-zh-clipboard-reply", @@ -68,6 +70,7 @@ private val ENGLISH = listOf( category = "economy", priority = 42, locale = "en", + taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION, ), card( id = "cap-en-clipboard-reply", @@ -97,6 +100,7 @@ private fun card( priority: Int, locale: String, conditions: List = emptyList(), + taskKind: AIHintTaskKind = AIHintTaskKind.AI_QUESTION, ) = AIHintCardDto( id = id, text = text, @@ -107,4 +111,5 @@ private fun card( locale = locale, conditions = conditions, metadata = buildJsonObject {}, + taskKind = taskKind, ) diff --git a/src/main/kotlin/com/osglab/account/features/content/feed/sources/GoogleFeedHintSource.kt b/src/main/kotlin/com/osglab/account/features/content/feed/sources/GoogleFeedHintSource.kt index e483de4..73a8b53 100644 --- a/src/main/kotlin/com/osglab/account/features/content/feed/sources/GoogleFeedHintSource.kt +++ b/src/main/kotlin/com/osglab/account/features/content/feed/sources/GoogleFeedHintSource.kt @@ -5,6 +5,7 @@ import com.osglab.account.features.content.feed.HintFeedSettings import com.osglab.account.features.content.feed.csvValues import com.osglab.account.features.content.feed.stableHintId import com.osglab.account.features.content.models.AIHintCardDto +import com.osglab.account.features.content.models.AIHintTaskKind import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.plugins.timeout @@ -44,6 +45,7 @@ class GoogleFeedHintSource( priority = 66, source = "google-trends-rss", locale = "en", + taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION, metadata = buildJsonObject { put("geo", geo) put("query", title) @@ -65,6 +67,7 @@ class GoogleFeedHintSource( priority = 58, source = "google-news-rss", locale = "en", + taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION, metadata = buildJsonObject { put("title", title) }, ) }.take(4) diff --git a/src/main/kotlin/com/osglab/account/features/content/feed/sources/TopHubHintSource.kt b/src/main/kotlin/com/osglab/account/features/content/feed/sources/TopHubHintSource.kt index cd47030..aca6d78 100644 --- a/src/main/kotlin/com/osglab/account/features/content/feed/sources/TopHubHintSource.kt +++ b/src/main/kotlin/com/osglab/account/features/content/feed/sources/TopHubHintSource.kt @@ -4,6 +4,7 @@ import com.osglab.account.features.content.feed.HintCardPolicy import com.osglab.account.features.content.feed.HintFeedSettings import com.osglab.account.features.content.feed.stableHintId import com.osglab.account.features.content.models.AIHintCardDto +import com.osglab.account.features.content.models.AIHintTaskKind import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.plugins.timeout @@ -73,6 +74,7 @@ class TopHubHintSource( priority = 78, source = "tophub-daily", locale = "zh", + taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION, metadata = buildJsonObject { data.string("day")?.let { put("day", it) } put("date", day) @@ -106,6 +108,7 @@ class TopHubHintSource( priority = 74, source = "tophub-daily", locale = "zh", + taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION, metadata = item.metadata("title" to title, "url" to item.string("url")), ) } @@ -202,6 +205,7 @@ class TopHubHintSource( priority = priority, source = source, locale = "zh", + taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION, metadata = metadata, ) diff --git a/src/main/kotlin/com/osglab/account/features/content/feed/sources/WeatherHintSource.kt b/src/main/kotlin/com/osglab/account/features/content/feed/sources/WeatherHintSource.kt index de4727d..55162cf 100644 --- a/src/main/kotlin/com/osglab/account/features/content/feed/sources/WeatherHintSource.kt +++ b/src/main/kotlin/com/osglab/account/features/content/feed/sources/WeatherHintSource.kt @@ -5,6 +5,7 @@ import com.osglab.account.features.content.feed.HintFeedSettings import com.osglab.account.features.content.feed.HintWeatherCity import com.osglab.account.features.content.feed.parseWeatherCities import com.osglab.account.features.content.models.AIHintCardDto +import com.osglab.account.features.content.models.AIHintTaskKind import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.plugins.timeout @@ -77,6 +78,7 @@ class WeatherHintSource( source = id, locale = locale, conditions = listOf("geo_optional"), + taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION, metadata = buildJsonObject { put("city", city.name) put("lat", city.latitude) diff --git a/src/main/kotlin/com/osglab/account/features/content/models/ContentModels.kt b/src/main/kotlin/com/osglab/account/features/content/models/ContentModels.kt index 2890153..183fa77 100644 --- a/src/main/kotlin/com/osglab/account/features/content/models/ContentModels.kt +++ b/src/main/kotlin/com/osglab/account/features/content/models/ContentModels.kt @@ -87,6 +87,15 @@ data class SkillCatalogRecord( val skills: List, ) +@Serializable +enum class AIHintTaskKind { + @SerialName("ai_question") + AI_QUESTION, + + @SerialName("current_information_question") + CURRENT_INFORMATION_QUESTION, +} + @Serializable data class AIHintCardDto( val id: String, @@ -99,6 +108,7 @@ data class AIHintCardDto( val locale: String, val conditions: List = emptyList(), val metadata: JsonObject? = null, + val taskKind: AIHintTaskKind = AIHintTaskKind.AI_QUESTION, ) @Serializable diff --git a/src/main/kotlin/com/osglab/account/features/gateway/providers/GatewayProvider.kt b/src/main/kotlin/com/osglab/account/features/gateway/providers/GatewayProvider.kt index 7a0e6ed..a805f74 100644 --- a/src/main/kotlin/com/osglab/account/features/gateway/providers/GatewayProvider.kt +++ b/src/main/kotlin/com/osglab/account/features/gateway/providers/GatewayProvider.kt @@ -36,6 +36,21 @@ class ProviderCatalog( class UnsupportedGatewayCapabilityException(capability: String) : IllegalArgumentException("No gateway provider is configured for capability '$capability'") +/** + * Provider configuration or credentials are unavailable. The route exposes + * only a stable service error and never leaks configuration details. + */ +open class ProviderUnavailableException(message: String) : RuntimeException(message) + +/** + * The provider rejected or failed an upstream request. HTTP status is retained + * only for safe error classification; provider response bodies remain private. + */ +open class ProviderUpstreamException( + message: String, + val upstreamStatus: Int? = null, +) : RuntimeException(message) + /** * Upstream returned an invalid metering/result envelope. Gateway orchestration * treats this as provider failure and releases the reservation. diff --git a/src/main/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekProvider.kt b/src/main/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekProvider.kt index 45de7de..9d263bf 100644 --- a/src/main/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekProvider.kt +++ b/src/main/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekProvider.kt @@ -20,6 +20,8 @@ import com.osglab.account.features.gateway.models.TextProviderRequest import com.osglab.account.features.gateway.models.UsageMeter import com.osglab.account.features.gateway.providers.GatewayProvider import com.osglab.account.features.gateway.providers.ProviderCompletionException +import com.osglab.account.features.gateway.providers.ProviderUnavailableException +import com.osglab.account.features.gateway.providers.ProviderUpstreamException import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.bearerAuth @@ -244,7 +246,10 @@ class KtorDeepSeekClient( if (!response.status.isSuccess()) { // Consume but never log or persist a provider body. runCatching { response.body().readBounded() } - throw DeepSeekProviderException("DeepSeek returned HTTP ${response.status.value}") + throw DeepSeekProviderException( + "DeepSeek returned HTTP ${response.status.value}", + response.status.value, + ) } val expectedContentType = if (request.stream) { ContentType.Text.EventStream @@ -652,15 +657,18 @@ private data class ResponseFormat( val type: String, ) -class DeepSeekConfigurationException(message: String) : IllegalStateException(message) +class DeepSeekConfigurationException(message: String) : ProviderUnavailableException(message) -class DeepSeekProviderException(message: String) : RuntimeException(message) +class DeepSeekProviderException( + message: String, + upstreamStatus: Int? = null, +) : ProviderUpstreamException(message, upstreamStatus) class DeepSeekUsageException(message: String) : ProviderCompletionException(message) class DeepSeekEmptyResultException( val finishReason: String = "missing", val reasoningContentPresent: Boolean = false, val usagePresent: Boolean = false, -) : RuntimeException("DeepSeek returned an empty result") +) : ProviderCompletionException("DeepSeek returned an empty result") private data class DeepSeekUsage( val total: Long, diff --git a/src/main/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekResponsesClient.kt b/src/main/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekResponsesClient.kt index a585be9..f76933e 100644 --- a/src/main/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekResponsesClient.kt +++ b/src/main/kotlin/com/osglab/account/features/gateway/providers/deepseek/DeepSeekResponsesClient.kt @@ -79,6 +79,9 @@ internal class DeepSeekSearchFallbackClient( request.executionPolicy.webSearch.name, failure::class.simpleName ?: "Exception", ) + if (request.executionPolicy.webSearch == GatewayWebSearchMode.REQUIRED) { + throw failure + } return fallback.complete( request.copy( executionPolicy = request.executionPolicy.copy( @@ -146,6 +149,7 @@ internal class KtorDeepSeekResponsesClient( runCatching { response.body().readBounded() } throw DeepSeekProviderException( "DeepSeek Responses returned HTTP ${response.status.value}", + response.status.value, ) } val responseContentType = response.headers[HttpHeaders.ContentType] diff --git a/src/main/kotlin/com/osglab/account/features/gateway/providers/volcengine/VolcengineAsrProvider.kt b/src/main/kotlin/com/osglab/account/features/gateway/providers/volcengine/VolcengineAsrProvider.kt index ae08fd2..2fcd403 100644 --- a/src/main/kotlin/com/osglab/account/features/gateway/providers/volcengine/VolcengineAsrProvider.kt +++ b/src/main/kotlin/com/osglab/account/features/gateway/providers/volcengine/VolcengineAsrProvider.kt @@ -14,6 +14,8 @@ import com.osglab.account.features.gateway.models.ProviderUsage import com.osglab.account.features.gateway.models.UsageMeter import com.osglab.account.features.gateway.providers.GatewayProvider import com.osglab.account.features.gateway.providers.ProviderCompletionException +import com.osglab.account.features.gateway.providers.ProviderUnavailableException +import com.osglab.account.features.gateway.providers.ProviderUpstreamException import io.ktor.client.HttpClient import io.ktor.client.plugins.websocket.webSocket import io.ktor.http.Url @@ -353,9 +355,9 @@ private data class RecognitionOptions( val showUtterances: Boolean = true, ) -class VolcengineConfigurationException(message: String) : IllegalStateException(message) +class VolcengineConfigurationException(message: String) : ProviderUnavailableException(message) -class VolcengineProviderException(message: String) : RuntimeException(message) +class VolcengineProviderException(message: String) : ProviderUpstreamException(message) class VolcengineUsageException(message: String) : ProviderCompletionException(message) internal fun extractFinalDuration( 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 cd5cd5f..59dfc76 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 @@ -24,6 +24,9 @@ import com.osglab.account.features.gateway.ports.GatewayIdentityPort import com.osglab.account.features.gateway.ports.GatewayPrincipalResolver import com.osglab.account.features.gateway.ports.GatewayRequestAlreadyClaimedException import com.osglab.account.features.gateway.providers.UnsupportedGatewayCapabilityException +import com.osglab.account.features.gateway.providers.ProviderCompletionException +import com.osglab.account.features.gateway.providers.ProviderUnavailableException +import com.osglab.account.features.gateway.providers.ProviderUpstreamException import com.osglab.account.features.gateway.services.GatewayAccessDeniedException import com.osglab.account.features.gateway.services.ComplimentaryRequestUnavailableException import com.osglab.account.features.gateway.services.GatewayGrantService @@ -31,6 +34,8 @@ import com.osglab.account.features.gateway.services.GatewayRefreshTokenInvalidEx 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 com.osglab.account.features.gateway.services.GatewayUsagePolicyException +import com.osglab.account.features.credits.domain.InsufficientCredits import com.osglab.account.features.oobe.OobeFeatureAlreadyUsedException import com.osglab.account.features.oobe.OobeRequestAlreadyClaimedException import io.ktor.http.ContentType @@ -63,6 +68,7 @@ import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withTimeout import kotlinx.io.readByteArray +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json fun Route.configureGatewayRoutes( @@ -311,10 +317,21 @@ fun Route.configureGatewayRoutes( flush() }) } catch (failure: Throwable) { - if (failure is CancellationException) throw failure + if (failure is CancellationException && + failure !is TimeoutCancellationException + ) { + throw failure + } // The response may already be committed. Emit metadata only. - val errorEvent = - "event: gateway_error\ndata: {\"code\":\"provider_error\",\"requestId\":\"$requestId\"}\n\n" + val descriptor = gatewayFailureDescriptor(failure) + val payload = ROUTE_JSON.encodeToString( + GatewayErrorResponse( + descriptor.code, + descriptor.message, + requestId, + ), + ) + val errorEvent = "event: gateway_error\ndata: $payload\n\n" writeFully(errorEvent.encodeToByteArray()) flush() } @@ -440,101 +457,151 @@ private suspend fun ApplicationCall.respondGatewayFailure( failure: Throwable, requestId: String, ) { + val descriptor = gatewayFailureDescriptor(failure) + respondGatewayError( + descriptor.status, + descriptor.code, + descriptor.message, + requestId, + ) +} + +internal data class GatewayFailureDescriptor( + val status: HttpStatusCode, + val code: String, + val message: String, +) + +internal fun gatewayFailureDescriptor(failure: Throwable): GatewayFailureDescriptor = when (failure) { - is GatewayRequestAlreadyClaimedException -> respondGatewayError( + is GatewayRequestAlreadyClaimedException -> GatewayFailureDescriptor( HttpStatusCode.Conflict, "request_already_claimed", "This account request ID is already ${failure.state.name.lowercase()}", - requestId, ) - is ComplimentaryRequestUnavailableException -> respondGatewayError( + is ComplimentaryRequestUnavailableException -> GatewayFailureDescriptor( HttpStatusCode.Conflict, "oobe_already_used", "The complimentary OOBE request has already been used", - requestId, ) - is OobeFeatureAlreadyUsedException -> respondGatewayError( + is OobeFeatureAlreadyUsedException -> GatewayFailureDescriptor( HttpStatusCode.Conflict, "oobe_feature_already_used", "This OOBE feature has already been used successfully in this session", - requestId, ) - is OobeRequestAlreadyClaimedException -> respondGatewayError( + is OobeRequestAlreadyClaimedException -> GatewayFailureDescriptor( HttpStatusCode.Conflict, "oobe_request_replayed", "This OOBE request ID has already been used", - requestId, ) - is GatewayBodyTooLargeException -> respondGatewayError( + is GatewayBodyTooLargeException -> GatewayFailureDescriptor( HttpStatusCode.PayloadTooLarge, "request_too_large", "Request body exceeds the gateway limit", - requestId, ) - is GatewayRequestTimeoutException -> respondGatewayError( + is GatewayRequestTimeoutException -> GatewayFailureDescriptor( HttpStatusCode.RequestTimeout, "request_timeout", "Request body was not received within the time limit", - requestId, ) - is GatewayAccessDeniedException -> respondGatewayError( + is GatewayAccessDeniedException -> GatewayFailureDescriptor( HttpStatusCode.Forbidden, "gateway_grant_denied", "Gateway access is not granted", - requestId, ) is GatewayRefreshTokenInvalidException, - is GatewayRefreshTokenReuseException -> respondGatewayError( + is GatewayRefreshTokenReuseException -> GatewayFailureDescriptor( HttpStatusCode.Unauthorized, "invalid_gateway_refresh", "Gateway refresh token is invalid", - requestId, ) - is AsrConcurrencyLimitException -> respondGatewayError( + is InsufficientCredits -> GatewayFailureDescriptor( + HttpStatusCode.PaymentRequired, + "insufficient_credits", + "The account does not have enough credits", + ) + + is AsrConcurrencyLimitException -> GatewayFailureDescriptor( HttpStatusCode.TooManyRequests, "asr_concurrency_limit", "Too many concurrent ASR sessions", - requestId, ) is AsrSessionNotFoundException, - is AsrSessionAlreadyUsedException -> respondGatewayError( + is AsrSessionAlreadyUsedException -> GatewayFailureDescriptor( HttpStatusCode.NotFound, "asr_session_unavailable", "ASR session is unavailable", - requestId, ) - is UnsupportedGatewayCapabilityException -> respondGatewayError( + is TimeoutCancellationException -> GatewayFailureDescriptor( + HttpStatusCode.GatewayTimeout, + "provider_timeout", + "The managed provider timed out", + ) + + is UnsupportedGatewayCapabilityException, + is ProviderUnavailableException -> GatewayFailureDescriptor( HttpStatusCode.ServiceUnavailable, "provider_unavailable", - "No provider is configured for this capability", - requestId, + "The managed provider is unavailable", ) - is IllegalArgumentException -> respondGatewayError( + is ProviderUpstreamException -> + when (failure.upstreamStatus) { + 408, 504 -> GatewayFailureDescriptor( + HttpStatusCode.GatewayTimeout, + "provider_timeout", + "The managed provider timed out", + ) + + 429 -> GatewayFailureDescriptor( + HttpStatusCode.ServiceUnavailable, + "provider_rate_limited", + "The managed provider is temporarily busy", + ) + + 401, 403, 503 -> GatewayFailureDescriptor( + HttpStatusCode.ServiceUnavailable, + "provider_unavailable", + "The managed provider is unavailable", + ) + + else -> GatewayFailureDescriptor( + HttpStatusCode.BadGateway, + "provider_failure", + "The managed provider request failed", + ) + } + + is ProviderCompletionException, + is GatewayUsagePolicyException, + is GatewayOutputLimitException -> GatewayFailureDescriptor( + HttpStatusCode.BadGateway, + "provider_invalid_response", + "The managed provider returned an invalid response", + ) + + is IllegalArgumentException -> GatewayFailureDescriptor( HttpStatusCode.BadRequest, "invalid_request", failure.message ?: "Request is invalid", - requestId, ) - else -> respondGatewayError( - HttpStatusCode.BadGateway, - "gateway_failure", - "The managed provider request failed", - requestId, + else -> GatewayFailureDescriptor( + HttpStatusCode.InternalServerError, + "internal_failure", + "The managed gateway could not complete the request", ) } -} private suspend fun ApplicationCall.respondGatewayError( status: HttpStatusCode, diff --git a/src/test/kotlin/com/osglab/account/features/content/feed/HintFeedPolicyTest.kt b/src/test/kotlin/com/osglab/account/features/content/feed/HintFeedPolicyTest.kt index 10274cc..b1db691 100644 --- a/src/test/kotlin/com/osglab/account/features/content/feed/HintFeedPolicyTest.kt +++ b/src/test/kotlin/com/osglab/account/features/content/feed/HintFeedPolicyTest.kt @@ -3,6 +3,7 @@ package com.osglab.account.features.content.feed import com.osglab.account.features.content.feed.sources.BaselineHintSource import com.osglab.account.features.content.feed.sources.HintFeedGenerationContext import com.osglab.account.features.content.models.AIHintCardDto +import com.osglab.account.features.content.models.AIHintTaskKind import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.shouldBe @@ -54,6 +55,23 @@ class HintFeedPolicyTest : FunSpec({ "cap-en-clipboard-translate", ) } + + test("baseline marks only current-information cards as requiring online search") { + val source = BaselineHintSource() + val cards = source.fetch( + "zh", + HintFeedGenerationContext( + generatedAt = Instant.parse("2026-08-21T00:00:00Z"), + localDate = LocalDate.parse("2026-08-21"), + ), + settings(), + ).associateBy(AIHintCardDto::id) + + cards.getValue("cap-zh-stocks").taskKind shouldBe + AIHintTaskKind.CURRENT_INFORMATION_QUESTION + cards.getValue("cap-zh-encyclopedia").taskKind shouldBe + AIHintTaskKind.AI_QUESTION + } }) private fun hint(id: String, text: String, priority: Int) = diff --git a/src/test/kotlin/com/osglab/account/features/content/routes/ContentRoutesTest.kt b/src/test/kotlin/com/osglab/account/features/content/routes/ContentRoutesTest.kt index 91b6792..bbac149 100644 --- a/src/test/kotlin/com/osglab/account/features/content/routes/ContentRoutesTest.kt +++ b/src/test/kotlin/com/osglab/account/features/content/routes/ContentRoutesTest.kt @@ -12,6 +12,7 @@ import com.osglab.account.features.content.feed.HintFeedGenerationStatusResponse import com.osglab.account.features.content.feed.HintFeedPackGenerationResult import com.osglab.account.features.content.feed.HintFeedService import com.osglab.account.features.content.models.AIHintCardDto +import com.osglab.account.features.content.models.AIHintTaskKind import com.osglab.account.features.content.models.CreateOfficialSkillRequest import com.osglab.account.features.content.models.SkillLocalizationDto import com.osglab.account.features.content.models.SkillLocalizationsDto @@ -85,6 +86,7 @@ class ContentRoutesTest { source = "official", locale = "en", conditions = listOf("idle"), + taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION, ), ), ), @@ -110,6 +112,7 @@ class ContentRoutesTest { pack.status shouldBe HttpStatusCode.OK pack.bodyAsText() shouldContain """"version":1""" pack.bodyAsText() shouldContain """"text":"Daily brief"""" + pack.bodyAsText() shouldContain """"taskKind":"current_information_question"""" legacyPack.bodyAsText() shouldBe pack.bodyAsText() legacyPack.headers[HttpHeaders.ETag] shouldBe etag legacyPack.headers[HttpHeaders.CacheControl] shouldBe pack.headers[HttpHeaders.CacheControl] 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 a94b31b..c19bff0 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 @@ -243,7 +243,7 @@ class DeepSeekClientTest : StringSpec({ } } - "falls back to Chat Completions when Responses search fails" { + "falls back to thinking Chat Completions when optional search fails" { val paths = mutableListOf() val requestBodies = mutableListOf() val client = HttpClient( @@ -274,8 +274,8 @@ class DeepSeekClientTest : StringSpec({ try { val usage = DeepSeekProvider(client, CONFIG).execute( request( - taskKind = GatewayTaskKind.CURRENT_INFORMATION_QUESTION, - webSearch = GatewayWebSearchMode.REQUIRED, + taskKind = GatewayTaskKind.AI_QUESTION, + webSearch = GatewayWebSearchMode.ALLOWED, ), DISCARD_OUTPUT, ) @@ -291,6 +291,39 @@ class DeepSeekClientTest : StringSpec({ } } + "does not return an offline answer when current information requires search" { + val paths = mutableListOf() + val client = HttpClient( + MockEngine { request -> + paths += request.url.encodedPath + respond( + content = """{"error":{"message":"search unavailable"}}""", + status = HttpStatusCode.ServiceUnavailable, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), + ) + }, + ) { + install(ContentNegotiation) { + json(Json { explicitNulls = false }) + } + } + try { + shouldThrow { + DeepSeekProvider(client, CONFIG).execute( + request( + taskKind = GatewayTaskKind.CURRENT_INFORMATION_QUESTION, + webSearch = GatewayWebSearchMode.REQUIRED, + ), + DISCARD_OUTPUT, + ) + } + + paths shouldBe listOf("/v1/responses") + } finally { + client.close() + } + } + "retries one buffered empty result and returns the successful retry" { var attempts = 0 val provider = DeepSeekProvider( diff --git a/src/test/kotlin/com/osglab/account/features/gateway/routes/GatewayFailureMappingTest.kt b/src/test/kotlin/com/osglab/account/features/gateway/routes/GatewayFailureMappingTest.kt new file mode 100644 index 0000000..2684272 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/gateway/routes/GatewayFailureMappingTest.kt @@ -0,0 +1,48 @@ +package com.osglab.account.features.gateway.routes + +import com.osglab.account.features.credits.domain.InsufficientCredits +import com.osglab.account.features.gateway.providers.ProviderCompletionException +import com.osglab.account.features.gateway.providers.ProviderUnavailableException +import com.osglab.account.features.gateway.providers.ProviderUpstreamException +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.ktor.http.HttpStatusCode + +class GatewayFailureMappingTest : StringSpec({ + "reports insufficient credits instead of a gateway failure" { + val result = gatewayFailureDescriptor(InsufficientCredits(available = 1, required = 2)) + + result.status shouldBe HttpStatusCode.PaymentRequired + result.code shouldBe "insufficient_credits" + } + + "distinguishes unavailable busy timeout and invalid provider responses" { + gatewayFailureDescriptor(ProviderUnavailableException("missing")).let { + it.status shouldBe HttpStatusCode.ServiceUnavailable + it.code shouldBe "provider_unavailable" + } + gatewayFailureDescriptor(ProviderUpstreamException("busy", upstreamStatus = 429)).let { + it.status shouldBe HttpStatusCode.ServiceUnavailable + it.code shouldBe "provider_rate_limited" + } + gatewayFailureDescriptor(ProviderUpstreamException("unavailable", upstreamStatus = 503)).let { + it.status shouldBe HttpStatusCode.ServiceUnavailable + it.code shouldBe "provider_unavailable" + } + gatewayFailureDescriptor(ProviderUpstreamException("timeout", upstreamStatus = 504)).let { + it.status shouldBe HttpStatusCode.GatewayTimeout + it.code shouldBe "provider_timeout" + } + gatewayFailureDescriptor(ProviderCompletionException("invalid")).let { + it.status shouldBe HttpStatusCode.BadGateway + it.code shouldBe "provider_invalid_response" + } + } + + "keeps unexpected server failures distinct from upstream failures" { + val result = gatewayFailureDescriptor(IllegalStateException("database unavailable")) + + result.status shouldBe HttpStatusCode.InternalServerError + result.code shouldBe "internal_failure" + } +})