AI App Development

How to Switch Between OpenAI and Other AI Providers in .NET Using Microsoft.Extensions.AI

Use IChatClient to separate application policy from provider integration, with explicit capability checks and migration tests.

Application policy uses IChatClient which routes through a selected provider adapter.

Switching this sample from OpenAI to Ollama changes the client construction and model settings. The application still calls the same ExplanationService: it validates a short topic, creates system and user messages, requests a bounded token budget, and checks the completion before returning text.

The runnable path is the SDK console in the shared Jovenzo source archive. Its two commands, --provider openai and --provider ollama, explain the fixed topic dependency injection. This guide follows that implementation rather than creating a second project. The commands do not switch the provider behind the archive's ASP.NET JSON, SSE or PDF endpoints; those retain their separate direct OpenAI adapters and Ai configuration.

Understand what the abstraction provides

The Microsoft IChatClient guide describes shared chat messages, options, complete responses and streaming operations. Here, ExplanationService depends on IChatClient instead of either vendor's request classes. ProviderCommand supplies an OpenAI chat-completions adapter or OllamaSharp client. A change at that construction boundary leaves the service's input checks and response checks in place.

That is a useful, narrow form of portability. It does not establish that two models follow the same instruction, understand the same context, refuse the same requests or produce equally useful explanations. The offline tests below inspect requests and deterministic responses. They establish application wiring and selected adapter mappings, while a real deployment still needs acceptance examples against its exact models.

The service contains no conversation store, customer identity or provider-specific continuation ID. Each invocation creates just two messages. A web application that adds conversation history would also need to decide which history can cross a provider boundary and who is allowed to send it. Those decisions are outside this console example. Keeping that distinction visible prevents a small client-selection switch from being mistaken for a complete migration system.

Four portability layers: source compilation, capabilities, behavior, and operations.
A compilation result is useful evidence but does not complete a provider migration.

Use the shared solution and its pinned dependencies

Extract the archive and open a terminal in Jovenzo.Samples, beside its README and solution file. Keep all four projects together. Core and Api support the other tutorials; Sdk contains this command and the agent/MCP examples; Tests exercises the common package. There is no separate ProviderSwitch solution to create or maintain.

Archive-relative fileRole in this article
src/Jovenzo.Samples.Sdk/Program.csReads the existing --provider argument and supplies the demonstration topic.
src/Jovenzo.Samples.Sdk/ProviderExplanation.csProviderConfiguration, client factory, shared ExplanationService and owned request lifetime.
src/Jovenzo.Samples.Sdk/Jovenzo.Samples.Sdk.csproj and packages.lock.jsonDirect package versions and resolved dependency graph.
tests/Jovenzo.Samples.Tests/ProviderSwitchTests.csConfiguration, service and lifecycle regressions using a fake IChatClient.
tests/Jovenzo.Samples.Tests/ProviderAdapterTests.csReal adapter construction over an intercepted HTTP transport.

The SDK project pins Microsoft.Extensions.AI.OpenAI 10.11.0 and OllamaSharp 5.5.0. Its lock file resolves OpenAI 2.14.0 and System.ClientModel 1.16.0. The official OpenAI adapter repository documentation explains the abstraction; the checked-in package and lock files identify the versions actually built here. Review dependency updates deliberately rather than mixing examples from different package generations.

With .NET 10 SDK and Node.js installed, run the following from the extracted directory. Verification used .NET SDK 10.0.403 and Node 24. The initial restore may download NuGet packages. None of these commands asks an AI provider for a response, requires an API key or starts an Ollama model.

dotnet restore Jovenzo.Samples.slnx --locked-mode
dotnet build Jovenzo.Samples.slnx --configuration Release --no-restore --disable-build-servers -m:1
dotnet test tests/Jovenzo.Samples.Tests/Jovenzo.Samples.Tests.csproj --configuration Release --no-build --no-restore
node --test tests/stream-browser.test.mjs

Select the client with the existing command

ProviderConfiguration accepts exactly openai or ollama. OpenAI requires nonblank OPENAI_MODEL and OPENAI_API_KEY; Ollama requires only nonblank OLLAMA_MODEL. Missing, empty or whitespace-only values fail before client construction or network access. An unknown provider is rejected before any environment setting is read. The command does not use an AI_PROVIDER or shared AI_MODEL variable.

ProviderCommand's default factory constructs the OpenAI client with its configured key and chat model, then calls AsIChatClient(). The Ollama branch constructs OllamaApiClient at the fixed http://localhost:11434 endpoint with its own model setting. The caller cannot supply an arbitrary remote URL through this CLI. Both branches hand the resulting IChatClient to the same service.

The following commands are separate live operations, not part of the key-free tests. For OpenAI, choose a chat model available to your account and supply your own key privately. Running the final command sends the demonstration messages to OpenAI and can incur charges. The placeholder is intentionally not a claimed working model name.

# Live OpenAI request; not executed during this verification.
$env:OPENAI_API_KEY = '<your-own-key>'
$env:OPENAI_MODEL = '<your-enabled-chat-model>'
dotnet run --project src/Jovenzo.Samples.Sdk --configuration Release --no-build -- --provider openai

For Ollama, install and start the inference service separately, obtain a model suitable for your hardware and license, and set the exact name reported by your installation. The Microsoft local-model quickstart explains that setup. Adding OllamaSharp through NuGet does not install the Ollama server or download model weights. The command below does not need an OpenAI key.

# Real local inference; not executed during this verification.
$env:OLLAMA_MODEL = '<your-installed-local-model>'
dotnet run --project src/Jovenzo.Samples.Sdk --configuration Release --no-build -- --provider ollama

These SDK environment variables are independent of the API's Ai settings. Starting the API or opening its browser forms is not a way to exercise this provider command. Likewise, changing OLLAMA_MODEL cannot redirect the API's embeddings, SSE stream or grounded answer generation. That separation lets the other nine tutorials continue using their existing entry points.

Server configuration is validated against an allowlist before constructing OpenAI or Ollama client.
The server controls provider selection and secret injection.

Apply the same explanation rules to both clients

This is the actual ExplanationService from ProviderExplanation.cs; its surrounding namespace and imports are omitted. It borrows the supplied client. The input limit uses .NET string length: 1,000 UTF-16 code units, not 1,000 tokens or Unicode characters. For example, 500 supplementary emoji occupy 1,000 units. Whitespace-only input and a 1,001-unit topic are rejected without a chat request.

public sealed class ExplanationService(IChatClient client)
{
    public async Task<string> ExplainAsync(string topic, CancellationToken cancellationToken)
    {
        if (string.IsNullOrWhiteSpace(topic) || topic.Length > 1000)
            throw new ArgumentException("A topic of 1 to 1000 UTF-16 units is required.", nameof(topic));
        cancellationToken.ThrowIfCancellationRequested();
        ChatMessage[] messages =
        [
            new(ChatRole.System, "Explain software concepts accurately and briefly."),
            new(ChatRole.User, topic)
        ];
        var response = await client.GetResponseAsync(messages, new ChatOptions { MaxOutputTokens = 300 }, cancellationToken);
        if (response.FinishReason != ChatFinishReason.Stop)
            throw new InvalidOperationException("The provider did not report a normal completed response.");
        return !string.IsNullOrWhiteSpace(response.Text) ? response.Text
            : throw new InvalidOperationException("The provider returned no usable text.");
    }
}

The system instruction and user topic remain separate messages. MaxOutputTokens requests 300 output tokens, and the caller's cancellation token reaches GetResponseAsync. The real-adapter transport tests observe max_completion_tokens: 300 in the OpenAI request and options.num_predict: 300 in the Ollama request. This proves those adapter mappings for the pinned versions, not that every model enforces the request identically.

The service returns text only when the adapter reports ChatFinishReason.Stop and the text is nonblank. A length-limited, filtered, tool-call, missing or otherwise unexpected finish reason produces a controlled InvalidOperationException instead of returning partial text as a completed explanation. These application errors do not embed the rejected generated text. Stop is still provider metadata, not evidence that the answer is correct or even sufficient for the user's purpose.

This check happens after the SDK returns its response. There is no separate hard response-byte limit in this SDK command, and MaxOutputTokens is not a memory bound. The direct HTTP client in the first article has a different bounded-body implementation; that implementation should not be assumed to apply to an SDK adapter. A production feature needing a strict retained-byte limit would require an appropriate transport strategy and its own tests.

ProviderCommand owns the constructed IChatClient and disposes it after success, failure or cancellation. It creates a token linked to caller cancellation, schedules cancellation after 45 seconds and passes that token to ExplanationService. The service does not dispose its borrowed dependency. The factory's HTTP-client overload exists for intercepted transport tests; a supplied HttpClient remains owned by its caller. It does not change the default CLI endpoint.

The 45-second token requests cooperative cancellation; it cannot forcibly interrupt arbitrary code or undo work already performed by a provider. Offline cancellation tests cancel an active fake call and verify cleanup. They do not wait for 45 seconds or establish when a real provider stops computing or charging. If this service later participates in a web request, link the request lifetime explicitly rather than detaching the work.

Separate implemented behavior from model compatibility

The table records this provider command, not every feature in the shared archive. “Offline checked” means deterministic fake-client or fake-HTTP evidence. It does not mean a real OpenAI or Ollama model was executed. Keeping a separate live column makes the remaining work visible without inventing a model comparison.

CapabilityImplemented hereOffline checkedLive model status
One complete text explanationBoth adapters, same serviceMessages, model, option mapping, stop/text checksBoth unverified
Input validation and cancellation1,000 UTF-16 units; linked 45-second deadlineBoundary inputs, propagated cancellation and disposalReal timing unverified
Tool calling and streamingNot used by --providerNo provider-compatibility claimUnverified for both
Structured output and embeddingsNot used by --providerNo schema/vector portability claimUnverified for both
Cache, tenant routing and fallbackNot implemented in this commandApplication failure makes no second-provider attemptNo live comparison performed

The archive's agent and MCP demonstrations exercise different entry points. Their deterministic tool-loop successes do not prove that either selected provider handles real function calls. The first-agent guide discusses that boundary separately. Similarly, a shared message type capable of holding images is not evidence that this text-only service or either configured model supports image input.

Loopback inference changes the destination of this command's chat request, but it does not certify the surrounding system's privacy. Model downloads, application logging and deployment configuration need their own review. No privacy guarantee, hardware performance result or hosted-versus-local cost ranking follows from the fake transport fixtures.

Capability evaluation defines a requirement, verifies provider support, and records evidence.
Mark a capability tested, unsupported, or unverified instead of assuming equivalence.

Distinguish SDK retries from application fallback

ProviderCommand calls its selected service once. It has no application retry loop, alternate-provider fallback, response cache or added telemetry pipeline. A failure does not cause the application to construct the other provider. That does not mean one application call always produces just one HTTP attempt: an SDK can retry internally.

The pinned OpenAI 2.14.0 documentation describes up to three additional retries for HTTP 408, 429, 500, 502, 503 and 504, with backoff. The default factory retains that policy. Its fake-HTTP 429 test observes four requests: the initial attempt and three retries, followed by failure. The fixture supplies a short retry delay so the check does not depend on internet timing.

The OllamaSharp 5.5.0 fake-HTTP 503 case makes one request and throws; the application does not retry or switch providers. This is the tested status path, not a promise about every possible network failure. The pinned OllamaSharp abstraction mapping source also shows the output-token and completion-reason conversions checked by the adapter tests.

The direct Responses HTTP implementation described in the first tutorial does not automatically retry POSTs. Its policy belongs to that implementation and cannot be carried over to this SDK path by assumption. Here, the linked deadline covers the awaited adapter operation, including SDK retry delays when cancellation is honored. Retries still do not establish successful completion or prevent provider-side work from having occurred.

If fallback becomes a product requirement later, it needs an explicit decision about allowed data destinations, compatible capabilities, side effects and a total time budget. It is a separate production feature rather than an invisible addition to this sample. Keeping the current command single-provider makes a failure and its origin easier to investigate.

Run the regressions before evaluating real models

ProviderSwitchTests references the actual SDK components through a project reference, rather than copying their policy into test code. Its 29 cases cover provider selection, missing and whitespace settings, key-free Ollama configuration, input boundaries, exact messages, the output-token request, blank text, finish reasons, caller cancellation and disposal. ProviderAdapterTests adds eight cases using the real factory and adapters with all HTTP requests intercepted.

# Key-free provider subset; included in the complete C# suite.
dotnet test tests/Jovenzo.Samples.Tests/Jovenzo.Samples.Tests.csproj --configuration Release --no-build --no-restore --filter 'FullyQualifiedName~ProviderSwitchTests|FullyQualifiedName~ProviderAdapterTests'

# Existing key-free SDK paths; real local MCP transport, fake model turns.
dotnet run --project src/Jovenzo.Samples.Sdk --configuration Release --no-build -- --agent-offline
dotnet run --project src/Jovenzo.Samples.Sdk --configuration Release --no-build -- --mcp-smoke
dotnet run --project src/Jovenzo.Samples.Sdk --configuration Release --no-build -- --agent-mcp-offline

The adapter fixtures deliberately return synthetic text and completion metadata. A fixture named fixture-model is a test identifier, not a real model recommendation. These tests establish serialization, selected finish-reason mapping and observed retry behavior. They do not establish credential validity, model availability, instruction following, safety or answer quality. The three smoke commands retain the existing agent/MCP wiring without invoking either live provider.

Verification results — November 2, 2026

Package revision provider-switch-1, based on source revision 58689df plus the changes documented in the technical verification report: the four-project Release build completed with zero warnings or errors. The complete shared C# suite passed 223 tests, 0 failed, 0 skipped; the provider subset passed 37 of those tests. The unchanged JavaScript suite was run and passed 45 tests. All three existing offline agent/MCP smoke commands passed.

The rebuilt shared ZIP was extracted to a separate directory. Its locked restore and four-project Release build succeeded with zero warnings or errors; its complete suite again passed 223 C# and 45 JavaScript tests. The three offline SDK smoke commands also passed from that extraction. The archive contains 45 source/documentation/test files; SHA-256: 84185554a3708f4436e091806156c734dea01c152c7845ff1fde5bb35b525f1c. No live OpenAI request, real Ollama inference, real-model output evaluation or behavioral comparison was performed. The tests did not exercise an actual browser or production proxy, measure cost or latency, or wait out the 45-second deadline. These results describe local deterministic checks, not a claim of model equivalence.

For a later live acceptance run, record the selected model, package revision, task, settings and observed result for each provider. Use the same short topics, inspect incorrect or incomplete answers as well as successes, and retain a configuration rollback. Chat switching does not migrate an embedding index: a new embedding model may require rebuilding vectors, as discussed in the PDF RAG guide.

Fake client policy tests and real provider compatibility tests precede enabling a configuration.
Deterministic service tests and credentialed model tests answer different questions.

Locate the failed boundary

A “Set OPENAI_MODEL” or similar error indicates local configuration validation, before inference. An unsupported provider error means the CLI selector was not one of the two exact lowercase choices. For an Ollama connection failure, check the separately installed service and the exact locally installed model name. Do not add an OpenAI key to solve an Ollama setup problem.

A controlled “no usable text” or “normal completed response” failure comes from the shared service's output checks. Inspect the completion metadata in a safe development fixture instead of displaying rejected partial text as success. A provider SDK exception is a separate transport or service boundary; the console does not turn it into an API HTTP status. Avoid publishing raw exceptions or credentials in a production UI.

If an adapter method changes after a package update, compare the package and lock-file diff first. If real answer quality differs, compare the actual messages, options and finish metadata before attributing the result to the shared interface. For the archive's distinct HTTP behavior, use the Responses API integration guide; for its browser stream lifecycle, use the SSE guide.

Key takeaways

  • The two SDK commands use one real ExplanationService with the same messages and validation.
  • Provider settings are explicit; the API endpoint providers do not change.
  • Token options and normal finish metadata do not prove answer quality or enforce a byte limit.
  • SDK retries, application fallback and live model compatibility are separate concerns.

Frequently asked questions

Does Ollama require OPENAI_API_KEY?

No. Its selector reads OLLAMA_MODEL only and connects to the fixed local service. That service and model must already be installed.

Does changing the flag also switch the API?

No. The flag belongs to the SDK console. The JSON, SSE and PDF API routes retain their own configuration and direct OpenAI clients.

Are the two providers behaviorally equivalent?

That has not been established. Offline tests check this application's rules and selected adapter conversions; live model behavior requires a separate acceptance run.

Conclusion and next step

The shared package now offers two explicit client choices behind one tested explanation service. Configuration checks, messages, cancellation and completion handling remain in place when the choice changes. Run the key-free suite first, then evaluate your installed or authorized models with the separate live commands before relying on their answers.

KEEP EXPLORING

Sources & further reading

  1. Use the IChatClient interface
  2. Chat with a local AI model using .NET
  3. Microsoft.Extensions.AI.OpenAI repository documentation
Take the code with you.Download the example project, setup guide, and tests.

Share this article

Bora Yılmaz
ABOUT THE AUTHOR

Bora Yılmaz

Backend developer with a Computer Engineering degree and a professional software development background dating to 2015. His work spans C#, .NET, APIs, enterprise integrations, and software architecture.

FIND YOUR NEXT SOLUTION

What are you building?

Explore practical tutorials, developer workflows, and architecture guides.

AI App Development ↗AI Developer Tools ↗AI Agents ↗
YOUR PRIVACY, YOUR CHOICE