Repository navigation
A refused round explained itself with an HTTP header dump (#476, code half) - #961
Conversation
The requested-vs-serving half of #476 shipped this morning (#938, #944): the round records the model that ANSWERED, `ThreadMessage.RequestedModelName` carries the one that was asked for, and a round no model can serve now fails with a localized message instead of settling Completed. What none of that touched is the case the portal actually dies on, because it is not a credential problem at all. `HasUsableCredential` proves a key resolves. It cannot prove the deployment will SERVE — a model with a perfect key still refuses every round when it is out of quota (429) or when the endpoint itself is faulting (500). #476's own repro is exactly that: the fallback picks a credentialed model and dies on its rate limit. So the requirement is not "never fall back onto a throttled model" — nothing local can predict throttling — it is: when the provider refuses, fail LEGIBLY. It did not. `ThreadExecution`'s terminal catch pasted `ex.Message` into the response cell's Text and the thread's Summary. For an Azure / System.ClientModel transport failure that string is the status line, then the JSON body, then the COMPLETE HTTP response header block (`x-ms-client-request-id`, `x-ratelimit-remaining-requests`, `Retry-After`, …) — in English, whatever the viewer's language. The thread's only account of why it failed was an unreadable dump, and because the round had been silently substituted, that dump named a model the user never picked with nothing anywhere saying why. Fix — the same presentation/condition split #944 established, extended to the provider: - `ProviderFailureClassifier` (new, pure, no provider-SDK dependency) names the condition off the exception CHAIN — the streaming pipeline wraps provider faults, so the transport exception is rarely outermost. Typed `HttpRequestException.StatusCode` first; otherwise the conventional `Status: NNN` banner Azure.Core and System.ClientModel both render. The banner probe is anchored and bounded to three digits so prose ("Status: ok") or a longer run ("Status: 4291 tokens") yields nothing rather than an invented status. - `ThreadExecution` builds the prose AT WRITE TIME off the round's own `AccessContext.Locale` — explicit, never ambient `CultureInfo` (a round hops schedulers): `chat.modelRateLimited` (429) / `chat.modelProviderError` (5xx), each naming the model that actually served. - A SUBSTITUTED round adds one sentence, `chat.modelSubstitutionNote`, naming the requested model and the one used instead. The swap stays silent on a HEALTHY round (directive 2026-07-13 — an unusable pin is not actionable mid-thread); on a FAILED one it is the difference between an explicable thread and a baffling one, since the failure names a model the user never chose. - Anything unclassified keeps its own message VERBATIM. For a tool fault or a bug of ours that message IS the diagnosis; replacing it with tidy prose would be the real information loss. Nothing is swallowed and nothing is retried: the raw provider text stays on the `LogError(ex, …)` that already precedes the terminal write, now joined by a `PROVIDER_REFUSED` warning carrying the status, the serving model and the requested one. No quota probe, no circuit breaker, no watchdog — a refusal is reported, not worked around. Also folds the three repeated `actualModel ?? effectiveModel ?? request.ModelName` spellings in that block into one `servingModel` local, so the cell, the token satellite and the message cannot drift apart. Keys added to strings.en.json + strings.de.json (LocalizationTest gates the pair). Tests: `ProviderFailureClassifierTest` pins both directions on verbatim-shaped Azure dumps — the 429 and the 500 classify, a wrapped chain still classifies, and an ordinary fault does NOT (over-classifying would erase a real diagnosis). `ProviderRefusalRoundTest` drives a real round whose substitute has a GOOD credential and then refuses with the full header dump: the round ends Error, the cell and the thread summary carry the localized condition plus the substitution sentence, no header line reaches either, and the record still attributes the round to the model that ran. Part 1 of #476 — a deployment with real quota on memex.systemorph.com — is unaffected by this and remains a maintainer/Azure action. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR addresses issue #476’s “provider refusal” failure mode by classifying provider-side HTTP failures (429 / 5xx) and rendering a localized, user-readable terminal message (instead of pasting raw transport/header dumps into the thread), while also clarifying model substitution on failed rounds.
Changes:
- Add
ProviderFailureClassifierto detect provider refusal status codes through exception chains and letThreadExecutionrender localized, concise failure text (plus substitution note when applicable). - Add integration + unit tests that pin both the classifier boundaries and the end-to-end “provider refuses substituted model” round behavior.
- Add new localization keys (EN/DE) and documentation updates (What’s New + provider configuration guidance).
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/MeshWeaver.AI.Test/ProviderRefusalRoundTest.cs | New integration test covering a substituted round that fails due to provider refusal and ensuring raw header dumps do not reach thread/cell text. |
| test/MeshWeaver.AI.Test/ProviderFailureClassifierTest.cs | New unit tests pinning classification behavior for 429/5xx and ensuring non-provider faults are not over-classified. |
| src/MeshWeaver.Messaging.Hub/Localization/strings.en.json | Adds localized strings for rate-limited/provider-error conditions and substitution note (EN). |
| src/MeshWeaver.Messaging.Hub/Localization/strings.de.json | Adds localized strings for rate-limited/provider-error conditions and substitution note (DE). |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-08-legible-provider-refusal.md | New What’s New entry describing the improved failure presentation. |
| src/MeshWeaver.Documentation/Data/AI/ProviderConfiguration.md | Documents the quota/refusal behavior and how the system now classifies/presents provider failures. |
| src/MeshWeaver.AI/ThreadExecution.cs | Uses provider-failure classification to avoid leaking raw transport dumps into thread/cell text; logs structured refusal info; unifies serving-model selection via a local. |
| src/MeshWeaver.AI/ProviderFailureClassifier.cs | New pure classifier that extracts HTTP status from typed exceptions or Azure-style “Status: NNN” banners across the exception chain. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Test Results (shard 0)685 tests 685 ✅ 5m 14s ⏱️ Results for commit 5b8ae25. ♻️ This comment has been updated with latest results. |
Test Results (shard 3) 11 files 11 suites 5m 17s ⏱️ Results for commit 5b8ae25. ♻️ This comment has been updated with latest results. |
Test Results (shard 1)1 198 tests 1 198 ✅ 7m 49s ⏱️ Results for commit 5b8ae25. ♻️ This comment has been updated with latest results. |
Test Results (shard 4)1 216 tests 1 209 ✅ 6m 51s ⏱️ Results for commit 5b8ae25. ♻️ This comment has been updated with latest results. |
Test Results (shard 5)1 710 tests 1 519 ✅ 5m 58s ⏱️ Results for commit 5b8ae25. ♻️ This comment has been updated with latest results. |
Test Results (shard 2)1 859 tests 1 857 ✅ 5m 49s ⏱️ Results for commit 5b8ae25. ♻️ This comment has been updated with latest results. |
The inner waits summed to exactly the 120s method timeout — 30s resolver + 60s thread + 30s cell, before the five seeds that run ahead of the first assertion. If any stage spent its full allowance, xUnit's abort won the race, and that abort carries no assertion message: you lose the one fact worth having, which stage hung. Each stage bound is now a named constant sized as a broken-fixture detector (the healthy test settles in under a second), and the ceiling is computed from their sum with a 33% margin rather than written as a literal. Change a stage and the ceiling follows; it cannot silently drop below the total it has to clear. The restated table in the doc comment had drifted to an earlier iteration's numbers — 135s/180s against constants totalling 90s/120s. Corrected, and marked as the one part that can drift, since a comment claiming numbers the code does not have is the failure this change exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the last open piece of the code half of #476.
Do not close #476 when this merges — its Part 1 is still outstanding (below).
What was already fixed, and what was left
#938 and #944 (both merged this morning) covered the requested-vs-serving half:
the round records the model that answered,
ThreadMessage.RequestedModelNamecarries the one that was asked for, an operator gets
MODEL_SUBSTITUTED, and around that no model can serve now fails with a localized message rather than
settling
Completed. Token attribution follows the same truth via #933/#595.What none of that reached is the failure the portal actually dies on. It is not a
credential problem:
HasUsableCredentialproves a key resolves, never that thedeployment will serve. A model with a perfect key still refuses every round when
it is out of quota (429) or when the endpoint is faulting (500) — which is #476's
own repro, the fallback landing on a credentialed model and dying on its rate limit.
Nothing local can predict throttling, so the requirement is not "never fall back onto
a throttled model" — it is fail legibly when the provider refuses.
The defect
ThreadExecution's terminal catch pastedex.Messageinto the response cell's Textand the thread's Summary. For an Azure / System.ClientModel transport failure that
string is the status line, then the JSON body, then the complete HTTP response
header block (
x-ms-client-request-id,x-ratelimit-remaining-requests,Retry-After, …) — in English, whatever the viewer's language. That dump was thethread's only account of why it failed; and because the round had been silently
substituted, it named a model the user never picked with nothing saying why.
The fix
Same presentation/condition split #944 established, extended to the provider:
ProviderFailureClassifier(new, pure, no provider-SDK dependency) names thecondition off the exception chain — the streaming pipeline wraps provider
faults, so the transport exception is rarely outermost. Typed
HttpRequestException.StatusCodefirst, else the conventionalStatus: NNNbanner Azure.Core and System.ClientModel both render. The banner probe is anchored
and bounded to three digits, so
"Status: ok"or"Status: 4291 tokens"yieldsnothing rather than an invented status.
AccessContext.Locale—explicit, never ambient
CultureInfo(a round hops schedulers):chat.modelRateLimited(429) /chat.modelProviderError(5xx), each naming themodel that actually served.
chat.modelSubstitutionNote) naming therequested model and the one used instead. The swap stays silent on a healthy
round (directive 2026-07-13 — an unusable pin is not actionable mid-thread); on a
failed one it is the difference between an explicable thread and a baffling one.
of ours that message is the diagnosis — replacing it would be the real
information loss.
Nothing swallowed, nothing retried: the raw provider text stays on the
LogError(ex, …)that already precedes the terminal write, now joined by aPROVIDER_REFUSEDwarning carrying the status, the serving model and the requestedone. No quota probe, no circuit breaker, no watchdog — a refusal is reported, not
worked around.
Also folds the three repeated
actualModel ?? effectiveModel ?? request.ModelNamespellings in that block into a single
servingModellocal, so the cell, the tokensatellite and the message cannot drift apart.
Tests
ProviderFailureClassifierTest— 16 cases pinning both directions onverbatim-shaped Azure dumps: the 429 and the 500 classify, a wrapped chain still
classifies, and an ordinary fault does not (over-classifying erases a real
diagnosis).
ProviderRefusalRoundTest— drives a real round whose substitute has a goodcredential and then refuses with the full header dump. Asserts the round ends
Error, the cell text and thread summary carry the localized condition plus thesubstitution sentence, no header line reaches either, and the record still
attributes the round to the model that ran.
New file rather than edits to
ModelSubstitutionTest, deliberately — #960(
fix/948-round-locale-propagation) is touching that file for locale, and this addsno assertion it already makes.
Localization keys added to both
strings.en.jsonandstrings.de.json(
LocalizationTestgates the pair).Verification
Branch synced with
main(621558c4b).dotnet build -c Release -warnaserrorcleanfor
MeshWeaver.AI,MeshWeaver.Messaging.Hub,MeshWeaver.Documentation,MeshWeaver.AI.Test.ProviderFailureClassifierTestProviderRefusalRoundTestModelSubstitutionTest(regression)LocalizationTestDocumentationLinkIntegrityTest🤖 Generated with Claude Code