When a team says, “we should add AI,” everyone can hear something a little different. A writer may picture a helpful chat assistant. A product manager may picture a summary button. A finance lead may picture a new cost. An engineer may picture an API key and a model name in configuration.
All of those views are valid. They also leave one important question unanswered: when a customer uses the feature, who decides which AI service does the work?
At first, the answer is often simple: the application sends every request to one model from one provider. Later, the trade-offs start to show up. The quick option may cost more. The low-cost option may be slower. The preferred provider may have an outage. Some requests may contain information that needs stricter handling. Different jobs may need different strengths.
A plain-English map of the terms#
You do not need a machine-learning background to make good choices here. These are the only terms you need for this article:
| Term | Plain-English meaning | Example question |
|---|---|---|
| Model | The part that does the AI work: it can write, summarize, classify, reason, or create other output | “Which one is good enough for this job?” |
| Provider | The company or service that makes a model available to your application | “Who will process this request?” |
| Chat subscription | A paid chat product for a person to use in a browser, desktop app, or mobile app | “Can our analysts use this interactively?” |
| API | The connection that lets your own software send a request to an AI service and receive an answer | “How does our product use AI for a customer?” |
| Router or gateway | A traffic controller between your application and AI services | “What should happen if our usual choice is slow, unavailable, too expensive, or not suitable for this data?” |
These terms are related, but they are not the same thing. A model can be available through more than one provider. A provider can offer many models. A subscription helps a person use a chat product; an API lets software use an AI capability. A router sits in the middle when you want the choice to follow rules you have agreed on.
We will start with subscriptions, direct APIs, and routing platforms. Then we will separate models from providers, look at the rules that make more than one provider worthwhile, and build a .NET 10 implementation that keeps those rules easy to test. No machine-learning background is required.
Changing a model name in configuration is easy. Using several models safely deserves more care. OpenRouter gives an application one mostly OpenAI-compatible API for hundreds of models and many providers. The connection is only the plumbing. The useful work is agreeing which choices the application is allowed to make, then writing those choices down as named, testable policies.
In this guide, we will build that boundary in .NET 10. We will start with the portable path using the official OpenAI .NET client and Microsoft.Extensions.AI.IChatClient. Then we will add a small HTTP client for OpenRouter-only controls, including model fallbacks, provider ordering, spending ceilings, and Zero Data Retention. Each control is explained before it appears in the code.
Companion sample: OpenRouter .NET Routing Demo is the standalone, tested companion repository for this article.
Four ways to use AI, and why they are different#
It is easy to put ChatGPT, Claude, an API key, and OpenRouter in the same mental bucket. They can all help produce an answer, but they are useful for different jobs.
A ChatGPT or Claude subscription is mainly for a person using a chat application. It works well for research, writing, analysis, and everyday knowledge work. It does not provide API calls for your application. OpenAI bills ChatGPT and API usage separately, and Anthropic does the same for Claude plans and its Console/API. A team can sensibly buy both: a subscription for people and API access for a product.
A direct API is usually the best first production choice when one provider and one or two models meet the need. It means fewer moving parts, a direct relationship with that provider, and the quickest access to its newest features. For example, a document-analysis service that has been tested against one model and has a clear data agreement does not become better just because a router sits in front of it.
A routing platform earns its place when choosing a model or provider has become a real business and operational concern, rather than an interesting bit of trivia. It gives you one way to connect to a catalogue of options, brings usage into one place, and can choose a route based on availability, price, speed, supported features, and data rules. OpenRouter’s Models API lets you inspect the catalogue, while its provider-routing controls let an application make those choices per request.
That value is not only for developers:
- A product lead can compare a faster, cheaper model against a higher-quality option without committing the whole roadmap to one vendor.
- Finance and operations can see usage in one place and set spending limits rather than chase invoices and price sheets across several accounts.
- Reliability and support teams can reduce the blast radius of a provider incident when an evaluated fallback is available.
- Security and privacy stakeholders can review an explicit provider and retention policy instead of discovering an implicit route in application code.
- Developers still benefit from one endpoint, but the bigger win is making a shared decision easy to review and repeat.
A routing platform is helpful, but it does not remove the risk. It adds another service, another commercial relationship, and another place through which data travels. It cannot replace checking providers, testing the workload, reviewing contracts, protecting the application, or planning for an incident. Treat it as a traffic controller, not a substitute for good judgment.
| Approach | Best fit | Primary advantage | Important trade-off |
|---|---|---|---|
| Chat subscription | A person working in a vendor chat experience | Fast access to a polished interactive product | Separate from application API billing and integration |
| Direct provider API | A workload with a clear preferred provider | Fewer hops and direct access to provider capabilities | You integrate, bill, monitor, and fail over each provider yourself |
| Managed routing platform | A product that needs a tested choice across models or providers | One API, one catalogue, routing rules, and consolidated usage | Adds an intermediary and does not remove data, compliance, or evaluation work |
| Self-hosted gateway | Teams that need local models, custom rules, or full infrastructure control | Maximum control over routing and deployment | You run the gateway, including its reliability, security, and monitoring |
Adding more than one provider without making a mess#
Using more than one provider is not a maturity badge. Start with the smallest arrangement that meets the product need. Add more choice only when it solves a real problem.
- Start with one known-good route. Pick one model, define the task, and measure quality, speed, availability, and cost with realistic examples. Every alternative should be compared with this baseline.
- Describe what the job needs, not just the brand you prefer. Does it need tool calling, structured output, a large amount of context, low latency, regional processing, or strict data-retention rules? Those needs decide which options are suitable.
- Choose where the decisions live. Use a direct API for a stable, single-provider workload. Use a managed router when you need a choice of providers but do not want to run the gateway. Use a self-hosted gateway when local inference, custom routing, or owning the infrastructure matters most.
- Treat every fallback as a customer-experience choice. A fallback can change tone, correctness, tool behavior, and cost. Test it, put limits around it, and record which route was used. Do not let an availability setting quietly change the experience for customers.
- Keep the client simple and put the rules on the server. A client asks for an intent such as
economyorresilient-private. The server chooses approved models, providers, price limits, and data rules. That is the boundary the companion application implements.
With that framing, OpenRouter is neither a replacement for an OpenAI or Claude subscription nor an automatic upgrade over a direct API. It is useful when a team needs to manage choice across a changing set of models and providers. The next section separates the choices a router can make.
What OpenRouter is actually routing#
There are two separate choices hiding behind the word “model.” They sound similar at first, so it is worth separating them before the code turns up:
- Model selection chooses a model family and version, such as one model for inexpensive classification and another for difficult reasoning.
- Provider selection chooses an endpoint that serves that model, potentially based on price, latency, throughput, availability, quantization, or data policy.
OpenRouter can participate in both decisions. A request can name one model, provide an ordered models fallback list, or use one of OpenRouter’s routers. For each model, multiple providers may be eligible.
That distinction matters. A fallback from one provider to another can preserve the same model behavior. A fallback from one model to another may change output quality, tool behavior, context limits, and price. Those are different customer-experience risks, and the code should make them easy to spot rather than tuck them behind a clever default.
At the time of this audit on September 22, 2026, OpenRouter’s public API reported 444 text-capable models. Filtering the same catalogue for models advertising tool support returned 354 results. A live catalogue can change even within a day, which is precisely why I do not want those numbers frozen inside application logic or a long-lived price table.
The catalogue is queryable directly:
| |
The Models API exposes model IDs, context lengths, supported parameters, pricing, expiration dates, and other useful labels. Use it when preparing a deployment or in an administrative workflow. Do not allow every application request to choose freely from the public catalogue.
Put the policy behind your API#
The browser or mobile client should not receive an OpenRouter key, submit a provider allowlist, or decide the maximum acceptable price. Those are server-side decisions. Keeping them on the server prevents credentials and routing policy from leaking into untrusted clients.
The sample uses a small ASP.NET Core gateway:
flowchart LR
Client["Application client"] -->|"POST /api/chat/{policy}"| API["ASP.NET Core gateway"]
API --> Catalog["Named routing policy catalog"]
Catalog --> PolicyClient["OpenRouter policy client"]
API -->|"POST /api/chat-compatible"| Compatible["Portable IChatClient demo path"]
Compatible --> Router["OpenRouter"]
PolicyClient --> Router
Router --> ProviderA["Provider A"]
Router -. fallback .-> ProviderB["Provider B"]
Router -. model fallback .-> ProviderC["Provider C"]
The public request is deliberately small:
| |
The caller selects a policy name such as economy, resilient-private, or free. It does not send a model ID, provider order, API key, endpoint, or price ceiling. That keeps privileged routing decisions in configuration and source control, where people can review them and tests can check them.
The separate compatibility endpoint is deliberately a smaller demonstration: it sends an ordinary IChatClient request through a configured model. It does not apply the named OpenRouter routing policy. That separation makes the portability trade-off visible instead of pretending every provider-specific control can travel through a portable interface.
Start with the compatible IChatClient path#
OpenRouter implements the OpenAI Chat Completions request shape. In practical terms, that means the official OpenAI .NET package can point at OpenRouter’s URL instead of OpenAI’s. Microsoft’s IChatClient adapter then gives the rest of the application one familiar interface, rather than making every feature learn the details of every provider.
The sample isolates that construction in a factory:
| |
That is enough for ordinary chat calls:
| |
This path is useful when the application needs the common denominator: messages in, response out, optional streaming, and middleware from Microsoft.Extensions.AI. Swapping OpenRouter for a direct provider or local model changes the application composition rather than the business logic.
But compatibility has a boundary. Properties such as models, provider.max_price, provider.data_collection, and provider.zdr are OpenRouter extensions. Hiding them inside a portable abstraction makes the code harder to understand and a future provider swap less safe. Use an explicit client when those controls are required.
For those controls, I prefer an explicit OpenRouter client.
Be explicit about OpenRouter-only controls#
The policy client serializes a contract that mirrors only the parts of OpenRouter’s API the application uses:
| |
This is not a complete clone of OpenRouter’s schema. It is the subset the application supports as policy. A smaller surface is easier to validate, easier to explain, and harder to misuse.
The full request then gathers the pieces: an ordered list of models, a sensible output limit, the messages, and the provider rules:
| |
OpenRouter’s models fallback behavior can move to the next model when the first choice is unavailable, rate-limited, rejected by moderation, or cannot accept the request’s context length. The response’s model field tells us what ultimately did the work. Record it so fallback behavior remains visible in telemetry and support investigations.
Policy one: economy with a hard ceiling#
The first policy asks for the cheapest eligible endpoint across a small, approved model set:
| |
Three settings deserve a closer look.
First, partition: "none" lets price sorting look across all configured models and providers. With the default partition: "model", endpoints stay grouped by model, so the first model remains preferred even if an endpoint for a fallback model is cheaper. Neither choice is universally right; this policy is explicitly shopping for the least expensive approved option.
Second, max_price is a hard limit. In this example, an eligible provider must charge no more than $0.50 per million prompt tokens and $2.00 per million completion tokens. OpenRouter documents that a request can fail when no endpoint satisfies the ceiling. This prevents a routing change from silently exceeding the budget during an outage.
Third, the application still owns the approved model list. “Cheapest” does not mean “any model currently available.” Cost optimization still needs a quality boundary.
As of September 22, 2026, the public catalogue reports that both configured models support max_completion_tokens and tools, and their listed token prices fit this ceiling. That compatibility check matters because require_parameters: true will exclude a model whose eligible endpoints cannot honor every parameter in the request. Revalidate it before deployment rather than assuming an old fallback list is still viable.
The current provider routing behavior distinguishes preferences from constraints. Throughput and latency thresholds can deprioritize endpoints that miss a target, while max_price excludes endpoints above the specified cost.
Policy two: resilient and private#
The second policy has a different personality. It keeps the preferred model order, but looks for the quickest eligible provider within each model:
| |
Here, partition: "model" is deliberate. The first model remains the preferred choice for the job. OpenRouter sorts eligible providers for that model, then moves to later models only when necessary. This preserves model preference before changing the character of the answer.
require_parameters prevents a fallback provider from accepting a request while quietly ignoring a capability the application needs. The sample does not send tools, but this becomes especially important for agent requests that depend on tools, structured outputs, reasoning controls, or a particular maximum output length.
data_collection: "deny" and zdr: true are related but distinct. The first filters providers based on data-collection policy. The second restricts the request to endpoints that OpenRouter marks as Zero Data Retention.
At the time of this audit on September 22, 2026, OpenRouter’s ZDR endpoint catalogue reported at least one eligible endpoint advertising tool support for each model in this policy. The endpoint metadata does not expose the same completion-token parameter name for every provider, so require_parameters: true may exclude some of those endpoints. This is not a permanent guarantee, and a deployment check should fail safely when a policy has no eligible route.
Use a free model for a live smoke test#
The sample also includes a free policy for the awkward moment when you want to exercise the real HTTP path without turning a documentation run into a bill. It defaults to inclusionai/ling-3.0-flash-fin:free, which the Models API listed at zero prompt and completion prices on September 22, 2026, and it enforces a zero max_price ceiling. Set OpenRouter__FreeModel to another current :free entry if that model is unavailable in your account or region.
The free endpoint currently advertises max_tokens rather than max_completion_tokens, so the policy selects the legacy field for that profile. That small detail is a useful reminder that a free model can still have a different request contract from a paid model. The require_parameters setting keeps the provider honest about whichever field the policy sends.
Free does not mean unlimited, guaranteed-available, or private. OpenRouter documents that free variants have their own rate limits and availability. Account-wide provider allowlists and guardrails can remove every free endpoint from consideration, which appears as a 404 “no endpoints available” response rather than an authentication failure. Free variants are useful for a low-stakes smoke test of authentication, serialization, routing, and error handling. They are not a substitute for evaluating the paid models you intend to use in production. See the free model variant documentation and validate the selected ID and account policy against the live Models API.
ZDR is a control, not a private network#
OpenRouter’s ZDR documentation defines ZDR as the provider processing the request without storing it. It does not mean that prompts stay inside your infrastructure. The prompt still travels through OpenRouter and an eligible inference provider.
A useful threat-model table is:
| Control | What it addresses | What it does not address |
|---|---|---|
| Server-side API key | Keeps credentials away from untrusted clients | Prompt retention or provider training |
data_collection: "deny" | Filters endpoints that collect data | Your own application logs |
zdr: true | Requires eligible no-retention endpoints | Network transit or local telemetry |
| Application log policy | Controls what your service records | Downstream provider behavior |
| Self-hosted local model | Can keep inference on infrastructure you control | Operational security by itself |
OpenRouter says its own prompts are not retained unless prompt logging is enabled. Provider policies remain endpoint-specific, and optional logging features have their own retention rules. For regulated workloads, follow the full route and verify the contractual terms. A single Boolean property is not a compliance program.
If the requirement is that prompts never leave infrastructure you control, this is where a local model or self-hosted gateway becomes the more honest answer. My guides to running local AI with .NET and Ollama and combining local and cloud models cover that side of the decision.
Send the policy through a typed client#
OpenRouterHttpGateway.SendAsync receives IOpenRouterCredentials through dependency injection, so it never reads a key from tracked configuration.
The live gateway builds an ordinary authenticated request. Authentication proves who may use the account. The optional headers below help OpenRouter attribute the application and return routing details.
| |
HTTP-Referer and X-OpenRouter-Title are optional attribution headers. They are not authentication. The older X-Title spelling remains supported for compatibility, but the current app-attribution documentation uses X-OpenRouter-Title.
The response is reduced to an application-owned contract:
| |
That mapping keeps upstream response changes away from the rest of the application. It also makes the operational fields hard to forget. The client opts into router metadata and reads the provider from the endpoint marked selected. OpenRouter omits router metadata on response-cache hits, so Provider stays nullable. Record the selected model, available provider metadata, usage, and cost while excluding prompt content from logs.
Keep costs factual, not guessed#
OpenRouter now includes usage information automatically in complete responses and in the final event of a streaming response. According to the current usage-accounting documentation, older usage.include and stream_options.include_usage switches are deprecated and have no effect.
The returned usage object can contain:
- prompt and completion tokens;
- reasoning-token details when relevant;
- cache-read and cache-write token details when available;
- the cost charged for the request;
- the upstream inference cost breakdown.
Capture the returned amount rather than recalculating it from a copied price table. Models tokenize differently, providers change prices, caching changes billable input, and fallback can change the model that served the request. A hand-maintained price spreadsheet is a fine place for a planning conversation; it is a poor place to settle the bill.
There is an important billing detail. OpenRouter’s current FAQ says upstream inference prices are passed through without markup, but purchasing credits carries a 5.5% fee with a $0.80 minimum. Its BYOK allowance is measured by monthly list-price inference cost: pay-as-you-go currently includes $25,000 without a BYOK fee, while Enterprise includes $200,000. Usage above the applicable allowance currently carries a 5% fee. “No inference markup” does not mean “no platform fees.”
Those values are accurate as of September 22, 2026. Link to the live policy rather than presenting them as permanent constants.
Safe configuration and first-run behavior#
The demo defaults to a simulated gateway:
| |
A fresh clone needs package access for its first restore. After that, it can build, test, and call the simulated API without credentials, an OpenRouter account, a model download, or paid inference. The simulator still runs prompt validation and policy selection, then returns a deterministic zero-cost result.
Live mode is explicit and server-side:
| |
The OpenRouter key is never accepted from an API request. Live requests also need the separate X-Demo-Access-Key header shown in the README. The sample keeps the upstream endpoint fixed to https://openrouter.ai/api/v1/, bounds prompts to 8,000 characters, and bounds output through the named policy.
To use the zero-price policy, call /api/chat/free with the same demo access header. The other named policies intentionally keep their paid model examples so the routing controls remain meaningful.
The sample applies a per-IP rate limit as a basic safety measure. Production systems should replace the demo key with authentication and authorization, then add per-user quotas, account budgets, request-size limits, distributed tracing, and redacted structured logs. Without an abuse boundary, a model gateway can become an expensive public endpoint.
Test the routing rules, not just the happy path#
The deterministic suite contains 25 passing tests split between core unit tests and HTTP integration tests. That may sound like housekeeping, but it is how we keep the policy from becoming a collection of hopeful comments.
The unit tests verify:
- case-insensitive policy lookup;
- blank and oversized prompt rejection;
- ordered fallback model lists;
- snake-case serialization of OpenRouter fields;
- inclusion of price and privacy controls;
- deterministic, zero-cost simulator behavior;
- invalid policy configuration rejected at construction time;
- the configurable
:freemodel and zero price ceiling;
The HTTP tests verify:
- the credential-free first-run endpoint;
- successful simulated chat calls;
- unknown-policy and validation responses;
- the disabled compatibility endpoint in simulated mode;
- live-mode access-key and per-IP rate-limit behavior;
- compatible-path upstream failure mapping;
- bearer authorization and attribution headers sent upstream;
- the router-metadata opt-in header and documented selected-provider response shape;
- request serialization for
sort,partition, andmax_price; - model, provider, token, and cost mapping;
- safe handling of an upstream
429without reflecting its body to the caller; - public catalogue filtering and mapping through a fake upstream handler;
- rejection of unsupported catalogue sort values before an HTTP request is sent.
The live client test uses a fake HttpMessageHandler, so CI never contacts OpenRouter or consumes credits. WebApplicationFactory<Program> exercises the running ASP.NET Core pipeline in memory.
| |
The current build completes with zero warnings and zero errors. All 25 tests pass. I also started the API on http://127.0.0.1:5088 and exercised the documented root, policy-list, economy-chat, free-policy route in simulated mode, and public model-catalogue requests.
For the September 22 release audit, I enabled live mode with a temporary zero-budget key and called the free policy through the running sample. OpenRouter returned a real response from inclusionai/ling-3.0-flash-fin:free through Novita with simulated: false and a reported cost of zero. I also pointed the portable IChatClient path at the same free model and received a real response. The credential stayed in the process environment and was not written to configuration or source control.
That proves the boundary and serialization. It does not prove that every model behaves equally, that a particular provider will always be available, or that one live response represents production behavior. Those questions need a versioned evaluation dataset and repeated, production-shaped measurements.
OpenRouter or LiteLLM?#
OpenRouter and LiteLLM overlap, but they represent different operating choices:
| Concern | OpenRouter | Self-hosted LiteLLM |
|---|---|---|
| Operations | Managed service | You deploy and operate it |
| Model/provider onboarding | Central catalogue and billing | You configure providers and credentials |
| Data path | Through OpenRouter and an eligible provider | Through infrastructure and providers you choose |
| Local models | Not an on-premises inference runtime | Can front local endpoints such as Ollama |
| Routing control | OpenRouter request and account policies | Gateway configuration you own |
| Best fit | Fast multi-provider access with low operational overhead | Central control, local/cloud unification, or custom governance |
My LiteLLM self-hosting guide is the natural companion to this article. Choose based on who should own the control plane, where requests may travel, and how much infrastructure the team is prepared to operate.
A pragmatic team may use both: LiteLLM as its internal policy and observability boundary, with OpenRouter as one managed upstream alongside direct providers and local infrastructure. Every additional hop needs a clear purpose, though. Compatible APIs are not a good enough reason to build a small tower of gateways and hope it becomes architecture.
A production checklist#
Before enabling a routing policy, work through this checklist:
- Which models are approved for this workload, and how were they evaluated?
- Does model order express a quality preference, or should sorting cross model boundaries?
- Which request parameters are mandatory?
- Is a performance target a preference or a hard rejection condition?
- What is the maximum acceptable input, output, request, or image price?
- May any eligible provider collect or retain prompt data?
- Do we record the model and provider that actually served the request?
- Are token usage and returned cost attributed to the correct tenant and feature?
- What happens when no endpoint satisfies every constraint?
- Can the deterministic test suite run without network access or paid inference?
If those answers live only in an architecture diagram or an operator’s memory, the routing policy is not ready for production. A policy is useful when the team can find it, understand it, and change it deliberately.
The useful idea: ask for an outcome#
OpenRouter makes the first request simple. Pointing an OpenAI-compatible .NET client at a different endpoint can get a prototype working in minutes. A successful first response is only the start of the design.
The more important step is what comes next: deciding what economy, resilient-private, or free means for your system, expressing that intent as a small contract, and testing the serialized policy without spending money.
That approach gives the application two clean seams. IChatClient preserves portability for ordinary AI operations. The typed OpenRouter client exposes gateway-specific controls without presenting them as universal. The application owns the intent and the gateway performs the route.
This is a stronger foundation than a model ID in appsettings.json with no documented reason for its selection.
References#
- OpenRouter quickstart
- OpenAI: ChatGPT and API billing
- Claude: paid plans and API access
- OpenRouter Models API
- Provider routing
- Free model variants
- Model fallbacks
- Router metadata
- Usage accounting
- Zero Data Retention
- App attribution
- OpenRouter pricing and fee FAQ
- Official OpenAI .NET SDK: custom base URL and API key
- Microsoft.Extensions.AI libraries
ChatClient.AsIChatClient