Harden current-information search failures
Preserve required-search semantics for live hints and expose stable gateway error codes without leaking provider details.
This commit is contained in:
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+3
@@ -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.
|
||||||
|
|||||||
+12
-4
@@ -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,
|
||||||
|
|||||||
+4
@@ -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]
|
||||||
|
|||||||
+4
-2
@@ -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,101 +457,151 @@ private suspend fun ApplicationCall.respondGatewayFailure(
|
|||||||
failure: Throwable,
|
failure: Throwable,
|
||||||
requestId: String,
|
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) {
|
when (failure) {
|
||||||
is GatewayRequestAlreadyClaimedException -> respondGatewayError(
|
is GatewayRequestAlreadyClaimedException -> GatewayFailureDescriptor(
|
||||||
HttpStatusCode.Conflict,
|
HttpStatusCode.Conflict,
|
||||||
"request_already_claimed",
|
"request_already_claimed",
|
||||||
"This account request ID is already ${failure.state.name.lowercase()}",
|
"This account request ID is already ${failure.state.name.lowercase()}",
|
||||||
requestId,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
is ComplimentaryRequestUnavailableException -> respondGatewayError(
|
is ComplimentaryRequestUnavailableException -> GatewayFailureDescriptor(
|
||||||
HttpStatusCode.Conflict,
|
HttpStatusCode.Conflict,
|
||||||
"oobe_already_used",
|
"oobe_already_used",
|
||||||
"The complimentary OOBE request has already been used",
|
"The complimentary OOBE request has already been used",
|
||||||
requestId,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
is OobeFeatureAlreadyUsedException -> respondGatewayError(
|
is OobeFeatureAlreadyUsedException -> GatewayFailureDescriptor(
|
||||||
HttpStatusCode.Conflict,
|
HttpStatusCode.Conflict,
|
||||||
"oobe_feature_already_used",
|
"oobe_feature_already_used",
|
||||||
"This OOBE feature has already been used successfully in this session",
|
"This OOBE feature has already been used successfully in this session",
|
||||||
requestId,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
is OobeRequestAlreadyClaimedException -> respondGatewayError(
|
is OobeRequestAlreadyClaimedException -> GatewayFailureDescriptor(
|
||||||
HttpStatusCode.Conflict,
|
HttpStatusCode.Conflict,
|
||||||
"oobe_request_replayed",
|
"oobe_request_replayed",
|
||||||
"This OOBE request ID has already been used",
|
"This OOBE request ID has already been used",
|
||||||
requestId,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
is GatewayBodyTooLargeException -> respondGatewayError(
|
is GatewayBodyTooLargeException -> GatewayFailureDescriptor(
|
||||||
HttpStatusCode.PayloadTooLarge,
|
HttpStatusCode.PayloadTooLarge,
|
||||||
"request_too_large",
|
"request_too_large",
|
||||||
"Request body exceeds the gateway limit",
|
"Request body exceeds the gateway limit",
|
||||||
requestId,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
is GatewayRequestTimeoutException -> respondGatewayError(
|
is GatewayRequestTimeoutException -> GatewayFailureDescriptor(
|
||||||
HttpStatusCode.RequestTimeout,
|
HttpStatusCode.RequestTimeout,
|
||||||
"request_timeout",
|
"request_timeout",
|
||||||
"Request body was not received within the time limit",
|
"Request body was not received within the time limit",
|
||||||
requestId,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
is GatewayAccessDeniedException -> respondGatewayError(
|
is GatewayAccessDeniedException -> GatewayFailureDescriptor(
|
||||||
HttpStatusCode.Forbidden,
|
HttpStatusCode.Forbidden,
|
||||||
"gateway_grant_denied",
|
"gateway_grant_denied",
|
||||||
"Gateway access is not granted",
|
"Gateway access is not granted",
|
||||||
requestId,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
is GatewayRefreshTokenInvalidException,
|
is GatewayRefreshTokenInvalidException,
|
||||||
is GatewayRefreshTokenReuseException -> respondGatewayError(
|
is GatewayRefreshTokenReuseException -> GatewayFailureDescriptor(
|
||||||
HttpStatusCode.Unauthorized,
|
HttpStatusCode.Unauthorized,
|
||||||
"invalid_gateway_refresh",
|
"invalid_gateway_refresh",
|
||||||
"Gateway refresh token is invalid",
|
"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,
|
HttpStatusCode.TooManyRequests,
|
||||||
"asr_concurrency_limit",
|
"asr_concurrency_limit",
|
||||||
"Too many concurrent ASR sessions",
|
"Too many concurrent ASR sessions",
|
||||||
requestId,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
is AsrSessionNotFoundException,
|
is AsrSessionNotFoundException,
|
||||||
is AsrSessionAlreadyUsedException -> respondGatewayError(
|
is AsrSessionAlreadyUsedException -> GatewayFailureDescriptor(
|
||||||
HttpStatusCode.NotFound,
|
HttpStatusCode.NotFound,
|
||||||
"asr_session_unavailable",
|
"asr_session_unavailable",
|
||||||
"ASR session is 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,
|
HttpStatusCode.ServiceUnavailable,
|
||||||
"provider_unavailable",
|
"provider_unavailable",
|
||||||
"No provider is configured for this capability",
|
"The managed provider is unavailable",
|
||||||
requestId,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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,
|
HttpStatusCode.BadRequest,
|
||||||
"invalid_request",
|
"invalid_request",
|
||||||
failure.message ?: "Request is invalid",
|
failure.message ?: "Request is invalid",
|
||||||
requestId,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
else -> respondGatewayError(
|
else -> GatewayFailureDescriptor(
|
||||||
HttpStatusCode.BadGateway,
|
HttpStatusCode.InternalServerError,
|
||||||
"gateway_failure",
|
"internal_failure",
|
||||||
"The managed provider request failed",
|
"The managed gateway could not complete the request",
|
||||||
requestId,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun ApplicationCall.respondGatewayError(
|
private suspend fun ApplicationCall.respondGatewayError(
|
||||||
status: HttpStatusCode,
|
status: HttpStatusCode,
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
+36
-3
@@ -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(
|
||||||
|
|||||||
+48
@@ -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"
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user