Harden current-information search failures
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Preserve required-search semantics for live hints and expose stable gateway error codes without leaking provider details.
This commit is contained in:
Rocky
2026-08-22 22:43:16 +08:00
parent 4f5b6eafbd
commit 03eac71905
16 changed files with 277 additions and 45 deletions
+1
View File
@@ -69,6 +69,7 @@ export interface AIHintCard {
locale: "zh" | "en"; locale: "zh" | "en";
conditions: string[]; conditions: string[];
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
taskKind?: "ai_question" | "current_information_question";
} }
export interface AdminHintPack { export interface AdminHintPack {
+11 -2
View File
@@ -480,8 +480,10 @@ paths:
and output-budget policy from `capability` plus optional `taskKind`. It and output-budget policy from `capability` plus optional `taskKind`. It
never infers task type from `input` or `context`, and clients cannot never infers task type from `input` or `context`, and clients cannot
supply provider parameters. Ordinary AI questions allow model-selected supply provider parameters. Ordinary AI questions allow model-selected
server-side web search; current-information questions require it. Other server-side web search and retain thinking if search is unavailable.
tools remain disabled. Search has no separate credit fee; settlement 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 uses the provider-reported LLM input and output Token counts, including
any search context charged by the provider. any search context charged by the provider.
For account grants, `oobe` is accepted only for dictation polish and the For account grants, `oobe` is accepted only for dictation polish and the
@@ -1785,6 +1787,13 @@ components:
type: array type: array
maxItems: 20 maxItems: 20
items: { type: string, minLength: 1, maxLength: 64 } 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: metadata:
type: object type: object
additionalProperties: true additionalProperties: true
@@ -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.feed.HintFeedSettings
import com.osglab.account.features.content.models.AIHintCardDto import com.osglab.account.features.content.models.AIHintCardDto
import com.osglab.account.features.content.models.AIHintTaskKind
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
class BaselineHintSource : HintFeedSource { class BaselineHintSource : HintFeedSource {
@@ -31,6 +32,7 @@ private val CHINESE = listOf(
category = "economy", category = "economy",
priority = 42, priority = 42,
locale = "zh", locale = "zh",
taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION,
), ),
card( card(
id = "cap-zh-clipboard-reply", id = "cap-zh-clipboard-reply",
@@ -68,6 +70,7 @@ private val ENGLISH = listOf(
category = "economy", category = "economy",
priority = 42, priority = 42,
locale = "en", locale = "en",
taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION,
), ),
card( card(
id = "cap-en-clipboard-reply", id = "cap-en-clipboard-reply",
@@ -97,6 +100,7 @@ private fun card(
priority: Int, priority: Int,
locale: String, locale: String,
conditions: List<String> = emptyList(), conditions: List<String> = emptyList(),
taskKind: AIHintTaskKind = AIHintTaskKind.AI_QUESTION,
) = AIHintCardDto( ) = AIHintCardDto(
id = id, id = id,
text = text, text = text,
@@ -107,4 +111,5 @@ private fun card(
locale = locale, locale = locale,
conditions = conditions, conditions = conditions,
metadata = buildJsonObject {}, metadata = buildJsonObject {},
taskKind = taskKind,
) )
@@ -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.csvValues
import com.osglab.account.features.content.feed.stableHintId import com.osglab.account.features.content.feed.stableHintId
import com.osglab.account.features.content.models.AIHintCardDto 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.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.plugins.timeout import io.ktor.client.plugins.timeout
@@ -44,6 +45,7 @@ class GoogleFeedHintSource(
priority = 66, priority = 66,
source = "google-trends-rss", source = "google-trends-rss",
locale = "en", locale = "en",
taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION,
metadata = buildJsonObject { metadata = buildJsonObject {
put("geo", geo) put("geo", geo)
put("query", title) put("query", title)
@@ -65,6 +67,7 @@ class GoogleFeedHintSource(
priority = 58, priority = 58,
source = "google-news-rss", source = "google-news-rss",
locale = "en", locale = "en",
taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION,
metadata = buildJsonObject { put("title", title) }, metadata = buildJsonObject { put("title", title) },
) )
}.take(4) }.take(4)
@@ -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.HintFeedSettings
import com.osglab.account.features.content.feed.stableHintId import com.osglab.account.features.content.feed.stableHintId
import com.osglab.account.features.content.models.AIHintCardDto 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.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.plugins.timeout import io.ktor.client.plugins.timeout
@@ -73,6 +74,7 @@ class TopHubHintSource(
priority = 78, priority = 78,
source = "tophub-daily", source = "tophub-daily",
locale = "zh", locale = "zh",
taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION,
metadata = buildJsonObject { metadata = buildJsonObject {
data.string("day")?.let { put("day", it) } data.string("day")?.let { put("day", it) }
put("date", day) put("date", day)
@@ -106,6 +108,7 @@ class TopHubHintSource(
priority = 74, priority = 74,
source = "tophub-daily", source = "tophub-daily",
locale = "zh", locale = "zh",
taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION,
metadata = item.metadata("title" to title, "url" to item.string("url")), metadata = item.metadata("title" to title, "url" to item.string("url")),
) )
} }
@@ -202,6 +205,7 @@ class TopHubHintSource(
priority = priority, priority = priority,
source = source, source = source,
locale = "zh", locale = "zh",
taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION,
metadata = metadata, metadata = metadata,
) )
@@ -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.HintWeatherCity
import com.osglab.account.features.content.feed.parseWeatherCities import com.osglab.account.features.content.feed.parseWeatherCities
import com.osglab.account.features.content.models.AIHintCardDto 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.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.plugins.timeout import io.ktor.client.plugins.timeout
@@ -77,6 +78,7 @@ class WeatherHintSource(
source = id, source = id,
locale = locale, locale = locale,
conditions = listOf("geo_optional"), conditions = listOf("geo_optional"),
taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION,
metadata = buildJsonObject { metadata = buildJsonObject {
put("city", city.name) put("city", city.name)
put("lat", city.latitude) put("lat", city.latitude)
@@ -87,6 +87,15 @@ data class SkillCatalogRecord(
val skills: List<OfficialSkillRecord>, val skills: List<OfficialSkillRecord>,
) )
@Serializable
enum class AIHintTaskKind {
@SerialName("ai_question")
AI_QUESTION,
@SerialName("current_information_question")
CURRENT_INFORMATION_QUESTION,
}
@Serializable @Serializable
data class AIHintCardDto( data class AIHintCardDto(
val id: String, val id: String,
@@ -99,6 +108,7 @@ data class AIHintCardDto(
val locale: String, val locale: String,
val conditions: List<String> = emptyList(), val conditions: List<String> = emptyList(),
val metadata: JsonObject? = null, val metadata: JsonObject? = null,
val taskKind: AIHintTaskKind = AIHintTaskKind.AI_QUESTION,
) )
@Serializable @Serializable
@@ -36,6 +36,21 @@ class ProviderCatalog(
class UnsupportedGatewayCapabilityException(capability: String) : class UnsupportedGatewayCapabilityException(capability: String) :
IllegalArgumentException("No gateway provider is configured for capability '$capability'") 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 * Upstream returned an invalid metering/result envelope. Gateway orchestration
* treats this as provider failure and releases the reservation. * treats this as provider failure and releases the reservation.
@@ -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.models.UsageMeter
import com.osglab.account.features.gateway.providers.GatewayProvider import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCompletionException 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.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.bearerAuth import io.ktor.client.request.bearerAuth
@@ -244,7 +246,10 @@ class KtorDeepSeekClient(
if (!response.status.isSuccess()) { if (!response.status.isSuccess()) {
// Consume but never log or persist a provider body. // Consume but never log or persist a provider body.
runCatching { response.body<ByteReadChannel>().readBounded() } runCatching { response.body<ByteReadChannel>().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) { val expectedContentType = if (request.stream) {
ContentType.Text.EventStream ContentType.Text.EventStream
@@ -652,15 +657,18 @@ private data class ResponseFormat(
val type: String, 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 DeepSeekUsageException(message: String) : ProviderCompletionException(message)
class DeepSeekEmptyResultException( class DeepSeekEmptyResultException(
val finishReason: String = "missing", val finishReason: String = "missing",
val reasoningContentPresent: Boolean = false, val reasoningContentPresent: Boolean = false,
val usagePresent: Boolean = false, val usagePresent: Boolean = false,
) : RuntimeException("DeepSeek returned an empty result") ) : ProviderCompletionException("DeepSeek returned an empty result")
private data class DeepSeekUsage( private data class DeepSeekUsage(
val total: Long, val total: Long,
@@ -79,6 +79,9 @@ internal class DeepSeekSearchFallbackClient(
request.executionPolicy.webSearch.name, request.executionPolicy.webSearch.name,
failure::class.simpleName ?: "Exception", failure::class.simpleName ?: "Exception",
) )
if (request.executionPolicy.webSearch == GatewayWebSearchMode.REQUIRED) {
throw failure
}
return fallback.complete( return fallback.complete(
request.copy( request.copy(
executionPolicy = request.executionPolicy.copy( executionPolicy = request.executionPolicy.copy(
@@ -146,6 +149,7 @@ internal class KtorDeepSeekResponsesClient(
runCatching { response.body<ByteReadChannel>().readBounded() } runCatching { response.body<ByteReadChannel>().readBounded() }
throw DeepSeekProviderException( throw DeepSeekProviderException(
"DeepSeek Responses returned HTTP ${response.status.value}", "DeepSeek Responses returned HTTP ${response.status.value}",
response.status.value,
) )
} }
val responseContentType = response.headers[HttpHeaders.ContentType] val responseContentType = response.headers[HttpHeaders.ContentType]
@@ -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.models.UsageMeter
import com.osglab.account.features.gateway.providers.GatewayProvider import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCompletionException 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.HttpClient
import io.ktor.client.plugins.websocket.webSocket import io.ktor.client.plugins.websocket.webSocket
import io.ktor.http.Url import io.ktor.http.Url
@@ -353,9 +355,9 @@ private data class RecognitionOptions(
val showUtterances: Boolean = true, 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) class VolcengineUsageException(message: String) : ProviderCompletionException(message)
internal fun extractFinalDuration( internal fun extractFinalDuration(
@@ -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.GatewayPrincipalResolver
import com.osglab.account.features.gateway.ports.GatewayRequestAlreadyClaimedException import com.osglab.account.features.gateway.ports.GatewayRequestAlreadyClaimedException
import com.osglab.account.features.gateway.providers.UnsupportedGatewayCapabilityException 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.GatewayAccessDeniedException
import com.osglab.account.features.gateway.services.ComplimentaryRequestUnavailableException import com.osglab.account.features.gateway.services.ComplimentaryRequestUnavailableException
import com.osglab.account.features.gateway.services.GatewayGrantService 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.GatewayRefreshTokenReuseException
import com.osglab.account.features.gateway.services.GatewayService import com.osglab.account.features.gateway.services.GatewayService
import com.osglab.account.features.gateway.services.GatewayTaskPolicyResolver 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.OobeFeatureAlreadyUsedException
import com.osglab.account.features.oobe.OobeRequestAlreadyClaimedException import com.osglab.account.features.oobe.OobeRequestAlreadyClaimedException
import io.ktor.http.ContentType import io.ktor.http.ContentType
@@ -63,6 +68,7 @@ import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.io.readByteArray import kotlinx.io.readByteArray
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
fun Route.configureGatewayRoutes( fun Route.configureGatewayRoutes(
@@ -311,10 +317,21 @@ fun Route.configureGatewayRoutes(
flush() flush()
}) })
} catch (failure: Throwable) { } 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. // The response may already be committed. Emit metadata only.
val errorEvent = val descriptor = gatewayFailureDescriptor(failure)
"event: gateway_error\ndata: {\"code\":\"provider_error\",\"requestId\":\"$requestId\"}\n\n" val payload = ROUTE_JSON.encodeToString(
GatewayErrorResponse(
descriptor.code,
descriptor.message,
requestId,
),
)
val errorEvent = "event: gateway_error\ndata: $payload\n\n"
writeFully(errorEvent.encodeToByteArray()) writeFully(errorEvent.encodeToByteArray())
flush() flush()
} }
@@ -440,100 +457,150 @@ private suspend fun ApplicationCall.respondGatewayFailure(
failure: Throwable, failure: Throwable,
requestId: String, requestId: String,
) { ) {
when (failure) { val descriptor = gatewayFailureDescriptor(failure)
is GatewayRequestAlreadyClaimedException -> respondGatewayError( respondGatewayError(
HttpStatusCode.Conflict, descriptor.status,
"request_already_claimed", descriptor.code,
"This account request ID is already ${failure.state.name.lowercase()}", descriptor.message,
requestId,
)
is ComplimentaryRequestUnavailableException -> respondGatewayError(
HttpStatusCode.Conflict,
"oobe_already_used",
"The complimentary OOBE request has already been used",
requestId,
)
is OobeFeatureAlreadyUsedException -> respondGatewayError(
HttpStatusCode.Conflict,
"oobe_feature_already_used",
"This OOBE feature has already been used successfully in this session",
requestId,
)
is OobeRequestAlreadyClaimedException -> respondGatewayError(
HttpStatusCode.Conflict,
"oobe_request_replayed",
"This OOBE request ID has already been used",
requestId,
)
is GatewayBodyTooLargeException -> respondGatewayError(
HttpStatusCode.PayloadTooLarge,
"request_too_large",
"Request body exceeds the gateway limit",
requestId,
)
is GatewayRequestTimeoutException -> respondGatewayError(
HttpStatusCode.RequestTimeout,
"request_timeout",
"Request body was not received within the time limit",
requestId,
)
is GatewayAccessDeniedException -> respondGatewayError(
HttpStatusCode.Forbidden,
"gateway_grant_denied",
"Gateway access is not granted",
requestId,
)
is GatewayRefreshTokenInvalidException,
is GatewayRefreshTokenReuseException -> respondGatewayError(
HttpStatusCode.Unauthorized,
"invalid_gateway_refresh",
"Gateway refresh token is invalid",
requestId,
)
is AsrConcurrencyLimitException -> respondGatewayError(
HttpStatusCode.TooManyRequests,
"asr_concurrency_limit",
"Too many concurrent ASR sessions",
requestId,
)
is AsrSessionNotFoundException,
is AsrSessionAlreadyUsedException -> respondGatewayError(
HttpStatusCode.NotFound,
"asr_session_unavailable",
"ASR session is unavailable",
requestId,
)
is UnsupportedGatewayCapabilityException -> respondGatewayError(
HttpStatusCode.ServiceUnavailable,
"provider_unavailable",
"No provider is configured for this capability",
requestId,
)
is IllegalArgumentException -> respondGatewayError(
HttpStatusCode.BadRequest,
"invalid_request",
failure.message ?: "Request is invalid",
requestId,
)
else -> respondGatewayError(
HttpStatusCode.BadGateway,
"gateway_failure",
"The managed provider request failed",
requestId, requestId,
) )
} }
internal data class GatewayFailureDescriptor(
val status: HttpStatusCode,
val code: String,
val message: String,
)
internal fun gatewayFailureDescriptor(failure: Throwable): GatewayFailureDescriptor =
when (failure) {
is GatewayRequestAlreadyClaimedException -> GatewayFailureDescriptor(
HttpStatusCode.Conflict,
"request_already_claimed",
"This account request ID is already ${failure.state.name.lowercase()}",
)
is ComplimentaryRequestUnavailableException -> GatewayFailureDescriptor(
HttpStatusCode.Conflict,
"oobe_already_used",
"The complimentary OOBE request has already been used",
)
is OobeFeatureAlreadyUsedException -> GatewayFailureDescriptor(
HttpStatusCode.Conflict,
"oobe_feature_already_used",
"This OOBE feature has already been used successfully in this session",
)
is OobeRequestAlreadyClaimedException -> GatewayFailureDescriptor(
HttpStatusCode.Conflict,
"oobe_request_replayed",
"This OOBE request ID has already been used",
)
is GatewayBodyTooLargeException -> GatewayFailureDescriptor(
HttpStatusCode.PayloadTooLarge,
"request_too_large",
"Request body exceeds the gateway limit",
)
is GatewayRequestTimeoutException -> GatewayFailureDescriptor(
HttpStatusCode.RequestTimeout,
"request_timeout",
"Request body was not received within the time limit",
)
is GatewayAccessDeniedException -> GatewayFailureDescriptor(
HttpStatusCode.Forbidden,
"gateway_grant_denied",
"Gateway access is not granted",
)
is GatewayRefreshTokenInvalidException,
is GatewayRefreshTokenReuseException -> GatewayFailureDescriptor(
HttpStatusCode.Unauthorized,
"invalid_gateway_refresh",
"Gateway refresh token is invalid",
)
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",
)
is AsrSessionNotFoundException,
is AsrSessionAlreadyUsedException -> GatewayFailureDescriptor(
HttpStatusCode.NotFound,
"asr_session_unavailable",
"ASR session is unavailable",
)
is TimeoutCancellationException -> GatewayFailureDescriptor(
HttpStatusCode.GatewayTimeout,
"provider_timeout",
"The managed provider timed out",
)
is UnsupportedGatewayCapabilityException,
is ProviderUnavailableException -> GatewayFailureDescriptor(
HttpStatusCode.ServiceUnavailable,
"provider_unavailable",
"The managed provider is unavailable",
)
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",
)
else -> GatewayFailureDescriptor(
HttpStatusCode.InternalServerError,
"internal_failure",
"The managed gateway could not complete the request",
)
} }
private suspend fun ApplicationCall.respondGatewayError( private suspend fun ApplicationCall.respondGatewayError(
@@ -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.BaselineHintSource
import com.osglab.account.features.content.feed.sources.HintFeedGenerationContext import com.osglab.account.features.content.feed.sources.HintFeedGenerationContext
import com.osglab.account.features.content.models.AIHintCardDto 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.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
@@ -54,6 +55,23 @@ class HintFeedPolicyTest : FunSpec({
"cap-en-clipboard-translate", "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) = private fun hint(id: String, text: String, priority: Int) =
@@ -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.HintFeedPackGenerationResult
import com.osglab.account.features.content.feed.HintFeedService import com.osglab.account.features.content.feed.HintFeedService
import com.osglab.account.features.content.models.AIHintCardDto 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.CreateOfficialSkillRequest
import com.osglab.account.features.content.models.SkillLocalizationDto import com.osglab.account.features.content.models.SkillLocalizationDto
import com.osglab.account.features.content.models.SkillLocalizationsDto import com.osglab.account.features.content.models.SkillLocalizationsDto
@@ -85,6 +86,7 @@ class ContentRoutesTest {
source = "official", source = "official",
locale = "en", locale = "en",
conditions = listOf("idle"), conditions = listOf("idle"),
taskKind = AIHintTaskKind.CURRENT_INFORMATION_QUESTION,
), ),
), ),
), ),
@@ -110,6 +112,7 @@ class ContentRoutesTest {
pack.status shouldBe HttpStatusCode.OK pack.status shouldBe HttpStatusCode.OK
pack.bodyAsText() shouldContain """"version":1""" pack.bodyAsText() shouldContain """"version":1"""
pack.bodyAsText() shouldContain """"text":"Daily brief"""" pack.bodyAsText() shouldContain """"text":"Daily brief""""
pack.bodyAsText() shouldContain """"taskKind":"current_information_question""""
legacyPack.bodyAsText() shouldBe pack.bodyAsText() legacyPack.bodyAsText() shouldBe pack.bodyAsText()
legacyPack.headers[HttpHeaders.ETag] shouldBe etag legacyPack.headers[HttpHeaders.ETag] shouldBe etag
legacyPack.headers[HttpHeaders.CacheControl] shouldBe pack.headers[HttpHeaders.CacheControl] legacyPack.headers[HttpHeaders.CacheControl] shouldBe pack.headers[HttpHeaders.CacheControl]
@@ -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<String>() val paths = mutableListOf<String>()
val requestBodies = mutableListOf<String>() val requestBodies = mutableListOf<String>()
val client = HttpClient( val client = HttpClient(
@@ -274,8 +274,8 @@ class DeepSeekClientTest : StringSpec({
try { try {
val usage = DeepSeekProvider(client, CONFIG).execute( val usage = DeepSeekProvider(client, CONFIG).execute(
request( request(
taskKind = GatewayTaskKind.CURRENT_INFORMATION_QUESTION, taskKind = GatewayTaskKind.AI_QUESTION,
webSearch = GatewayWebSearchMode.REQUIRED, webSearch = GatewayWebSearchMode.ALLOWED,
), ),
DISCARD_OUTPUT, DISCARD_OUTPUT,
) )
@@ -291,6 +291,39 @@ class DeepSeekClientTest : StringSpec({
} }
} }
"does not return an offline answer when current information requires search" {
val paths = mutableListOf<String>()
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<DeepSeekProviderException> {
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" { "retries one buffered empty result and returns the successful retry" {
var attempts = 0 var attempts = 0
val provider = DeepSeekProvider( val provider = DeepSeekProvider(
@@ -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"
}
})