| File: ChatRouting\OrderedFailoverChatClient.cs | |
| Project: ..\..\..\src\Libraries\Microsoft.Extensions.AI\Microsoft.Extensions.AI.csproj (Microsoft.Extensions.AI) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; /// <summary>Provides ordered failover across a sequence of chat clients.</summary> /// <remarks> /// <para> /// The clients are tried in order. An invocation failure before streaming output is exposed advances to the next /// client. Cancellation and failures after streaming output is exposed are propagated without failover. /// </para> /// <para> /// The configured clients are snapshotted by the constructor. The same client may appear more than once, in which /// case it is invoked once per position. When every client has failed, the final failure is rethrown. /// </para> /// </remarks> [Experimental(DiagnosticIds.Experiments.AIRoutingChat, UrlFormat = DiagnosticIds.UrlFormat)] public sealed class OrderedFailoverChatClient : FailoverChatClient { private readonly bool _leaveOpen; private readonly IChatClient[] _clients; // Holds the next client index for a request that has a failed attempt. A nonterminal update is always followed // by another selection, so a stored index is always in range. private readonly ConcurrentDictionary<RoutingContext, int> _requestStates = new(); private bool _disposed; /// <summary>Initializes a new instance of the <see cref="OrderedFailoverChatClient"/> class.</summary> /// <param name="clients">The clients to invoke, in fallback order.</param> /// <param name="leaveOpen"> /// <see langword="true"/> to leave inner clients open when this instance is disposed; /// otherwise, <see langword="false"/>. /// </param> /// <exception cref="ArgumentNullException"><paramref name="clients"/> is <see langword="null"/>.</exception> /// <exception cref="ArgumentException"><paramref name="clients"/> is empty or contains <see langword="null"/>.</exception> public OrderedFailoverChatClient(IReadOnlyList<IChatClient> clients, bool leaveOpen = false) { _ = Throw.IfNull(clients); IChatClient[] clientsSnapshot = [.. clients]; if (clientsSnapshot.Length == 0) { Throw.ArgumentException(nameof(clients), "At least one client must be provided."); } foreach (IChatClient client in clientsSnapshot) { if (client is null) { Throw.ArgumentException(nameof(clients), "Clients must not contain null."); } } _leaveOpen = leaveOpen; _clients = clientsSnapshot; } /// <inheritdoc/> protected override ValueTask<IChatClient> SelectClientAsync( RoutingContext context, CancellationToken cancellationToken) { _ = Throw.IfNull(context); _ = cancellationToken; int clientIndex = _requestStates.TryGetValue(context, out int nextClientIndex) ? nextClientIndex : 0; return new(_clients[clientIndex]); } /// <inheritdoc/> protected override ValueTask OnRoutingUpdateAsync( RoutingContext context, FailoverChatClientAttempt attempt, bool isTerminal, CancellationToken cancellationToken) { _ = cancellationToken; if (isTerminal) { _ = _requestStates.TryRemove(context, out _); return default; } Exception? exception = attempt.Exception; Debug.Assert(exception is not null, "A nonterminal update always reports a failed invocation."); int nextClientIndex = (_requestStates.TryGetValue(context, out int attemptedIndex) ? attemptedIndex : 0) + 1; if (nextClientIndex < _clients.Length) { _requestStates[context] = nextClientIndex; return default; } // Every client has failed. Release the state before the final failure ends routing. _ = _requestStates.TryRemove(context, out _); ExceptionDispatchInfo.Capture(exception!).Throw(); throw exception!; } /// <inheritdoc/> protected override void Dispose(bool disposing) { if (_disposed) { return; } _disposed = true; _requestStates.Clear(); if (disposing && !_leaveOpen) { foreach (IChatClient client in _clients) { client.Dispose(); } } base.Dispose(disposing); } }