DEV Community

Cover image for 5 Traps When One IChatClient Fronts OpenAI, Azure, Gemini and Anthropic (.NET 10)
MICHAEL-MAURICE
MICHAEL-MAURICE

Posted on

5 Traps When One IChatClient Fronts OpenAI, Azure, Gemini and Anthropic (.NET 10)

"Just put an interface in front of the LLM" is advice everyone agrees with. In .NET the interface even exists already: IChatClient from Microsoft.Extensions.AI, with adapters for OpenAI (which also covers Azure OpenAI), and official SDKs from Anthropic and Google that expose AsIChatClient() themselves.

So I built a small support-ticket triage API on .NET 10 that can run on any of the four, switched by one config value. Getting the interface in place took an afternoon. Making it actually hold took longer. Here are the five traps I hit, and the check I now use for each one.

Packages, as used in the project: Microsoft.Extensions.AI 10.11.0, OpenAI 2.14.0, Anthropic 13.3.0, Google.GenAI 1.27.0. Code below is trimmed to the shape; omitted parts are marked.

Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. Complete, working, tested .NET projects with full source code and PDF guides: Tech Skill Builder.

If you want a ready-made starter instead of wiring this yourself, my Clean Architecture .NET 9 Web API with Unified AI Integration covers six providers out of the box. More on it at the end.

Trap 1: the interface is there, but the SDK leaked anyway

The whole point is that the Application layer knows IChatClient and nothing else. In practice, someone adds using OpenAI; to a handler "just for one option", and the abstraction is gone without anyone noticing in review.

A rule you can't see is a rule you don't have. So I don't rely on discipline; a test reads the compiled Application assembly's references and fails if any vendor SDK shows up:

[Fact]
public void Application_knows_only_the_AI_abstractions_never_a_vendor_SDK()
{
    var refs = typeof(TriageTicketHandler).Assembly.GetReferencedAssemblies()
        .Select(a => a.Name!).ToArray();
    Assert.Contains("Microsoft.Extensions.AI.Abstractions", refs);
    // assert none of: OpenAI, Anthropic, Google.GenAI, Microsoft.Extensions.AI(.OpenAI)
    // (full list in the complete project)
}
Enter fullscreen mode Exit fullscreen mode

The Application project only references Microsoft.Extensions.AI.Abstractions. Everything vendor-specific lives in one factory in Infrastructure.

Trap 2: treating Azure OpenAI as a fifth SDK

My first instinct was "Azure needs its own client". It doesn't anymore. Azure OpenAI's v1 API is OpenAI-compatible, so the factory uses the plain OpenAI SDK pointed at your resource's /openai/v1/ base URL, with the deployment name as the model and no api-version.

That makes the endpoint the fragile part. Forget the /openai/v1/ suffix and you get requests that look almost right. So the Azure branch always goes through one small helper:

AiProvider.AzureOpenAI => CreateOpenAI(provider, s, endpoint: AzureV1Endpoint(s.Endpoint!)),

internal static Uri AzureV1Endpoint(string resourceEndpoint)
{
    // normalizes https://your-resource.openai.azure.com/ to .../openai/v1/
    // (handles trailing slashes and an already-present suffix; see the complete project)
}
Enter fullscreen mode Exit fullscreen mode

I checked that a test actually guards this: when I swapped AzureV1Endpoint(...) for the raw endpoint, exactly one test went red, the Azure adapter test. Then I reverted it.

Trap 3: failover works, and now you don't know who answered

Microsoft.Extensions.AI 10.11.0 ships OrderedFailoverChatClient. It tries clients in order, fails over only before any streaming output was exposed, and doesn't treat cancellation as a failure. It's marked experimental (MEAI001), so I suppress that warning locally, in the one registration that uses it.

The catch: after a failover, asking the outer client for its ChatClientMetadata doesn't tell you which vendor produced this response. For logs, cost tracking and support tickets, that matters.

My fix is a tiny DelegatingChatClient around each provider that stamps the configured provider name onto every response, under a key the Application owns:

internal sealed class ProviderNameChatClient(IChatClient inner, string providerName, string? model)
    : DelegatingChatClient(inner)
{
    public override async Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken ct = default)
    {
        var response = await base.GetResponseAsync(messages, options, ct);
        (response.AdditionalProperties ??= [])[AiResponseKeys.Provider] = providerName;
        return response;
    }
    // streaming override and GetService(ChatClientMetadata): in the complete project
}
Enter fullscreen mode Exit fullscreen mode

The use case reads a plain string. It never sees a vendor type.

Trap 4: the missing key shows up on the first customer request

With one config value choosing the provider, it's very easy to deploy Provider = Anthropic to an environment where only the OpenAI key exists. Without validation, you find out when the first real request fails.

I bind the settings to an options class, register an IValidateOptions<AiOptions> and call ValidateOnStart(). The validator only checks the providers you actually selected (primary and fallback), and it also rejects a fallback that's the same as the primary. Running with AI__Provider=OpenAI and no key now stops the app at startup:

Microsoft.Extensions.Options.OptionsValidationException: AI:OpenAI:ApiKey is required.
Enter fullscreen mode Exit fullscreen mode

There's also a Fake provider that needs no settings at all, so the app and its tests run offline by default.

Trap 5: mocking IChatClient tests your code, not your wiring

The obvious test double is a fake IChatClient. It's useful for the use case, but it skips everything that breaks in real life: the endpoint, the auth header, how ChatOptions gets translated into each vendor's request body.

Because every SDK gets its HttpClient from IHttpClientFactory, the tests can build the real registration and replace only the network:

services.AddUnifiedAi(configuration);
services.ConfigureHttpClientDefaults(b => b.ConfigurePrimaryHttpMessageHandler(() => stub));
// stub records each request and returns a hand-written response in that vendor's format
Enter fullscreen mode Exit fullscreen mode

Now each provider test asserts what the real SDK put on the wire: the URL, the auth header, and JSON mode and the system prompt mapped into that vendor's shape. This caught one wrong assumption on the first run. I expected Anthropic's SDK to send an x-api-key header, but Anthropic 13.3.0 sends the key as Authorization: Bearer. The SDK's XML docs confirm it, so the test now pins that.

The same stub makes failover testable too. Answer Anthropic with a 529 overloaded_error, and the test checks there was one request to Anthropic, then one to Gemini, and that the result reports Gemini.

To be clear about limits: these tests never hit a live vendor. The responses are written by hand in each documented format, so real model output, rate limits and streaming from the actual services aren't covered.

The checklist I'd use on any multi-vendor setup

  • [ ] A test fails if the Application layer references a vendor SDK
  • [ ] Azure OpenAI goes through one endpoint helper, and a test breaks if you bypass it
  • [ ] Every response says which provider answered, even after failover
  • [ ] Missing keys or models for the selected providers stop the app at startup
  • [ ] Adapter tests check the real HTTP request, not a mocked interface
  • [ ] Business rules (like "a data breach is always Urgent") live in the domain, so no model can override them

The project behind this has 42 offline xUnit tests: 9 provider-adapter, 12 failover and configuration, 10 use-case, 5 domain, 2 architecture and 4 API tests. No API key needed.

Want the ready-made version?

The Clean Architecture .NET 9 Web API - Unified AI Integration starter kit ($8) is the bigger, production-style take on this idea: Domain / Application / Infrastructure / API layers with CQRS and MediatR, six providers (OpenAI, Azure OpenAI, Gemini, Anthropic, Cohere, Hugging Face), endpoints for text generation, image generation, image analysis, provider listing, model discovery and provider health, plus Serilog, HealthChecks, Swagger and an MIT license. It targets .NET 9. To be upfront: the small .NET 10 triage demo from this post is my own and isn't part of the kit.

And if you learn best from complete code you can run, Tech Skill Builder gives you complete, working, tested .NET projects with full source code and step-by-step PDF guides, with new ones added regularly. Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. Join Tech Skill Builder

Which of these five has bitten you when switching LLM vendors?

Top comments (0)