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.ProviderUsage
import com.osglab.account.features.gateway.models.TextProviderRequest 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.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
@@ -72,16 +73,16 @@ internal class DeepSeekSearchFallbackClient(
} catch (failure: CancellationException) { } catch (failure: CancellationException) {
throw failure throw failure
} catch (failure: Exception) { } catch (failure: Exception) {
val upstreamStatus = (failure as? ProviderUpstreamException)?.upstreamStatus
LOG.warn( 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.requestId,
request.executionPolicy.taskKind.name, request.executionPolicy.taskKind.name,
request.executionPolicy.webSearch.name, request.executionPolicy.webSearch.name,
failure::class.simpleName ?: "Exception", failure::class.simpleName ?: "Exception",
upstreamStatus ?: "unknown",
) )
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(
@@ -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 paths = mutableListOf<String>()
val requestBodies = mutableListOf<String>()
val client = HttpClient( val client = HttpClient(
MockEngine { request -> MockEngine { request ->
paths += request.url.encodedPath paths += request.url.encodedPath
respond( requestBodies += request.body.toByteArray().decodeToString()
content = """{"error":{"message":"search unavailable"}}""", if (request.url.encodedPath.endsWith("/responses")) {
status = HttpStatusCode.ServiceUnavailable, respond(
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), 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) { install(ContentNegotiation) {
@@ -308,17 +320,20 @@ class DeepSeekClientTest : StringSpec({
} }
} }
try { try {
shouldThrow<DeepSeekProviderException> { val usage = DeepSeekProvider(client, CONFIG).execute(
DeepSeekProvider(client, CONFIG).execute( request(
request( taskKind = GatewayTaskKind.CURRENT_INFORMATION_QUESTION,
taskKind = GatewayTaskKind.CURRENT_INFORMATION_QUESTION, webSearch = GatewayWebSearchMode.REQUIRED,
webSearch = GatewayWebSearchMode.REQUIRED, ),
), DISCARD_OUTPUT,
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 { } finally {
client.close() client.close()
} }