Repository navigation
[llm-pi-ai] Upstream "Provider returned an empty response" (OpenRouter) falls through classifyPiAiError to non-retryable PI_AI_ERROR #8321
Replies: 1 comment
|
Thanks for the attempt log — the seven The chain, link by link (master as of today)
So the attempt is declined before any The asymmetry that makes this a gap rather than a policy
The same thing happened to you, and the harness declined to retry it — the only difference is who noticed. When the adapter notices locally it is retryable; when the provider reports it as an error and the classifier cannot place the wording, it is not. Identity of the code decides, not the property of the attempt, and the code is chosen by a wording table with a catch-all that means both "transient, unnamed" and "terminal by design". Why the fix cannot be one line in the tableThere are exactly three producers of What is available to discriminate on at the seam is the one thing the provider gave you and the adapter preserved: the attempt produced zero reader-visible chunks. A failed attempt that streamed text before failing is a different animal from one that never emitted anything, and only the latter is safe to repeat unconditionally. A plugin that closes itI have published the misfire as a plugin rather than leaving it as an analysis, since the seam (
npm install @argszero/cordis-plugin-empty-response-retryIt observes the
— rewrites the terminal chunk's
Configuration (all validated at mount time):
Since you are the one with the data, start with Verified against If you would rather this landed in core as a widened classifier rule for the empty-response wording, that is a reasonable alternative — the discriminator above (zero output produced) is the part I would insist on either way, because the code alone does not carry it. |
Uh oh!
There was an error while loading. Please reload this page.
Environment
dsh-llm-pi-aiserving an OpenRouter route (api: openai-completions)dsh-llm-retryis active through the stockdsh-basebundle registration — confirmed at runtime by the presence ofllm/retry-startedsession events. So this is not a missing-plugin issue: the retry executor runs, the failure is in classification.Symptom
When OpenRouter relays an upstream empty response, the turn ends with an agent error and no retry attempt:
Seven attempt-level failures logged across Sep 29–30, all on the same route.
dsh-llm-retrynever fires for them.Root cause
"Provider returned an empty response"is the upstream provider's error text, relayed verbatim by pi-ai's OpenAI-completions adapter into the terminalerrorevent'serrorMessage.classifyPiAiError()(packages/llm/llm-pi-ai/src/stream.ts) has no rule that matches the wording, so it falls to the catch-all:PI_AI_ERRORis not inDEFAULT_RETRYABLE_CODES(EMPTY_RESPONSE,RATE_LIMIT,SERVER,TIMEOUT,TRANSPORT), sorecover()short-circuits at!policy.retryableCodes.includes(failure.code)and the request is never retried.Note the inconsistency:
mapStopReason()classifies a locally detected zero-contentstopasEMPTY_RESPONSE— retryable by default. Two paths that both mean "the model produced nothing" disagree; one retries, the other silently ends the turn.Why this should be retryable
EMPTY_RESPONSE_CODE's own docstring (packages/llm/llm/src/types/error.ts) states the principle:An upstream-relayed empty response is exactly "nothing durable" — the same transient failure as a mid-stream truncation, just worded by a different layer.
Proposed fix
Narrow: one more rule in
classifyPiAiError:Structural (preferred): this is the fourth report on the same regex chain — #4361 (
finish_reason: network_error), #6595 (localized timeout text), #7691 (textual HTTP 5xx). Each report adds new wording, which suggests text enumeration alone can't keep up. A wording-independent rule would end the whack-a-mole: when a terminal error arrives and the attempt emitted zero content blocks, classify it asEMPTY_RESPONSEregardless of message text. That implements the "nothing durable → safe to repeat" principle at the layer that actually knows how much was produced.Workaround until then
Provider-level config, no code change required:
Two caveats worth knowing before copying this:
retryableCodesreplaces the default list rather than merging with it, and thePI_AI_ERRORcatch-all also carriespending/deferredendings thatmapStopReason()'s docstring marks as intentionally non-retryable — so it is best scoped to the route that actually needs it.All reactions