-
Notifications
You must be signed in to change notification settings - Fork 2k
.NET: Add RoutingChatClient for routing requests across multiple chat clients #6932
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
westey-m
wants to merge
4
commits into
microsoft:main
Choose a base branch
from
westey-m:dotnet-routing-chat-client
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
965f640
Add a RoutingChatClient that can route requests to different chat cli…
westey-m b04abf1
Address PR comments
westey-m b120aef
Add information on setting a custom router
westey-m 288d3a8
Merge branch 'main' into dotnet-routing-chat-client
westey-m File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
17 changes: 17 additions & 0 deletions
17
...les/02-agents/Agents/Agent_Step22_MultiModelRouting/Agent_Step22_MultiModelRouting.csproj
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| <Project Sdk="Microsoft.NET.Sdk"> | ||
|
|
||
| <PropertyGroup> | ||
| <OutputType>Exe</OutputType> | ||
| <TargetFrameworks>net10.0</TargetFrameworks> | ||
|
|
||
| <Nullable>enable</Nullable> | ||
| <ImplicitUsings>enable</ImplicitUsings> | ||
| </PropertyGroup> | ||
|
|
||
| <ItemGroup> | ||
| <PackageReference Include="Azure.Identity" /> | ||
| <ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" /> | ||
| <ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" /> | ||
| </ItemGroup> | ||
|
|
||
| </Project> |
119 changes: 119 additions & 0 deletions
119
dotnet/samples/02-agents/Agents/Agent_Step22_MultiModelRouting/Program.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| // Copyright (c) Microsoft. All rights reserved. | ||
|
|
||
| // Multi-model routing with RoutingChatClient | ||
| // | ||
| // Demonstrates how to back a single agent with multiple chat clients that use | ||
| // different models, and switch between them at runtime. | ||
| // | ||
| // RoutingChatClient is an IChatClient decorator that holds several named inner | ||
| // clients and, for each request, routes to one of them based on the active | ||
| // destination stored in the session. It also accepts an optional fallback | ||
| // factory that builds a client on the fly for any key that is not a registered | ||
| // inner client. Here we key the inner clients (and the fallback) by model name. | ||
| // | ||
| // This sample: | ||
| // 1) Registers two inner clients keyed by model name (models A and B). | ||
| // 2) Adds a fallback factory that constructs a Foundry client for whatever | ||
| // model name (key) is requested but not pre-registered (model C). | ||
| // 3) Retrieves the RoutingChatClient back from the agent via GetService. | ||
| // 4) Runs the same agent/session three times, switching the active model | ||
| // between runs with SetActiveDestinationKey, so a single conversation is | ||
| // served by three different models in turn. | ||
| // | ||
| // Chat history storage | ||
| // -------------------- | ||
| // Every client is created with AsIChatClientWithStoredOutputDisabled(...), i.e. | ||
| // the Responses API "store" flag is set to false and chat history is kept | ||
| // client-side by the agent's session. This is required when routing across | ||
| // clients: service-stored chat history is tied to the *service* that created the | ||
| // conversation, so it is only available when every turn is served by that same | ||
| // service. Because routing can send different turns to different clients (and | ||
| // potentially different services), the conversation must be carried client-side | ||
| // so it is replayed to whichever model handles the next turn. | ||
| // | ||
| // If you instead route only among clients that all share the same service, you | ||
| // can use service-stored history (AsIChatClient(...) with storage enabled) and | ||
| // let the service persist the conversation. | ||
| // | ||
| // Reasoning content | ||
| // ----------------- | ||
| // Every client also passes includeReasoningEncryptedContent: false. Encrypted | ||
| // reasoning content is model-specific: one model cannot necessarily interpret | ||
| // another model's encrypted reasoning, so it must not be echoed back in requests | ||
| // when a single conversation is served by multiple models. When you route to only | ||
| // a single model, you can leave this enabled (the default) to preserve reasoning | ||
| // across turns. | ||
|
|
||
| using Azure.AI.Extensions.OpenAI; | ||
| using Azure.AI.Projects; | ||
| using Azure.Identity; | ||
| using Microsoft.Agents.AI; | ||
| using Microsoft.Extensions.AI; | ||
|
|
||
| string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); | ||
|
|
||
| // Two pre-registered models (inner clients) and a third model resolved via the | ||
| // fallback factory. Set these to models deployed in your Foundry project. | ||
| string modelA = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4"; | ||
| string modelB = Environment.GetEnvironmentVariable("FOUNDRY_MODEL_ALT1") ?? "gpt-5.4-mini"; | ||
| string modelC = Environment.GetEnvironmentVariable("FOUNDRY_MODEL_ALT2") ?? "Deepseek-V4-Pro"; | ||
|
|
||
| // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. | ||
| // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid | ||
| // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. | ||
| AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); | ||
| var responsesClient = aiProjectClient.GetProjectOpenAIClient().GetProjectResponsesClient(); | ||
|
|
||
| // Register two inner clients keyed by model name. Each uses client-side chat | ||
| // history (stored output disabled), and disables encrypted reasoning content | ||
| // (includeReasoningEncryptedContent: false) since it is not portable across | ||
| // models, so the conversation can move freely between models. | ||
| var innerClients = new Dictionary<string, IChatClient> | ||
| { | ||
| [modelA] = responsesClient.AsIChatClientWithStoredOutputDisabled(modelA, includeReasoningEncryptedContent: false), | ||
| [modelB] = responsesClient.AsIChatClientWithStoredOutputDisabled(modelB, includeReasoningEncryptedContent: false), | ||
| }; | ||
|
|
||
| // The fallback factory builds a client for any requested model name (key) that | ||
| // is not one of the registered inner clients. The created client is used for the | ||
| // single request and then disposed by default. You can disable disposal by setting | ||
| // the RoutingChatClientOptions.DisableFallbackChatClientDisposal setting to true. | ||
| // This allows you to reuse or cache clients created in the fallback factory, | ||
| // but you must then manage their disposal yourself. | ||
| var routingChatClient = new RoutingChatClient( | ||
| innerClients, | ||
| fallbackFactory: (destinationKey, _, _) => | ||
| { | ||
| Console.WriteLine($" (fallback factory building a client for model '{destinationKey}')"); | ||
| return new ValueTask<IChatClient>(responsesClient.AsIChatClientWithStoredOutputDisabled(destinationKey, includeReasoningEncryptedContent: false)); | ||
| }); | ||
|
|
||
| AIAgent agent = new ChatClientAgent( | ||
| routingChatClient, | ||
| instructions: "You are a helpful assistant. Keep answers to a single short sentence, and always start by stating which model you are.", | ||
| name: "MultiModelAgent"); | ||
|
|
||
| AgentSession session = await agent.CreateSessionAsync(); | ||
|
|
||
| // The RoutingChatClient can be retrieved back from the agent via GetService. | ||
| // This is uesful when you don't hold a direct reference to it — for example when | ||
| // the agent was created elsewhere or resolved from a DI container. | ||
| RoutingChatClient routing = agent.GetService<RoutingChatClient>() | ||
| ?? throw new InvalidOperationException("The agent is not backed by a RoutingChatClient."); | ||
|
|
||
| // Run 1: default destination — the first registered inner client (model A). | ||
| Console.WriteLine($"[Active model: {routing.GetActiveDestinationKey(session) ?? "(default)"}]"); | ||
| Console.WriteLine(await agent.RunAsync("Give me a fun fact about the ocean.", session)); | ||
| Console.WriteLine(); | ||
|
|
||
| // Run 2: switch to the second registered inner client (model B). | ||
| routing.SetActiveDestinationKey(session, modelB); | ||
| Console.WriteLine($"[Active model: {routing.GetActiveDestinationKey(session)}]"); | ||
| Console.WriteLine(await agent.RunAsync("Give me another one, on a different topic.", session)); | ||
| Console.WriteLine(); | ||
|
|
||
| // Run 3: switch to a model that is NOT registered as an inner client. The | ||
| // fallback factory constructs a client for it on the fly. | ||
| routing.SetActiveDestinationKey(session, modelC); | ||
| Console.WriteLine($"[Active model: {routing.GetActiveDestinationKey(session)}]"); | ||
| Console.WriteLine(await agent.RunAsync("And one more, about space.", session)); | ||
65 changes: 65 additions & 0 deletions
65
dotnet/samples/02-agents/Agents/Agent_Step22_MultiModelRouting/README.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| # Multi-Model Routing | ||
|
|
||
| This sample demonstrates how to back a single agent with multiple chat clients that use different models, using `RoutingChatClient`, and how to switch the active model at runtime. | ||
|
|
||
| ## What This Sample Shows | ||
|
|
||
| `RoutingChatClient` is an `IChatClient` decorator that holds several named inner clients and, for each request, routes to one of them based on the *active destination* stored in the session. It also accepts an optional **fallback factory** that builds a client on the fly for any key that is not a registered inner client. | ||
|
|
||
| In this sample the destination key is the **model name**: | ||
|
|
||
| - Two inner clients are registered, keyed by model name (models A and B). | ||
| - A fallback factory constructs a Foundry client for whatever model name (key) is requested but not pre-registered (model C). The created client serves the single request and is disposed afterwards by default. | ||
| - The active model is changed between runs with `SetActiveDestinationKey`, and `GetActiveDestinationKey` reports the current model. A single conversation is served by three different models in turn. | ||
| - The `RoutingChatClient` is retrieved back from the agent with `agent.GetService<RoutingChatClient>()` — useful when you don't hold a direct reference (for example when the agent is created elsewhere or resolved from a DI container). | ||
|
|
||
| No custom `Router` is supplied, so routing simply follows the per-session active destination — exactly what `SetActiveDestinationKey` controls. | ||
|
|
||
| ## Chat History Storage | ||
|
|
||
| Every client is created with `AsIChatClientWithStoredOutputDisabled(...)`, which sets the Responses API `store` flag to `false` so chat history is kept **client-side** by the agent's session. | ||
|
|
||
| This matters when routing across clients: | ||
|
|
||
| - **Service-stored chat history** is tied to the **service** that created the conversation, so it is only available when every turn is served by that same service. If your destinations all share one service, you can enable service-stored history (`AsIChatClient(...)`) and let the service persist the conversation. | ||
| - **Client-side chat history** (used here) carries the conversation in the agent's session and replays it to whichever model handles the next turn. This is required when routing may send different turns to different clients (and potentially different services), as this sample does. | ||
|
|
||
| ## Reasoning Content | ||
|
|
||
| Every client is also created with `includeReasoningEncryptedContent: false`. Encrypted reasoning content is **model-specific** — one model cannot necessarily interpret another model's encrypted reasoning — so it must not be echoed back in requests when a single conversation is routed across multiple models. If you route to only a single model, you can leave this enabled (the default) to preserve reasoning across turns. | ||
|
|
||
| ## Prerequisites | ||
|
|
||
| - .NET 10 SDK or later | ||
| - Microsoft Foundry project endpoint with the models below deployed | ||
| - Azure CLI installed and authenticated | ||
|
|
||
| **Note**: This sample uses `DefaultAzureCredential`. Sign in with `az login` before running. For production, prefer a specific credential such as `ManagedIdentityCredential`. For more information, see the [Azure CLI authentication documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). | ||
|
|
||
| ## Environment Variables | ||
|
|
||
| ```powershell | ||
| $env:FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project" # Required | ||
| $env:FOUNDRY_MODEL="gpt-5.4" # Optional, model A (default: gpt-5.4) | ||
| $env:FOUNDRY_MODEL_ALT1="gpt-5.4-mini" # Optional, model B (default: gpt-5.4-mini) | ||
| $env:FOUNDRY_MODEL_ALT2="Deepseek-V4-Pro" # Optional, model C, resolved via the fallback factory (default: Deepseek-V4-Pro) | ||
| ``` | ||
|
|
||
| Update the model names to match models deployed in your Foundry project. | ||
|
|
||
| ## Running the Sample | ||
|
|
||
| ```powershell | ||
| cd dotnet/samples/02-agents/Agents/Agent_Step22_MultiModelRouting | ||
| dotnet run | ||
| ``` | ||
|
|
||
| ## Expected Behavior | ||
|
|
||
| The sample runs three turns of one conversation: | ||
|
|
||
| 1. **First turn** — routed to the default destination (model A, the first registered inner client). | ||
| 2. **Second turn** — `SetActiveDestinationKey` switches to model B (the second registered inner client). | ||
| 3. **Third turn** — `SetActiveDestinationKey` switches to model C, which is not registered as an inner client, so the fallback factory builds a client for it. | ||
|
|
||
| Each response starts by stating which model produced it, and the conversation context carries across all three models because chat history is stored client-side. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.