OpenRouter gives you access to hundreds of LLM models through a single API. There is no official NuGet package from OpenRouter. But here’s the thing: its API is compatible with the OpenAI API format. That means we can use the official OpenAI NuGet package and just point it at OpenRouter’s endpoint. No unofficial wrappers, no custom HTTP calls.

In this post I’ll walk through a complete ASP.NET Core Minimal API project that sends math problems to an LLM via OpenRouter and returns the solution.

Prerequisites

  • .NET 10 (also works with .NET 8 and .NET 9, since the OpenAI package targets .NETStandard 2.0 and net8.0)
  • OpenRouter API key from openrouter.ai/keys
  • OpenAI NuGet package

Project Structure

AspNetCore/
├── AspNetCore.csproj
├── Program.cs
├── Bootstraps.cs
├── AspNetCore.http
├── appsettings.json
├── appsettings.Development.json
├── Properties/
│   └── launchSettings.json
├── Clients/
│   ├── ILlmClient.cs
│   └── OpenRouterClient.cs
└── Settings/
    ├── OpenRouterSettings.cs
    └── LoggingHttpHandler.cs

Now let’s go through each file.

Setting Up the Project and NuGet Packages

<Project Sdk="Microsoft.NET.Sdk.Web">

    <PropertyGroup>
        <TargetFramework>net10.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
    </PropertyGroup>

    <ItemGroup>
        <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0"/>
        <PackageReference Include="OpenAI" Version="2.8.0" />
    </ItemGroup>

</Project>

OpenAI is the official SDK from OpenAI. We’ll configure it to talk to OpenRouter instead of OpenAI’s servers.

Configuring OpenRouter API Settings

namespace AspNetCore.Settings;

public sealed class OpenRouterSettings
{
    public static readonly string SectionName = "OpenRouter";
    public Uri BaseUrl { get; init; } = null!;
    public Uri? ProxyUrl { get; init; }
    public string ApiKey { get; init; } = null!;
    public string Model { get; init; } = null!;
}

A simple POCO that maps to the OpenRouter section in appsettings.json. ProxyUrl is optional and only used during development if you need to route traffic through a proxy.

Storing API Keys and Model Configuration

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "OpenRouter": {
    "BaseUrl": "https://openrouter.ai/api/v1",
    "Model": "google/gemini-2.5-flash-lite-preview-09-2025",
    "ApiKey": "PUT_YOUR_SECRET_OPENROUTER_API_KEY_HERE",
    "ProxyUrl": "http://your-proxy-if-needed-it-or-leave-it-empty:PORT"
  }
}

BaseUrl points to OpenRouter’s API. Model is the model identifier from OpenRouter’s model list. Here I’m using Gemini 2.5 Flash Lite, but you can swap it for any model OpenRouter supports. Replace the ApiKey placeholder with your actual key.

Adding HTTP Request and Response Logging

AI providers like OpenRouter typically show API examples using curl and raw JSON. Having a logging handler lets you see the exact HTTP requests and responses your app sends, so you can compare them directly against those examples when something goes wrong.

using System.Diagnostics;

namespace AspNetCore.Settings;

public class LoggingHttpHandler : DelegatingHandler
{
    private readonly ILogger<LoggingHttpHandler> _logger;

    public LoggingHttpHandler(ILogger<LoggingHttpHandler> logger)
    {
        _logger = logger;
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var requestId = Guid.NewGuid().ToString("N")[..8];
        var stopwatch = Stopwatch.StartNew();

        await LogRequestAsync(requestId, request);

        try
        {
            var response = await base.SendAsync(request, cancellationToken);
            stopwatch.Stop();

            await LogResponseAsync(requestId, response, stopwatch.ElapsedMilliseconds);

            return response;
        }
        catch (Exception ex)
        {
            stopwatch.Stop();
            _logger.LogError(ex,
                "[{RequestId}] Request failed after {ElapsedMs}ms",
                requestId,
                stopwatch.ElapsedMilliseconds);
            throw;
        }
    }

    private async Task LogRequestAsync(string requestId, HttpRequestMessage request)
    {
        const LogLevel logLevel = LogLevel.Information;

        _logger.Log(logLevel,
            "{Method} {Uri}",
            request.Method,
            request.RequestUri);

        foreach (var header in request.Headers)
        {
            var value = header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase)
                ? "[REDACTED]"
                : string.Join(", ", header.Value);

            _logger.Log(logLevel,
                "{Header}: {Value}", header.Key, value);
        }

        if (request.Content is not null)
        {
            var body = await request.Content.ReadAsStringAsync();

            _logger.Log(logLevel,
                "[{RequestId}] <-- Body: {@Body}",
                requestId,
                body);
        }
    }

    private async Task LogResponseAsync(
        string requestId,
        HttpResponseMessage response,
        long elapsedMs)
    {
        var logLevel = response.IsSuccessStatusCode ? LogLevel.Debug : LogLevel.Warning;

        _logger.Log(logLevel,
            "[{RequestId}] <-- {StatusCode} {ReasonPhrase} ({ElapsedMs}ms)",
            requestId,
            (int)response.StatusCode,
            response.ReasonPhrase,
            elapsedMs);

        var body = await response.Content.ReadAsStringAsync();

        var truncatedBody = body.Length > 10000
            ? body[..10000] + $"... [truncated, total {body.Length} chars]"
            : body;

        _logger.Log(logLevel,
            "[{RequestId}] <-- Body: {@Body}",
            requestId, truncatedBody);
    }
}

This DelegatingHandler logs every outgoing HTTP request and its response. It redacts the Authorization header so your API key doesn’t leak into logs, and truncates large response bodies. We only register it in the Development environment (more on that in Bootstraps.cs).

Defining the LLM Client Interface

namespace AspNetCore.Clients;

public interface ILlmClient
{
    Task<string?> Solve(string problemToSolve, CancellationToken ct = default);
}

A simple interface so we can swap implementations later if needed.

Building the OpenRouter Client with OpenAI SDK

using OpenAI.Chat;

namespace AspNetCore.Clients;

public class OpenRouterClient : ILlmClient
{
    private readonly ChatClient _chatClient;

    private const string SYSTEM_PROMPT = """
                                         You are an expert math professor with deep knowledge across all areas of mathematics.
                                         The user will provide a math problem.
                                         Your task is to solve it step by step, showing all work clearly.

                                         Guidelines:
                                         - Break down the solution into clear, numbered steps
                                         - State the final answer explicitly at the end
                                         """;

    public OpenRouterClient(ChatClient chatClient)
    {
        _chatClient = chatClient;
    }

    public async Task<string?> Solve(string problemToSolve, CancellationToken ct = default)
    {
        List<ChatMessage> messages =
        [
            new SystemChatMessage(SYSTEM_PROMPT),
            new UserChatMessage(problemToSolve)
        ];

        ChatCompletionOptions options = new()
        {
            Temperature = 0,
            TopP = 1,
            FrequencyPenalty = 0,
            PresencePenalty = 0,
            MaxOutputTokenCount = 5000,
            ResponseFormat = ChatResponseFormat.CreateTextFormat()
        };

        ChatCompletion completion = await _chatClient.CompleteChatAsync(messages, options, ct);

        var jsonResponse = completion.Content[0].Text;

        return jsonResponse;
    }
}

The OpenRouterClient uses ChatClient from the OpenAI SDK. We build a message list with a system prompt and the user’s math problem, set Temperature to 0 for deterministic output, and call CompleteChatAsync. The response comes back in the same format as an OpenAI response.

Registering Services with Dependency Injection

This is where the interesting wiring happens.

using System.ClientModel;
using System.ClientModel.Primitives;
using System.Net;
using AspNetCore.Clients;
using AspNetCore.Settings;
using OpenAI;
using OpenAI.Chat;

namespace AspNetCore;

public static class Bootstraps
{
    public static IServiceCollection RegisterInfrastructure(
        this IServiceCollection services,
        IConfiguration configuration,
        IHostEnvironment env)
    {
        services.AddScoped<ILlmClient, OpenRouterClient>();
        services.AddOpenAiChatClient(configuration, env);

        return services;
    }

    private static IServiceCollection AddOpenAiChatClient(
        this IServiceCollection services,
        IConfiguration configuration,
        IHostEnvironment env)
    {
        services.Configure<OpenRouterSettings>(
            configuration.GetSection(OpenRouterSettings.SectionName));

        var settings = configuration
                           .GetRequiredSection(OpenRouterSettings.SectionName)
                           .Get<OpenRouterSettings>()
                       ?? throw new InvalidOperationException(
                           $"Missing {OpenRouterSettings.SectionName} section.");

        const string clientName = "OpenRouter";

        var clientBuilder = services.AddHttpClient(clientName)
            .ConfigurePrimaryHttpMessageHandler(() =>
            {
                var handler = new HttpClientHandler();

                if (env.IsDevelopment()
                    && !string.IsNullOrWhiteSpace(settings.ProxyUrl?.ToString()))
                {
                    handler.Proxy = new WebProxy(settings.ProxyUrl);
                    handler.UseProxy = true;
                }
                else
                {
                    handler.UseProxy = false;
                }

                return handler;
            });

        if (env.IsDevelopment())
        {
            services.AddTransient<LoggingHttpHandler>();
            clientBuilder.AddHttpMessageHandler<LoggingHttpHandler>();
        }

        services.AddSingleton<ChatClient>(sp =>
        {
            var httpClientFactory = sp.GetRequiredService<IHttpClientFactory>();
            var httpClient = httpClientFactory.CreateClient(clientName);
            var transport = new HttpClientPipelineTransport(httpClient);

            var options = new OpenAIClientOptions
            {
                Transport = transport,
                Endpoint = settings.BaseUrl
            };

            return new ChatClient(
                model: settings.Model,
                credential: new ApiKeyCredential(settings.ApiKey),
                options: options);
        });

        return services;
    }
}

The key trick is in the ChatClient registration. We create an HttpClientPipelineTransport from our configured HttpClient and pass it into OpenAIClientOptions along with Endpoint = settings.BaseUrl. This redirects all SDK calls to https://openrouter.ai/api/v1 instead of OpenAI’s default endpoint. The ApiKeyCredential carries your OpenRouter API key.

The proxy and logging handler are development-only conveniences.

Creating the Minimal API Endpoint

using AspNetCore;
using AspNetCore.Clients;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();

builder.Services.RegisterInfrastructure(
    builder.Configuration,
    builder.Environment);

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

app.MapPost("/solve", async (
        [FromServices] ILlmClient llmClient,
        [FromBody] SolveRequestDto request,
        CancellationToken ct) =>
    {
        if (string.IsNullOrEmpty(request.Problem))
        {
            return Results.BadRequest("Problem field is required.");
        }

        var solution = await llmClient.Solve(request.Problem, ct);

        return Results.Ok(
            string.IsNullOrWhiteSpace(solution)
                ? new SolveResponseDto(false)
                : new SolveResponseDto(true, solution));
    })
    .WithName("SolveMathProblem");

await app.RunAsync();

public sealed record SolveRequestDto(string Problem);

public sealed record SolveResponseDto(bool HasSolved, string? Solution = null);

One POST endpoint at /solve. It takes a JSON body with a Problem field, passes it to the LLM client, and returns the solution. The DTOs are defined as records at the bottom of the file.

Testing the API with HTTP Requests

Create an AspNetCore.http file (supported by JetBrains Rider and Visual Studio) to test the endpoint:

@AspNetCore_HostAddress = http://localhost:5193

### POST /solve endpoint: send a math problem
POST {{AspNetCore_HostAddress}}/solve
Content-Type: application/json

{
  "problem": "Solve equation x^2-1=0"
}

###

### POST /solve endpoint: missing problem field (expect 400)
POST {{AspNetCore_HostAddress}}/solve
Content-Type: application/json

{}

###

Run the app with dotnet run, fire the first request, and you should get back a step-by-step solution from the model.

Conclusion

The OpenAI SDK does all the heavy lifting. We just swap the endpoint to OpenRouter, pass in the right API key, and pick a model. This gives you access to models from Google, Anthropic, and many others through one consistent interface.

Full source code is available on GitHub: github.com/archie1602/openrouter-dotnet-example