From 814dca93903dfae36610e2cb2def782d05b16cfd Mon Sep 17 00:00:00 2001 From: yetone Date: Thu, 8 Oct 2026 11:32:14 +0800 Subject: [PATCH] gateway: the ChatGPT backend's 502 "response protection is unavailable" is the request's, not the account's: asked once, no account rests, and a magpie-model compaction refused so is asked again as text (vs on Discord) A long Codex conversation's compaction refused so went to every ChatGPT account in turn, each rested on a growing backoff, then the last was asked twice more: 5 upstream tries per Codex request with 3 accounts, times Codex's own retries. Now the provider's other accounts and models are not asked it, nor the same one after a moment; another vendor left in a group is asked once. A reply broken off with it rests nobody either. codexCompact falls back to plainCompact on it, as vs's control experiment (the same history as text) was answered. TestProtectionRefusalAskedOnceNobodyRests fails on the old code with "asked 5 times ([acct-1 acct-3 acct-2 acct-2 acct-2]), not once" and two accounts resting; TestProtectionRefusedCompactionAsText with status 502. The error body's exact bytes are not the reporter's; no real ChatGPT account was tried. --- docs/subsystems/gateway-routing.md | 6 +- internal/gateway/codex_backend.go | 7 +- internal/gateway/fallback.go | 30 ++++++- internal/gateway/gateway.go | 26 ++++++- internal/gateway/protection_test.go | 117 ++++++++++++++++++++++++++++ 5 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 internal/gateway/protection_test.go diff --git a/docs/subsystems/gateway-routing.md b/docs/subsystems/gateway-routing.md index 400fb5f0..83eb2cd5 100644 --- a/docs/subsystems/gateway-routing.md +++ b/docs/subsystems/gateway-routing.md @@ -35,7 +35,7 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md). - Sealed tasks (#619) are checked here. A Codex subagent's task its lead's `spawn_agent` sealed goes only to a ChatGPT account, or back to the Responses provider that answered the lead (`leadProvider`, a relay of the ChatGPT backend such as Sub2API, #1109), relayed as it came even when web search is offered. A lead's turn carrying its subagent's sealed reply (`send_message`) goes back to the Responses provider that answered the lead's own conversation (`leadProvider` of the request's `conversationID`, #1237); otherwise it is refused before anyone is asked (`sealedReader`, `sealedTaskError`). - Rules and image inputs are handled here too. 3. Candidates come from `planGroup` for a group or `plan` for a provider. They are filtered by `sealedReaders` and `allowedCandidates`. An account past its share is held out of the candidates (`capHeld`): a usage cap's share, or 100% of a window for a Codex account set not to spend its credits (`provider.HoldShare`, `CodexNoCredits`). When every candidate is held and one held for its credits spends its resets by itself, `autoReset` spends a reset on it and it is tried (`planned.held`); otherwise a capped or barred set returns `cappedError` or `barredError`. Then `affine` moves the conversation's last answerer first, unless it is resting or nearly used up. An `X-Magpie-Account` header (`AccountHeader`) pins the request to one account: only that account is tried, and when it can't answer the caller is told why. -4. The candidate loop calls `attempt` on each candidate in turn, through a `holdWriter`. The writer holds the reply until it is clearly passing. A candidate that fails before anything is sent rests (`failure`): out of credit for 30 minutes, out of quota until reset, rate limited until retry-after, otherwise a minute and longer on repeats. A subscription account that fails any other way with a window full, by magpie's latest reading of its windows, rests until that window renews. Both quota rests and window rests are held to `longestQuota` (8 days), so a reset time read wrong (a year off) doesn't bench the account for that long. The next candidate is then tried. A failed provider waits `fallbackCooldown` (1 minute) at the back. The vendor's safety filter refusing a reply rests nobody, whether it refuses the turn before any of the reply is said or once some of it has been said (`refused`, `refusedAfter`). +4. The candidate loop calls `attempt` on each candidate in turn, through a `holdWriter`. The writer holds the reply until it is clearly passing. A candidate that fails before anything is sent rests (`failure`): out of credit for 30 minutes, out of quota until reset, rate limited until retry-after, otherwise a minute and longer on repeats. A subscription account that fails any other way with a window full, by magpie's latest reading of its windows, rests until that window renews. Both quota rests and window rests are held to `longestQuota` (8 days), so a reset time read wrong (a year off) doesn't bench the account for that long. The next candidate is then tried. A failed provider waits `fallbackCooldown` (1 minute) at the back. The vendor's safety filter refusing a reply rests nobody, whether it refuses the turn before any of the reply is said or once some of it has been said (`refused`, `refusedAfter`). The ChatGPT backend's 502 "response protection is unavailable" is about the request's content, not the account: the same history fails the same on each of its accounts and models. It rests nobody, isn't tried again after a moment, and isn't asked of the provider's other accounts or models; another provider left in a group is asked once (`protectionRefused`, `elsewhere`). 5. The last candidate's error reaches the agent. A try that answered is recorded (`served`, `answered`) for routing, affinity and usage. ## Constraints and failure behavior @@ -55,7 +55,7 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md). - An account resting out of its window (a Claude account by the time Claude Code's refusal named, `resets`, or any by `window`) is back once magpie's reading of its windows (`provider.Allowances`) finds it full till sooner: the window used below `SpentShareOf`, or its reset passed, with no other window over the same models still full. The reading tells `provider.OnRenewed` as the account's `UsageAgent` and user, and `renewed` lifts that account's rests out of quota or by `window`; its rests for a rate limit, its credit, a verification or a failure stay, as a key's do, and so does every rest of other accounts still out of their windows. Planning reads every account candidate's allowance, ordered groups included, so this needs no step of its own in `restingOf`. - Only an account is held at a usage cap (`capHeld`) or rested out of one model's pool (`pooled`); a key's windows order it and rest it, nothing more. - Codex offers its image tool (the `image_gen` namespace, #870) to every magpie provider. When a provider other than a ChatGPT sign-in or openai.com answers a bare 400, `attempt` asks it once more without the tool (`image_tool.go`). This happens both passed through and translated. If that works, the provider is marked unfit for the tool (`tool:image_gen`) and isn't offered it again. If it doesn't, the tool is restored and the 400 stands (#949). -- A translated Responses reply gives Codex reasoning items magpie made: an `rs_` id and a summary, nothing sealed. Codex hands them back on every turn. A request relayed as is to OpenAI's API or Azure OpenAI goes without them (`withoutBareReasoning`) unless `store` is true, because both look the id up among stored items and answer 400 "Item with id 'rs_…' not found" (#1008). Any other Responses upstream gets them, because a DeepSeek model wants its reasoning back (#388). When one answers 400 naming the input (`refusesInput`: `param` is `input`, or the error names an item by id), as a relay in front of OpenAI does with a message of its own, `passthrough` asks once more without them and, once that works, leaves them out for that provider and model from then on (`bareReasoningRefused`, #1044). Codex's compaction offers no tools, so it is relayed even when its turns were translated. When a provider answers its summary request 404, or 400 for an item it didn't find (`itemNotFound`, a relay in front of either), `codexCompact` asks once more with the conversation as text (`plainCompact`), then compacts locally (#866, #1008). +- A translated Responses reply gives Codex reasoning items magpie made: an `rs_` id and a summary, nothing sealed. Codex hands them back on every turn. A request relayed as is to OpenAI's API or Azure OpenAI goes without them (`withoutBareReasoning`) unless `store` is true, because both look the id up among stored items and answer 400 "Item with id 'rs_…' not found" (#1008). Any other Responses upstream gets them, because a DeepSeek model wants its reasoning back (#388). When one answers 400 naming the input (`refusesInput`: `param` is `input`, or the error names an item by id), as a relay in front of OpenAI does with a message of its own, `passthrough` asks once more without them and, once that works, leaves them out for that provider and model from then on (`bareReasoningRefused`, #1044). Codex's compaction offers no tools, so it is relayed even when its turns were translated. When a provider answers its summary request 404, or 400 for an item it didn't find (`itemNotFound`, a relay in front of either), `codexCompact` asks once more with the conversation as text (`plainCompact`), then compacts locally (#866, #1008). So does the ChatGPT backend's 502 "response protection is unavailable" (`protectionRefused`): a long history refused so was answered as text. - Structured output is carried across every translation (`Format`, [`format.go`](../../internal/gateway/format.go)). It is read from Responses' `text.format`, Chat's `response_format`, Anthropic's `output_config.format` and Gemini's `responseMimeType` with `responseJsonSchema`/`responseSchema`: `json_schema` with its name, description, schema and strict, or `json_object`. Each upstream is asked in its own field: `text.format` on Responses, `response_format` on Chat, `output_config.format` on Messages, and `generationConfig.responseMimeType`/`responseJsonSchema` on Gemini and Code Assist. An API with no name for it gets `response`. Messages and the Claude subscription's `--json-schema` take a schema only, so `json_object` is told in the system prompt there (`inSystem`). The bridges to a vendor's own API (Cursor, Devin, Kiro, Qoder, Command Code, Zed) are always told in the system prompt. A 400 or 422 that names the field (`refusesFormat`: DeepSeek's "This response_format type is unavailable now", a schema Anthropic can't hold) marks the provider and model (`formatRefused`), and the request is asked again with the format told in the system prompt, as are later requests to that provider and model. A relayed request keeps its format as sent. A Gemini client's format is also still told in its system prompt, as before. - Group members are `provider/model[:effort]`. A member with a fixed effort is asked for that effort, bounded by the group's levels (#671). - An agent whose config can't carry an effort has one picked in magpie (`provider.AgentEffort`, settings' `agentEfforts`; Cursor Private Inference's row, #1003). It is asked for on that agent's requests to a model that reasons (`Provider.Thinks`), in place of the level the request sent, fitted to the model's levels and bounded by the group's. Order: a member's fixed effort, then a model's `:effort` suffix, then the turn's pick (a routing rule's), then the agent's pick, then the request's own. A model that doesn't reason is sent as asked; the default (none picked) leaves every request as it came. The pick beats the request's own because Cursor sends its own default as often as a real choice. Picking Default hands the choice back to Cursor. @@ -64,7 +64,7 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md). ## Verification ```sh -go test -tags nogui ./internal/gateway -run 'TestQuiet|TestSilentHeld|TestRotateSpreadsSessions|TestRouting|TestSmartRouting|TestFallback|TestNoFallbackForOtherErrors|TestLastFallbackErrorReachesTheAgent|TestRateLimitedSinksToTheBack|TestGroupRateLimitedSinks|TestAffinity|TestSeveralKeysOnTakeOverFromEachOther|TestSubscriptionAccountsTakeOver|TestGroup|TestTrace|TestImageTool|TestCodexNoCredits|TestClaudeGoesOnMessagesWhereTheRelayHasThem|TestAutoRelaysOnTheClientsOwnAPI|TestSearchOfferedStaysOnTheClientsAPI|TestCodexSearchAnsweredOnResponses|TestAgentEffortOnCursorLocal|TestAzureCompactionWithoutMagpiesReasoning|TestCodexCompact|TestNotAnAPIReply|TestAPIReplyUntouched|TestCodexBackendWebPage|TestSub2APIKeyLimitIsQuota|TestKeysWeighedByTheirWindows|TestGroupWeighsKeyWindows|TestKeyOutOfItsWindowRestsUntilReset|TestKeyPoolErrorDoesNotRestForWindow|TestKeyBackOnceItsWindowIsNotFull|TestRestingKeyReadAgainInAnOrderedGroup|TestClaudeAccountBackOnceItsWindowRenews|TestThinkingStreams|TestHeldThinkingKeepsTheAgentAlive|TestRefusalAfterThinkingFailsOver|TestGeminiEmptyRepliesAskedAgain|TestLocalUnredacted|TestCORS|TestForeignPagesRefused|TestGeminiThinkingOffAtItsLowest|TestGeminiThoughtsMarkedInExtraContent|TestClaudeSignInNotToldItsSignInFailed|TestSignInWriterReleasesA403|TestSignInBetaStaysWithMagpie|TestStructuredOutput|TestResponsesTextKeepsVerbosityBesideFormat|TestRefusedFormatToldInWords' +go test -tags nogui ./internal/gateway -run 'TestQuiet|TestSilentHeld|TestRotateSpreadsSessions|TestRouting|TestSmartRouting|TestFallback|TestNoFallbackForOtherErrors|TestLastFallbackErrorReachesTheAgent|TestRateLimitedSinksToTheBack|TestGroupRateLimitedSinks|TestAffinity|TestSeveralKeysOnTakeOverFromEachOther|TestSubscriptionAccountsTakeOver|TestGroup|TestTrace|TestImageTool|TestCodexNoCredits|TestClaudeGoesOnMessagesWhereTheRelayHasThem|TestAutoRelaysOnTheClientsOwnAPI|TestSearchOfferedStaysOnTheClientsAPI|TestCodexSearchAnsweredOnResponses|TestAgentEffortOnCursorLocal|TestAzureCompactionWithoutMagpiesReasoning|TestCodexCompact|TestProtection|TestNotAnAPIReply|TestAPIReplyUntouched|TestCodexBackendWebPage|TestSub2APIKeyLimitIsQuota|TestKeysWeighedByTheirWindows|TestGroupWeighsKeyWindows|TestKeyOutOfItsWindowRestsUntilReset|TestKeyPoolErrorDoesNotRestForWindow|TestKeyBackOnceItsWindowIsNotFull|TestRestingKeyReadAgainInAnOrderedGroup|TestClaudeAccountBackOnceItsWindowRenews|TestThinkingStreams|TestHeldThinkingKeepsTheAgentAlive|TestRefusalAfterThinkingFailsOver|TestGeminiEmptyRepliesAskedAgain|TestLocalUnredacted|TestCORS|TestForeignPagesRefused|TestGeminiThinkingOffAtItsLowest|TestGeminiThoughtsMarkedInExtraContent|TestClaudeSignInNotToldItsSignInFailed|TestSignInWriterReleasesA403|TestSignInBetaStaysWithMagpie|TestStructuredOutput|TestResponsesTextKeepsVerbosityBesideFormat|TestRefusedFormatToldInWords' go test -tags nogui ./internal/provider -run 'TestGroup|TestCodexCreditsSwitch|TestRenewedAccountForgetsItsAllowance|TestNative|TestKeyAllowance|TestPlanKeyAllowance|TestClaudeAccountToldRenewed|TestPluginAccountToldRenewed|TestSkipsRedaction' ``` diff --git a/internal/gateway/codex_backend.go b/internal/gateway/codex_backend.go index e8c15275..9d310dde 100644 --- a/internal/gateway/codex_backend.go +++ b/internal/gateway/codex_backend.go @@ -1259,13 +1259,16 @@ func userMessage(text string) map[string]any { // asked once more with the conversation as plain text, none of its items' // ids or sealed reasoning in it; when that fails too the summary is // magpie's own, the conversation's user messages and last reply, -// so the compaction still completes and Codex goes on. Any other failure +// so the compaction still completes and Codex goes on. So is one the +// ChatGPT backend answers 502 "response protection is unavailable" (vs on +// Discord): the same long history failed so on every account and model, +// and as plain text was summarised. Any other failure // (401, 429, 500) goes back to Codex as it came. func (s *Server) codexCompact(w http.ResponseWriter, r *http.Request, body []byte) { rec := &recorder{header: http.Header{}, status: 200} s.serve(rec, r, provider.Responses, body) local := "" - if first := rec.status; first == http.StatusNotFound || itemNotFound(rec) { + if first := rec.status; first == http.StatusNotFound || itemNotFound(rec) || protectionRefused(first, rec.body.Bytes()) { who, msg := compactFailure(body, rec) log.Printf("codex compaction: %s answered %d (%s); asking again with the conversation as text", who, first, msg) rec = &recorder{header: http.Header{}, status: 200} diff --git a/internal/gateway/fallback.go b/internal/gateway/fallback.go index 2c854247..64ef4534 100644 --- a/internal/gateway/fallback.go +++ b/internal/gateway/fallback.go @@ -736,6 +736,34 @@ func shapeRefused(status int, body []byte) bool { !quotaWords.Match(body) && !unservedWords.Match(body) && !refusedWords.Match(body) } +// protectionWords are how the ChatGPT backend answers a long Codex +// conversation it won't take as it is: 502 "response protection is +// unavailable" (vs on Discord, 0.1.1108). It comes back the same for the +// same history on every account and model the backend serves, and the +// history as plain text is answered, so it is about the request, not the +// account: asked again there, it only drains the accounts' allowances. +var protectionWords = regexp.MustCompile(`(?i)response protection is unavailable`) + +// protectionRefused says the vendor turned the request's content away +// with protectionWords: the account is not at fault, and none of its +// provider's other accounts or models is asked the same. +func protectionRefused(status int, body []byte) bool { + return status >= 400 && protectionWords.Match(body) +} + +// elsewhere is, of the candidates left, those not at c's provider, whose +// every account and model the same request reaches the same backend +// through. +func elsewhere(left []candidate, c candidate) []candidate { + var out []candidate + for _, x := range left { + if x.p.ID != c.p.ID { + out = append(out, x) + } + } + return out +} + // retryable says whether another provider may do better with a request // that failed this way: the vendor was busy, out of quota or failing, or // this key or provider can't serve it — not the request itself at fault. @@ -763,7 +791,7 @@ func retryable(status int, body []byte) bool { // vendor turned away as it reads, would fail the same at the next asked: // nobody rests for it. func lateRests(msg string) bool { - return !tooLong(http.StatusBadRequest, msg) && !refusedWords.MatchString(msg) + return !tooLong(http.StatusBadRequest, msg) && !refusedWords.MatchString(msg) && !protectionWords.MatchString(msg) } // unsaidMargin is how far past a model's window a request's estimate diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index ddec48ea..989f6d4e 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -2192,7 +2192,29 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro skipped = append(skipped, c.label()+": "+call.Error) continue } - if wait, ok := passing(hw.code(), hw.header, hw.errBody(), again); ok && !last && hw.failed() && spentAfter(cands[i+1:]) { + // the ChatGPT backend's 502 "response protection is unavailable" + // (vs on Discord): the same history fails the same on every one + // of its accounts and models, so none of them is asked it again, + // nor this one after a moment, and nobody rests — 62 compactions + // had cost 616 tries, and the accounts their allowances. Another + // vendor left in the group is asked, once + protected := !hw.passing && !hw.refused && protectionRefused(hw.code(), hw.errBody()) + if protected { + if other == nil { + other = &Try{Status: call.Status, Error: call.Error} + } + try.Fail = failOther + if left := elsewhere(cands[i+1:], c); len(left) > 0 { + try.Fail = failShape + s.trace.update(tr, func(t *Route) { t.Tries[len(t.Tries)-1] = try }) + skipped = append(skipped, c.label()+": "+call.Error) + cands = append(cands[:i+1:i+1], left...) + continue + } + cands = cands[:i+1] + last = true + } + if wait, ok := passing(hw.code(), hw.header, hw.errBody(), again); ok && !protected && !last && hw.failed() && spentAfter(cands[i+1:]) { // the others left are out of their allowance (Discord, waroy: a // Codex account run out, Grok busy a moment): this one is the // last that may answer, and is tried again as the last is @@ -2219,7 +2241,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro skipped = append(skipped, c.label()+": "+call.Error) continue } - if wait, ok := passing(hw.code(), hw.header, hw.errBody(), again); ok && hw.failed() { + if wait, ok := passing(hw.code(), hw.header, hw.errBody(), again); ok && !protected && hw.failed() { // nobody else is left: the same one again, after a moment try.Fail, try.Again = failureOf(c, hw.code(), hw.errBody()), wait.Milliseconds() s.trace.update(tr, func(t *Route) { t.Tries[len(t.Tries)-1] = try }) diff --git a/internal/gateway/protection_test.go b/internal/gateway/protection_test.go new file mode 100644 index 00000000..2e939fa1 --- /dev/null +++ b/internal/gateway/protection_test.go @@ -0,0 +1,117 @@ +package gateway + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// A long Codex conversation's compaction, as Codex sends it: the history +// with sealed reasoning and calls, then the trigger. +const protectedCompaction = `{"model":%q,"stream":true,"store":false,"input":[` + + `{"type":"message","role":"user","content":[{"type":"input_text","text":"refactor the parser"}]},` + + `{"type":"reasoning","id":"rs_1","summary":[],"encrypted_content":"gAAAAABo-sealed"},` + + `{"type":"function_call","call_id":"call_1","name":"shell","arguments":"{\"cmd\":\"ls\"}"},` + + `{"type":"function_call_output","call_id":"call_1","output":"main.go"},` + + `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"done"}]},` + + `{"type":"compaction_trigger"}]}` + +// protectionUp stands in for the ChatGPT backend answering a structured +// history 502 "response protection is unavailable" on every account, and +// the history as plain text with a summary. It notes the accounts asked. +func protectionUp(t *testing.T, said string) *[]string { + t.Helper() + var mu sync.Mutex + tried := &[]string{} + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + mu.Lock() + *tried = append(*tried, r.Header.Get("chatgpt-account-id")) + mu.Unlock() + if !strings.Contains(string(b), "This is the conversation so far") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadGateway) + io.WriteString(w, said) + return + } + io.WriteString(w, sse( + `data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5.5"}}`, + `data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"the summary"}]}}`, + `data: {"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":7,"output_tokens":2}}}`)) + })) + t.Cleanup(up.Close) + was := provider.CodexBase + provider.CodexBase = up.URL + "/backend-api/codex" + t.Cleanup(func() { provider.CodexBase = was }) + return tried +} + +func restingNow(t *testing.T) []string { + t.Helper() + restingUntil.Lock() + defer restingUntil.Unlock() + var out []string + for k := range restingUntil.m { + out = append(out, k) + } + return out +} + +// vs on Discord (0.1.1108): a long Codex conversation's compaction on +// Codex's own model, three ChatGPT accounts on, the backend answering 502 +// "response protection is unavailable" for that history. It is the +// request, not an account: asked once, not on every account and then the +// last again, and no account rests for it. +func TestProtectionRefusalAskedOnceNobodyRests(t *testing.T) { + for _, said := range []string{ + `{"error":{"message":"response protection is unavailable","type":"server_error"}}`, + `{"detail":"response protection is unavailable"}`, + } { + t.Run(said, func(t *testing.T) { + codexSignedIn(t, "two@example.com", "three@example.com") + tried := protectionUp(t, said) + code, body := codexPost(t, strings.Replace(protectedCompaction, "%q", `"gpt-5.5"`, 1)) + if code != http.StatusBadGateway || !strings.Contains(body, "response protection is unavailable") { + t.Fatalf("status %d: %s", code, body) + } + if len(*tried) != 1 { + t.Errorf("asked %d times (%v), not once", len(*tried), *tried) + } + if r := restingNow(t); len(r) > 0 { + t.Errorf("resting after a refusal of the request: %v", r) + } + }) + } +} + +// The same compaction on a magpie model served by the ChatGPT accounts: +// refused so, it is asked again once with the conversation as plain text, +// as vs's control experiment was answered, and Codex gets its compaction. +func TestProtectionRefusedCompactionAsText(t *testing.T) { + codexSignedIn(t, "two@example.com") + tried := protectionUp(t, `{"error":{"message":"response protection is unavailable","type":"server_error"}}`) + code, body := codexPost(t, strings.Replace(protectedCompaction, "%q", `"codex/gpt-5.5"`, 1)) + if code != 200 || !strings.Contains(body, `"type":"compaction"`) || !strings.Contains(body, magpieCompaction) { + t.Fatalf("status %d: %s", code, body) + } + if len(*tried) != 2 { + t.Errorf("asked %d times (%v), not once as it came and once as text", len(*tried), *tried) + } + if r := restingNow(t); len(r) > 0 { + t.Errorf("resting: %v", r) + } +} + +func TestProtectionBreakOffDoesNotRest(t *testing.T) { + if lateRests("response protection is unavailable") { + t.Error("a reply broken off with the backend's refusal of the request rests its account") + } + if !lateRests("Unable to reach the model provider") { + t.Error("a vendor failing mid-reply no longer rests") + } +}