Background and motivation
Update (2026-08-22): This proposal was revised based on the discussion below. It now proposes a separate CascadingChatClient instead of extending FailoverChatClient.
The experimental routing APIs ([Experimental("MEAI001")]) introduced RoutingChatClient, SemanticRoutingChatClient, FailoverChatClient, and OrderedFailoverChatClient.
These types support selecting a client for a request and selecting another client after an eligible provider failure. What is still missing is an ordered routing mode that can continue after a request succeeds but the application does not accept the response quality.
That is the model-cascading pattern described by approaches such as FrugalGPT: start with a less expensive model, evaluate the completed response, and use a stronger model only when necessary.
This proposal does not define how quality is measured. That decision can remain in the application or evaluation layer and may use a predicate, a confidence score, Microsoft.Extensions.AI.Evaluation, or another application-specific policy.
Cascading and failover have different completion semantics. Failover is driven by provider failures and, for streaming, whether output has already been committed. Cascading is driven by a quality decision over a successful, completed ChatResponse. Keeping them as separate routing clients gives each behavior a smaller and clearer contract.
The two behaviors can still be combined through composition. Each cascade rung is an IChatClient, so a rung can itself be a resilient FailoverChatClient. The inner client handles provider failures within a tier, while the outer CascadingChatClient handles quality escalation between tiers.
A working proof of concept is available here:
https://github.com/WittBen/meai-quality-cascade
API Proposal
namespace Microsoft.Extensions.AI;
[Experimental("MEAI001")]
public abstract class CascadingChatClient : RoutingChatClient
{
protected CascadingChatClient(
IReadOnlyList<IChatClient> clients,
bool leaveOpen = false);
protected abstract ValueTask<bool> IsResponseAcceptedAsync(
RoutingContext context,
IChatClient client,
ChatResponse response,
CancellationToken cancellationToken);
}
No changes are proposed for RoutingContext, FailoverChatClient, or FailoverChatClientAttempt. In particular, this revision no longer proposes RoutingContext.AttemptNumber, FailoverChatClientAttempt.Response, or FailoverChatClient.ShouldSelectAgainAsync.
Behavioral contract
The constructor receives a non-empty ordered list of chat clients. The list is snapshotted and must not contain null entries.
Each request creates its own RoutingContext and starts with the first configured client. After a client returns a successful ChatResponse:
- If another client remains,
IsResponseAcceptedAsync is called.
- Returning
true returns the response immediately.
- Returning
false discards the response and invokes the next client.
- The final client's response is returned without another quality decision.
The final-response rule ensures that the finite client list always terminates. An application that requires a hard quality floor can validate the returned response outside the cascade.
The quality hook receives the request's RoutingContext, the client that produced the response, the completed ChatResponse, and the request cancellation token. This allows an application to apply request- or model-specific policy without introducing a judging abstraction into MEAI.
A provider exception, evaluation exception, or cancellation is propagated to the caller and does not cause quality escalation. Provider failover can be added by using a FailoverChatClient as an individual cascade rung.
The current rung is request-scoped and safe for concurrent requests. The implementation can follow the existing OrderedFailoverChatClient pattern and use a ConcurrentDictionary<RoutingContext, int>. Retained state is removed after completion, provider failure, evaluation failure, cancellation, or disposal.
CascadingChatClient does not provide native streaming. GetStreamingResponseAsync uses the non-streaming cascade path and converts the accepted response with ChatResponse.ToChatResponseUpdates(). The returned updates therefore do not provide native time-to-first-token latency.
The cascade owns and disposes its configured clients by default. Passing leaveOpen: true leaves their lifetime with the caller.
API Usage
A small concrete implementation can keep the quality policy in application code:
internal sealed class QualityCascadeChatClient : CascadingChatClient
{
private readonly Func<
RoutingContext,
IChatClient,
ChatResponse,
CancellationToken,
ValueTask<bool>> _isResponseAccepted;
public QualityCascadeChatClient(
IReadOnlyList<IChatClient> clients,
Func<
RoutingContext,
IChatClient,
ChatResponse,
CancellationToken,
ValueTask<bool>> isResponseAccepted,
bool leaveOpen = false)
: base(clients, leaveOpen)
{
_isResponseAccepted = isResponseAccepted;
}
protected override ValueTask<bool> IsResponseAcceptedAsync(
RoutingContext context,
IChatClient client,
ChatResponse response,
CancellationToken cancellationToken)
=> _isResponseAccepted(context, client, response, cancellationToken);
}
The application supplies the ordered clients and decides what an acceptable response means:
using IChatClient client = new QualityCascadeChatClient(
clients: [cheapClient, strongClient],
isResponseAccepted: static (context, selectedClient, response, cancellationToken) =>
{
cancellationToken.ThrowIfCancellationRequested();
double quality =
response.AdditionalProperties?.TryGetValue("quality", out object? value) == true
? Convert.ToDouble(value)
: 0;
return new ValueTask<bool>(quality >= 0.8);
});
ChatResponse response =
await client.GetResponseAsync("Summarize this contract.");
Provider failover can be composed within each quality tier:
IChatClient cheapTier = new OrderedFailoverChatClient(
[cheapPrimaryClient, cheapBackupClient]);
IChatClient strongTier = new OrderedFailoverChatClient(
[strongPrimaryClient, strongBackupClient]);
using IChatClient client = new QualityCascadeChatClient(
clients: [cheapTier, strongTier],
isResponseAccepted: EvaluateResponseAsync);
ChatResponse response =
await client.GetResponseAsync("Summarize this contract.");
In this composition, each inner OrderedFailoverChatClient handles provider failures for its tier. The outer CascadingChatClient evaluates successful responses and handles quality escalation between tiers.
The streaming API remains available as a completed-response facade:
await foreach (ChatResponseUpdate update in
client.GetStreamingResponseAsync("Summarize this contract."))
{
Console.Write(update.Text);
}
The first update is produced only after a complete response has been accepted.
Alternative Designs
Extend FailoverChatClient
The original proposal added FailoverChatClientAttempt.Response, RoutingContext.AttemptNumber, and FailoverChatClient.ShouldSelectAgainAsync.
That design could reuse the existing failover loop, but it mixes two different completion models. Failover completion depends on provider failures, cancellation, attempt limits, and streaming output commitment. Cascading completion depends on a quality decision over a successful response.
The original source-code observations remain valid:
FailoverChatClientAttempt does not expose the completed ChatResponse.
- A successful failover attempt is terminal before
OnRoutingUpdateAsync runs.
FailoverChatClient.GetResponseAsync and GetStreamingResponseAsync are sealed overrides.
These facts explain why a subclass workaround is not available, but the revised proposal does not require changes to the failover APIs.
Add an attempt number to RoutingContext
Adding AttemptNumber would expose policy state on the general routing context even though one-shot routing does not need it. Internal request state, following the OrderedFailoverChatClient pattern, keeps the base abstraction focused.
The ordered client list is also the complete attempt budget. Each client is selected at most once, so a quality predicate that always rejects cannot create an unbounded loop.
Buffer native streams
A buffer-before-commit implementation could consume provider updates, reconstruct a ChatResponse, evaluate it, and then replay or discard the updates. The final tier could stream live because no further quality escalation is possible.
This works, but accepted early-tier responses still produce no output until completion. It adds buffering, response reconstruction, enumerator, and commitment complexity without preserving the main latency benefit of streaming. Using the non-streaming path and ToChatResponseUpdates() is a clearer initial contract.
Keep cascading in application code
Applications can implement this loop today because RoutingChatClient leaves its invocation methods virtual. That is useful for experimentation, but each implementation must handle concurrent request state, cancellation, client ownership, disposal, and streaming behavior correctly.
A shared CascadingChatClient removes that repeated infrastructure without prescribing how applications measure quality.
Risks
Paid but discarded responses
A rejected response still consumes provider resources, tokens, and latency. Applications should measure escalation rate, discarded token usage, evaluator cost, and total request latency before assuming that a cascade is cheaper.
Streaming latency
The streaming facade does not provide live time to first token. Its first update is available only after a complete response has been accepted. This needs to be explicit so callers do not mistake update-shaped output for native streaming.
Capability differences between tiers
Clients in a ladder must be compatible with the requests routed to them. A tier without required tool calling, structured output, or message-history support cannot transparently replace another tier. The cascade does not normalize provider capabilities.
Quality-policy behavior
An evaluator may be slow, expensive, nondeterministic, or fail. Those effects remain part of the application policy. Evaluation failures are propagated instead of being silently converted into tier changes.
Final-response policy
The final response is returned even when an external judge might reject it. This guarantees finite progress through a fixed ladder. Applications requiring a strict minimum can validate the final response or use a domain-specific fallback as the last client.
Client lifetime
The cascade owns its clients by default. Applications that share client instances elsewhere must pass leaveOpen: true and manage those clients themselves.
Background and motivation
The experimental routing APIs (
[Experimental("MEAI001")]) introducedRoutingChatClient,SemanticRoutingChatClient,FailoverChatClient, andOrderedFailoverChatClient.These types support selecting a client for a request and selecting another client after an eligible provider failure. What is still missing is an ordered routing mode that can continue after a request succeeds but the application does not accept the response quality.
That is the model-cascading pattern described by approaches such as FrugalGPT: start with a less expensive model, evaluate the completed response, and use a stronger model only when necessary.
This proposal does not define how quality is measured. That decision can remain in the application or evaluation layer and may use a predicate, a confidence score,
Microsoft.Extensions.AI.Evaluation, or another application-specific policy.Cascading and failover have different completion semantics. Failover is driven by provider failures and, for streaming, whether output has already been committed. Cascading is driven by a quality decision over a successful, completed
ChatResponse. Keeping them as separate routing clients gives each behavior a smaller and clearer contract.The two behaviors can still be combined through composition. Each cascade rung is an
IChatClient, so a rung can itself be a resilientFailoverChatClient. The inner client handles provider failures within a tier, while the outerCascadingChatClienthandles quality escalation between tiers.A working proof of concept is available here:
https://github.com/WittBen/meai-quality-cascade
API Proposal
No changes are proposed for
RoutingContext,FailoverChatClient, orFailoverChatClientAttempt. In particular, this revision no longer proposesRoutingContext.AttemptNumber,FailoverChatClientAttempt.Response, orFailoverChatClient.ShouldSelectAgainAsync.Behavioral contract
The constructor receives a non-empty ordered list of chat clients. The list is snapshotted and must not contain
nullentries.Each request creates its own
RoutingContextand starts with the first configured client. After a client returns a successfulChatResponse:IsResponseAcceptedAsyncis called.truereturns the response immediately.falsediscards the response and invokes the next client.The final-response rule ensures that the finite client list always terminates. An application that requires a hard quality floor can validate the returned response outside the cascade.
The quality hook receives the request's
RoutingContext, the client that produced the response, the completedChatResponse, and the request cancellation token. This allows an application to apply request- or model-specific policy without introducing a judging abstraction into MEAI.A provider exception, evaluation exception, or cancellation is propagated to the caller and does not cause quality escalation. Provider failover can be added by using a
FailoverChatClientas an individual cascade rung.The current rung is request-scoped and safe for concurrent requests. The implementation can follow the existing
OrderedFailoverChatClientpattern and use aConcurrentDictionary<RoutingContext, int>. Retained state is removed after completion, provider failure, evaluation failure, cancellation, or disposal.CascadingChatClientdoes not provide native streaming.GetStreamingResponseAsyncuses the non-streaming cascade path and converts the accepted response withChatResponse.ToChatResponseUpdates(). The returned updates therefore do not provide native time-to-first-token latency.The cascade owns and disposes its configured clients by default. Passing
leaveOpen: trueleaves their lifetime with the caller.API Usage
A small concrete implementation can keep the quality policy in application code:
The application supplies the ordered clients and decides what an acceptable response means:
Provider failover can be composed within each quality tier:
In this composition, each inner
OrderedFailoverChatClienthandles provider failures for its tier. The outerCascadingChatClientevaluates successful responses and handles quality escalation between tiers.The streaming API remains available as a completed-response facade:
The first update is produced only after a complete response has been accepted.
Alternative Designs
Extend
FailoverChatClientThe original proposal added
FailoverChatClientAttempt.Response,RoutingContext.AttemptNumber, andFailoverChatClient.ShouldSelectAgainAsync.That design could reuse the existing failover loop, but it mixes two different completion models. Failover completion depends on provider failures, cancellation, attempt limits, and streaming output commitment. Cascading completion depends on a quality decision over a successful response.
The original source-code observations remain valid:
FailoverChatClientAttemptdoes not expose the completedChatResponse.OnRoutingUpdateAsyncruns.FailoverChatClient.GetResponseAsyncandGetStreamingResponseAsyncare sealed overrides.These facts explain why a subclass workaround is not available, but the revised proposal does not require changes to the failover APIs.
Add an attempt number to
RoutingContextAdding
AttemptNumberwould expose policy state on the general routing context even though one-shot routing does not need it. Internal request state, following theOrderedFailoverChatClientpattern, keeps the base abstraction focused.The ordered client list is also the complete attempt budget. Each client is selected at most once, so a quality predicate that always rejects cannot create an unbounded loop.
Buffer native streams
A buffer-before-commit implementation could consume provider updates, reconstruct a
ChatResponse, evaluate it, and then replay or discard the updates. The final tier could stream live because no further quality escalation is possible.This works, but accepted early-tier responses still produce no output until completion. It adds buffering, response reconstruction, enumerator, and commitment complexity without preserving the main latency benefit of streaming. Using the non-streaming path and
ToChatResponseUpdates()is a clearer initial contract.Keep cascading in application code
Applications can implement this loop today because
RoutingChatClientleaves its invocation methods virtual. That is useful for experimentation, but each implementation must handle concurrent request state, cancellation, client ownership, disposal, and streaming behavior correctly.A shared
CascadingChatClientremoves that repeated infrastructure without prescribing how applications measure quality.Risks
Paid but discarded responses
A rejected response still consumes provider resources, tokens, and latency. Applications should measure escalation rate, discarded token usage, evaluator cost, and total request latency before assuming that a cascade is cheaper.
Streaming latency
The streaming facade does not provide live time to first token. Its first update is available only after a complete response has been accepted. This needs to be explicit so callers do not mistake update-shaped output for native streaming.
Capability differences between tiers
Clients in a ladder must be compatible with the requests routed to them. A tier without required tool calling, structured output, or message-history support cannot transparently replace another tier. The cascade does not normalize provider capabilities.
Quality-policy behavior
An evaluator may be slow, expensive, nondeterministic, or fail. Those effects remain part of the application policy. Evaluation failures are propagated instead of being silently converted into tier changes.
Final-response policy
The final response is returned even when an external judge might reject it. This guarantees finite progress through a fixed ladder. Applications requiring a strict minimum can validate the final response or use a domain-specific fallback as the last client.
Client lifetime
The cascade owns its clients by default. Applications that share client instances elsewhere must pass
leaveOpen: trueand manage those clients themselves.