fix(gateway): restore safe search fallback
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Fall back to guarded Chat Completions answers when DeepSeek web search fails so grounded current-information hints do not surface generic provider errors.
This commit is contained in:
Rocky
2026-08-23 14:32:43 +08:00
parent 03eac71905
commit 51c37e6206
2 changed files with 36 additions and 20 deletions
@@ -9,6 +9,7 @@ import com.osglab.account.features.gateway.models.ProviderOutput
import com.osglab.account.features.gateway.models.ProviderUsage
import com.osglab.account.features.gateway.models.TextProviderRequest
import com.osglab.account.features.gateway.models.UsageMeter
import com.osglab.account.features.gateway.providers.ProviderUpstreamException
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.bearerAuth
@@ -72,16 +73,16 @@ internal class DeepSeekSearchFallbackClient(
} catch (failure: CancellationException) {
throw failure
} catch (failure: Exception) {
val upstreamStatus = (failure as? ProviderUpstreamException)?.upstreamStatus
LOG.warn(
"DeepSeek search path failed requestId={} taskKind={} searchMode={} failureType={}",
"DeepSeek search path failed requestId={} taskKind={} searchMode={} " +
"failureType={} upstreamStatus={} fallback=chat_completions",
request.requestId,
request.executionPolicy.taskKind.name,
request.executionPolicy.webSearch.name,
failure::class.simpleName ?: "Exception",
upstreamStatus ?: "unknown",
)
if (request.executionPolicy.webSearch == GatewayWebSearchMode.REQUIRED) {
throw failure
}
return fallback.complete(
request.copy(
executionPolicy = request.executionPolicy.copy(
@@ -291,16 +291,28 @@ class DeepSeekClientTest : StringSpec({
}
}
"does not return an offline answer when current information requires search" {
"falls back to a guarded answer when required search fails" {
val paths = mutableListOf<String>()
val requestBodies = 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()),
)
requestBodies += request.body.toByteArray().decodeToString()
if (request.url.encodedPath.endsWith("/responses")) {
respond(
content = """{"error":{"message":"search unavailable"}}""",
status = HttpStatusCode.ServiceUnavailable,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
)
} else {
respond(
content =
"""{"choices":[{"message":{"content":"无法核实实时信息"}}],""" +
""""usage":{"prompt_tokens":9,"completion_tokens":4,"total_tokens":13}}""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
)
}
},
) {
install(ContentNegotiation) {
@@ -308,17 +320,20 @@ class DeepSeekClientTest : StringSpec({
}
}
try {
shouldThrow<DeepSeekProviderException> {
DeepSeekProvider(client, CONFIG).execute(
request(
taskKind = GatewayTaskKind.CURRENT_INFORMATION_QUESTION,
webSearch = GatewayWebSearchMode.REQUIRED,
),
DISCARD_OUTPUT,
)
}
val usage = DeepSeekProvider(client, CONFIG).execute(
request(
taskKind = GatewayTaskKind.CURRENT_INFORMATION_QUESTION,
webSearch = GatewayWebSearchMode.REQUIRED,
),
DISCARD_OUTPUT,
)
paths shouldBe listOf("/v1/responses")
paths shouldBe listOf("/v1/responses", "/v1/chat/completions")
val fallbackSystem = Json.parseToJsonElement(requestBodies.last()).jsonObject
.getValue("messages").jsonArray.first().jsonObject
.getValue("content").jsonPrimitive.content
fallbackSystem shouldContain "could not be verified"
usage.units shouldBe 13
} finally {
client.close()
}