Prerequisites
- .NET 8 SDK (or later) installed and on your PATH.
- An API key from n4n.ai (set as
N4N_API_KEYin your environment orappsettings.json). - Basic comfort with C# records, dependency injection, and
curl. - A target model name you want to call (e.g.,
gpt-4o-minior any of the 240+ models the gateway fronts).
The code compiles without the key; you’ll just get 401 until it’s present.
Why a minimal API
Controllers and MVC add convention-based routing, model binders, and view machinery you don’t need for a single-purpose proxy. The asp.net core minimal api n4n approach keeps the entire surface in one file, uses IHttpClientFactory for socket management, and serializes straight through to the OpenAI-compatible contract. You ship less code and own the exact HTTP behavior.
Scaffold the project
Start from the empty web template. It produces a bare Program.cs with no Startup.cs.
dotnet new web -o N4nProxy
cd N4nProxy
dotnet add package Microsoft.Extensions.Http
Microsoft.Extensions.Http registers IHttpClientFactory, which pools HttpMessageHandler instances and avoids the DNS-stale socket problem you hit when you new HttpClient() per request.
Define the contract
OpenAI-compatible chat completions use a predictable JSON shape. We model only what we consume: messages in, choices and usage out. Paste these records at the top of Program.cs:
public record ChatMessage(string Role, string Content);
public record ChatRequest(string Model, List<ChatMessage> Messages);
public record Choice(int Index, ChatMessage Message);
public record Usage(int PromptTokens, int CompletionTokens, int TotalTokens);
public record ChatResponse(string Id, string Object, long Created, string Model,
List<Choice> Choices, Usage Usage);
System.Text.Json will ignore extra fields the gateway returns (like system_fingerprint). If you need strict schema validation, add [JsonRequired] or a custom converter, but for a proxy, leniency is fine.
Wire up HttpClient
Configure a named client with the base address and auth header. Place this before app.Run():
var builder = WebApplication.CreateBuilder(args);
var apiKey = builder.Configuration["N4N_API_KEY"]
?? throw new InvalidOperationException("Set N4N_API_KEY");
builder.Services.AddHttpClient("n4n", client =>
{
client.BaseAddress = new Uri("https://api.n4n.ai/v1/");
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
client.Timeout = TimeSpan.FromSeconds(30);
});
var app = builder.Build();
The BaseAddress ends with /v1/ so downstream calls use the relative path chat/completions. n4n.ai exposes an OpenAI-compatible endpoint that addresses 240+ models, so the same path works regardless of which backend you target in the model field.
Implement the minimal endpoint
Map a single POST route. Inject IHttpClientFactory, forward the body, return the typed response:
app.MapPost("/chat", async (ChatRequest req, IHttpClientFactory factory) =>
{
var client = factory.CreateClient("n4n");
var payload = new
{
model = req.Model,
messages = req.Messages.Select(m => new { role = m.Role, content = m.Content })
};
using var resp = await client.PostAsJsonAsync("chat/completions", payload);
if (!resp.IsSuccessStatusCode)
{
var detail = await resp.Content.ReadAsStringAsync();
return Results.Problem(detail, statusCode: (int)resp.StatusCode);
}
var result = await resp.Content.ReadFromJsonAsync<ChatResponse>();
return Results.Ok(result);
});
app.Run();
That is the entire proxy. It accepts a typed ChatRequest, forwards it, and returns the gateway’s response—including usage for per-token metering—without an intermediate DTO transformation.
Run and test
Export the key and start the app:
export N4N_API_KEY="sk-your-real-key"
dotnet run
The template listens on http://localhost:5000 and https://localhost:5001 by default. Send a request:
curl -X POST http://localhost:5000/chat \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o-mini",
"messages": [{"role":"user","content":"Say hi in 5 words."}]
}'
Expected successful response (truncated):
{
"id": "chatcmpl-abc123",
"object": "chat.completion",
"created": 1710000000,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "Hello! Hope you're doing well." }
}
],
"usage": { "promptTokens": 12, "completionTokens": 6, "totalTokens": 18 }
}
A missing or invalid key returns 401 with the gateway’s error JSON passed through via Results.Problem.
Hardening the wrapper
The sample is intentionally thin. For production, add timeout handling, error propagation, and structured logging.
Timeouts and cancellation
HttpClient.Timeout is 30s. If the gateway is slow, you’ll catch a TaskCanceledException. Convert that to a clean 504:
app.MapPost("/chat", async (ChatRequest req, IHttpClientFactory factory) =>
{
var client = factory.CreateClient("n4n");
try
{
var payload = new { model = req.Model, messages = req.Messages };
using var resp = await client.PostAsJsonAsync("chat/completions", payload);
resp.EnsureSuccessStatusCode();
return Results.Ok(await resp.Content.ReadFromJsonAsync<ChatResponse>());
}
catch (TaskCanceledException)
{
return Results.Problem("Upstream timeout", statusCode: 504);
}
});
Propagating provider errors
n4n.ai performs automatic fallback when a provider is rate-limited or degraded, but occasional 429 or 5xx still surface. Forward the raw status and body so callers can react to the original schema:
if (!resp.IsSuccessStatusCode)
{
var body = await resp.Content.ReadAsStringAsync();
return Results.Content(body, "application/json", statusCode: (int)resp.StatusCode);
}
Wrapping provider errors in ProblemDetails loses the error.code fields the gateway returns.
Logging
Inject ILogger into the handler and log the usage.TotalTokens on success. That gives you per-request cost signals without extra metering code:
app.MapPost("/chat", async (ChatRequest req, IHttpClientFactory factory, ILogger<Program> log) =>
{
// ... send request ...
var result = await resp.Content.ReadFromJsonAsync<ChatResponse>();
log.LogInformation("n4n call model={Model} tokens={Tokens}", result?.Model, result?.Usage.TotalTokens);
return Results.Ok(result);
});
Extending for routing and cache hints
The wrapper currently picks the model from the request body. Because n4n.ai honors client routing directives and forwards provider cache-control hints, you can extend it to pass through headers or extra body fields without changing the core flow.
For prompt caching, add an optional cache_control field to ChatMessage and let it serialize straight through; the gateway forwards it to providers that support it. The usage field is already mapped, so you can emit per-token metrics to your own pipeline.
If you need to force a specific provider, check the gateway docs for the routing header and add it per-request:
req.Headers.Add("x-n4n-router", "anthropic"); // verify exact header in current docs
Keep such extensions explicit. Don’t guess header names in production.
Streaming and next steps
The proxy above returns the full completion. To support token streaming, change the route to return IAsyncEnumerable<ChatResponse> and read the text/event-stream from the gateway with ReadAsStreamAsync. The minimal API model binds just as cleanly.
You now have a working asp.net core minimal api n4n wrapper that speaks the OpenAI chat format, surfaces token usage, and fails cleanly. Drop it into a larger ASP.NET Core app as a /proxy/chat route, or ship it as a standalone sidecar in front of your internal services.