fix(notice): a refused request is not an unavailable backend #893

Merged
coilysiren merged 1 commit from aos/claude/hs68-model-rejected into main 2026-08-17 05:43:00 +00:00
Member

The harness half of #875. Not its cause - see the last section, which matters more than this PR does.

What was wrong

A member was told model backend unavailable, retry shortly while DeepSeek was answering every single call and refusing the request this service built.

A modelHTTPError with a 4xx status fell through turnFailureNotice's type switch to the stageModel fallback, which is the availability notice. So a malformed-request rejection and a genuine outage read identically.

The advice could not work. Retrying rebuilt the same malformed message array and drew the same 400. That is the sirens-echo#449 family - a true sentence pointing somewhere useless - and #875's own Ask names it:

Do not report a 400 as "model backend unavailable." A malformed-request rejection and a backend outage need different user-facing text, because one is worth retrying and the other never is.

The change

rejectedByModel classifies a 4xx as the backend answering and refusing, ahead of the stage fallback. The member is told the harness built a request the model refused.

429 and 408 are excluded, because those are the two 4xx a wait does fix. They stay retryable and keep the availability notice - otherwise this would trade one misdirection for another, and a test pins it.

The cause is model_rejected rather than stage_failed, so a malformed-request class is countable on its own instead of collapsing into the stage every model failure shares. That is the same reason #875 went unnoticed: it looked like an availability problem in every aggregate.

Tests

test holds
TestAMalformedRequestIsNotReportedAsAnOutage a 400 no longer renders the unavailable notice
TestAnUnavailableBackendStillReportsAsOne 502/503/504 still do, and stay retryable
TestRateLimitAndRequestTimeoutAreNotRejections 429 and 408 are not rejections
TestARejectedRequestIsNeverRetried 400/404/422 classify and never retry
TestOnlyAModelStatusBecomesARejection a deadline, silence, and nil do not

The part that actually fixes #875, which is not here

The malformed array is built by Agent Proxy's context trimmer, and the fix landed there on 2026-08-13.

sirens-echo has no trimmer - the only trim in internal/ is strings.TrimSpace. agent-proxy's 1cdf8ec added group_tool_call_turns and a pre-dispatch unpaired_tool_message check, which is exactly what #875 asks for.

The deployed image is 349dd56, dated 2026-08-12, and does not contain either function:

$ git merge-base --is-ancestor 1cdf8ec 349dd561 ; echo $?
1
$ git show 349dd561:app/analysis.py | grep -c "group_tool_call_turns\|unpaired_tool_message"
0

So the P0 needs a rollout: coilyco-bridge/deploy#621, role/ops priority/P0. Full evidence on #875.

This PR does not close #875 and I have not weakened the reference to pretend otherwise. It closes the notice slice, filed as #894, which is worth landing either way: a 4xx reported as an outage would still be wrong after the roll.

The wording is member-facing and Content Creator's to change. I matched the existing phrasing rather than choosing a voice.

just gate passes.

closes #894

The harness half of #875. **Not its cause** - see the last section, which matters more than this PR does. ## What was wrong A member was told `model backend unavailable, retry shortly` while DeepSeek was answering every single call and refusing the request this service built. A `modelHTTPError` with a 4xx status fell through `turnFailureNotice`'s type switch to the `stageModel` fallback, which is the availability notice. So a malformed-request rejection and a genuine outage read identically. **The advice could not work.** Retrying rebuilt the same malformed message array and drew the same 400. That is the sirens-echo#449 family - a true sentence pointing somewhere useless - and #875's own **Ask** names it: > Do not report a 400 as "model backend unavailable." A malformed-request rejection and a backend outage need different user-facing text, because one is worth retrying and the other never is. ## The change `rejectedByModel` classifies a 4xx as the backend answering and refusing, ahead of the stage fallback. The member is told the harness built a request the model refused. **`429` and `408` are excluded**, because those are the two 4xx a wait does fix. They stay retryable and keep the availability notice - otherwise this would trade one misdirection for another, and a test pins it. The cause is `model_rejected` rather than `stage_failed`, so a malformed-request class is countable on its own instead of collapsing into the stage every model failure shares. That is the same reason #875 went unnoticed: it looked like an availability problem in every aggregate. ## Tests | test | holds | | --- | --- | | `TestAMalformedRequestIsNotReportedAsAnOutage` | a 400 no longer renders the unavailable notice | | `TestAnUnavailableBackendStillReportsAsOne` | 502/503/504 still do, and stay retryable | | `TestRateLimitAndRequestTimeoutAreNotRejections` | 429 and 408 are not rejections | | `TestARejectedRequestIsNeverRetried` | 400/404/422 classify and never retry | | `TestOnlyAModelStatusBecomesARejection` | a deadline, silence, and nil do not | ## The part that actually fixes #875, which is not here **The malformed array is built by Agent Proxy's context trimmer, and the fix landed there on 2026-08-13.** `sirens-echo` has no trimmer - the only `trim` in `internal/` is `strings.TrimSpace`. `agent-proxy`'s `1cdf8ec` added `group_tool_call_turns` and a pre-dispatch `unpaired_tool_message` check, which is exactly what #875 asks for. The deployed image is `349dd56`, dated **2026-08-12**, and does not contain either function: ``` $ git merge-base --is-ancestor 1cdf8ec 349dd561 ; echo $? 1 $ git show 349dd561:app/analysis.py | grep -c "group_tool_call_turns\|unpaired_tool_message" 0 ``` So the P0 needs a rollout: **coilyco-bridge/deploy#621**, `role/ops` `priority/P0`. Full evidence on #875. **This PR does not close #875** and I have not weakened the reference to pretend otherwise. It closes the notice slice, filed as **#894**, which is worth landing either way: a 4xx reported as an outage would still be wrong after the roll. **The wording is member-facing and Content Creator's to change.** I matched the existing phrasing rather than choosing a voice. `just gate` passes. closes #894
fix(notice): a refused request is not an unavailable backend
All checks were successful
ci / test (pull_request) Successful in 54s
ci / publish-echo-image (pull_request) Has been skipped
ci / publish-observed (pull_request) Has been skipped
ci / image-build (pull_request) Successful in 33s
73d90bc501
sirens-echo#875 reports a member being told "model backend unavailable, retry
shortly" while DeepSeek was answering every call and refusing a malformed
message array. The advice could not work, because retrying rebuilt the same
array. Same family as sirens-echo#449.

A 4xx means the backend answered and refused what this service built.
rejectedByModel classifies it, and the notice says the harness built a request
the model refused rather than blaming the backend.

429 and 408 are excluded, because those are the two 4xx a wait does fix. They
stay retryable and keep the availability notice.

The cause is model_rejected rather than stage_failed, so a malformed-request
class is countable on its own instead of collapsing into the stage that every
model failure shares.

This is the harness half of sirens-echo#875 and not its cause. The malformed
array is built by agent-proxy's context trimmer, and the fix for that landed
upstream on 2026-08-13 and is not deployed. Evidence on the issue.

The notice wording is member-facing and Content Creator's to change. I matched
the existing phrasing rather than choosing a voice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo!893
No description provided.