- C# 100%
|
|
||
|---|---|---|
| .github/workflows | ||
| samples/OpenRouter.Net.Sample | ||
| src | ||
| tests | ||
| .editorconfig | ||
| .gitignore | ||
| Directory.Build.props | ||
| Directory.Packages.props | ||
| OpenRouter.Net.slnx | ||
| README.md | ||
OpenRouter.Net
A well-tested, idiomatic C# adapter for the OpenRouter API.
OpenRouter.Net ships as a focused core library for chat completions, model
metadata, and account information. A separate OpenRouter.Net.Agents package layers
a stateful agent loop and a bundled tool roster on top of the core client for callers who
want OpenAI-style function calling and multi-turn tool execution out of the box.
Status: v0.2. Core API client and agent layer are feature-complete and tested against the live OpenRouter API. Not yet published to NuGet.
Highlights
net10.0, nullable enabled, warnings-as-errors, full XML doc coverage- One public type per file — easy to navigate, friendly to diff tools
Result<T>discriminated union for predictable error handling — no exceptions for API errors- Automatic retries with exponential backoff + jitter for transient failures (rate limits, upstream 5xx, network errors, and OpenRouter's "HTTP 200 with a retryable error body")
IAsyncEnumerable<ChatCompletionChunk>for SSE streaming- DI-first:
services.AddOpenRouter(o => o.ApiKey = "...") - Sealed records with
init/requiredproperties for every request and response model - Polymorphic message and content unions tagged via
roleandtypediscriminators - Full coverage of OpenRouter's request surface: provider preferences, fallback routing, reasoning options, structured outputs, tool calling, prompt caching, per-request usage
- Optional
OpenRouter.Net.Imagingpackage: auto-resize and re-encode image attachments (JPEG / PNG / WebP) to fit provider byte / dimension budgets — keeps native deps out of core - Stateful
Agentwith auto-generated tool schemas, observability events, sub-agents, workspace sandboxing, and an "out of rounds" extension hook
Installation
Not yet published to NuGet. Add as a project reference:
<ProjectReference Include="path/to/OpenRouter.Net/src/OpenRouter.Net/OpenRouter.Net.csproj" />
<ProjectReference Include="path/to/OpenRouter.Net/src/OpenRouter.Net.Agents/OpenRouter.Net.Agents.csproj" />
<!-- Optional: image attachment encoding (pulls in SkiaSharp + native binaries) -->
<ProjectReference Include="path/to/OpenRouter.Net/src/OpenRouter.Net.Imaging/OpenRouter.Net.Imaging.csproj" />
Three-package layout
| Package | Purpose | Depends on |
|---|---|---|
OpenRouter.Net |
HTTP client, request/response models, DI extension | — (pure managed) |
OpenRouter.Net.Agents |
Agent loop, Tool<TParams> base class, bundled tools |
OpenRouter.Net, DiffPlex |
OpenRouter.Net.Imaging |
Image attachment encoding (resize + re-encode to JPEG/PNG/WebP) | OpenRouter.Net, SkiaSharp |
Use just the core if you only need raw chat completions — it carries no native dependencies. Add the agents package for function calling, multi-turn tool execution, or the bundled filesystem / sub-agent tools. Add the imaging package only if you want automatic image resizing/re-encoding (it pulls in SkiaSharp and its native binaries — see Image attachments).
Core library — OpenRouter.Net
Quick start
using Microsoft.Extensions.DependencyInjection;
using OpenRouter.Net.Contracts;
using OpenRouter.Net.Extensions;
using OpenRouter.Net.Models.Common;
using OpenRouter.Net.Models.Messages;
using OpenRouter.Net.Models.Requests;
using OpenRouter.Net.Models.Responses;
var services = new ServiceCollection();
services.AddOpenRouter(o =>
{
o.ApiKey = Environment.GetEnvironmentVariable("OPENROUTER_API_KEY")!;
o.HttpReferer = "https://github.com/schattenan/OpenRouter.Net";
o.XTitle = "My App";
});
var client = services.BuildServiceProvider().GetRequiredService<IOpenRouterClient>();
var result = await client.CreateChatCompletionAsync(new ChatCompletionRequest
{
Model = "anthropic/claude-sonnet-4",
Messages =
[
new SystemMessage("You are a helpful assistant."),
new UserMessage("Why is the sky blue?"),
],
MaxTokens = 200,
});
switch (result)
{
case Result<ChatCompletionResponse>.Success ok:
Console.WriteLine(ok.Value.Choices[0].Message?.Content);
break;
case Result<ChatCompletionResponse>.Failure err:
Console.Error.WriteLine($"FAIL ({err.StatusCode}): {err.Error}");
break;
}
The Result<T> pattern
Non-streaming methods never throw on API errors. They return a Result<T> that
pattern-matches as Success(T value) or Failure(string error, int? statusCode, ApiError? details).
This keeps happy-path code linear and forces you to handle failure explicitly.
result.Match(
onSuccess: r => Console.WriteLine(r.Choices[0].Message?.Content),
onFailure: f => Console.Error.WriteLine($"{f.StatusCode}: {f.Error}"));
There are also IsSuccess, IsFailure, and ValueOrDefault for ad-hoc use.
Streaming throws OpenRouterException on transport failure, since IAsyncEnumerable<T>
cannot express a Result<T> shape; non-streaming methods surface API errors as
Result<T>.Failure.
Automatic retries
All non-streaming calls retry transient failures automatically: HTTP 408/429/500/502/503/504,
network errors, request timeouts, and OpenRouter's "HTTP 200 with a retryable error body"
responses (e.g. an upstream provider returning a 429). Defaults are 3 attempts with exponential
backoff starting at 1s (×2.0, capped at 30s), ±20% jitter, honoring the Retry-After header.
Streaming is not retried, since a partially-consumed stream cannot be safely replayed.
Configure or disable retries via RetryOptions on OpenRouterClientOptions:
services.AddOpenRouter(o =>
{
o.ApiKey = "...";
o.Retry = new RetryOptions { MaxAttempts = 5, InitialDelay = TimeSpan.FromMilliseconds(500) };
// o.Retry = RetryOptions.Disabled; // attempt every request exactly once
});
The first non-retryable result — success or failure — is returned immediately; once attempts are
exhausted the last result is returned as a Result<T>.Failure.
Streaming
await foreach (var chunk in client.StreamChatCompletionAsync(new ChatCompletionRequest
{
Model = "openai/gpt-4o",
Messages = [new UserMessage("Count from 1 to 10.")],
}))
{
var delta = chunk.Choices[0].Delta?.Content;
if (!string.IsNullOrEmpty(delta))
{
Console.Write(delta);
}
}
The SSE reader skips comments and the terminating [DONE] sentinel automatically.
Multimodal input
The ContentPart polymorphic union covers text, images (by URL or data: URI), and
audio (base64 bytes). You can mix them in a single UserMessage:
var msg = new UserMessage(
[
new TextPart("What's in this image?"),
new ImagePart("https://example.com/photo.jpg", detail: "high"),
]);
Auto-encoded image attachments
The optional OpenRouter.Net.Imaging package provides ImageEncoder, which loads an
image, resizes it to fit a maximum dimension, steps quality down to fit a maximum byte
budget, and embeds the result as a data: URI. Useful when uploading screenshots or photos
to providers with strict size limits. It's a separate package so the core client stays free
of native dependencies — install it only when you need this.
using OpenRouter.Net.Imaging;
var image = await ImageEncoder.FromFileAsync(
"screenshot.png",
new ImageEncodeOptions
{
MaxBytes = 2L * 1024 * 1024, // 2 MiB
MaxDimension = 1568, // longest side, in pixels
Format = ImageOutputFormat.Webp,
InitialQuality = 85,
MinQuality = 60,
Detail = "high",
});
var msg = new UserMessage([new TextPart("Describe this."), image]);
FromBytesAsync and FromStreamAsync are also available. PNG output skips quality
stepping and goes straight to dimension reduction (PNG is lossless).
Native dependency (SkiaSharp)
OpenRouter.Net.Imaging is backed by SkiaSharp, which
needs a native binary per platform. The native assets are resolved automatically on
Windows and macOS via SkiaSharp's own dependencies. On Linux (including most Docker
images and CI), add the matching native-asset package to your application project:
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.4" />
Use SkiaSharp.NativeAssets.Linux instead if you prefer to rely on system-installed
dependencies (e.g. fontconfig) rather than the self-contained build. If you skip this step
on Linux you'll get a DllNotFoundException: libSkiaSharp at runtime.
If you only ever attach already-encoded images, you don't need this package at all — the
core ImagePart.FromRawBytes(...) and ImagePart.FromBase64(...) factories embed bytes
directly with no image library.
Tool / function calling (raw)
var weatherTool = new ToolDefinition
{
Function = new FunctionDefinition
{
Name = "get_weather",
Description = "Get the current weather for a city.",
Parameters = JsonDocument.Parse("""
{ "type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"] }
""").RootElement,
},
};
var result = await client.CreateChatCompletionAsync(new ChatCompletionRequest
{
Model = "anthropic/claude-sonnet-4",
Messages = [new UserMessage("What's the weather in Berlin?")],
Tools = [weatherTool],
ToolChoice = ToolChoice.Auto,
});
ToolChoice exposes Auto, None, Required, and ToolChoice.Function("name") for
forcing a specific call. For a higher-level loop that handles tool_calls /
tool-result round-trips automatically, see the OpenRouter.Net.Agents package below.
Provider routing
var request = new ChatCompletionRequest
{
Model = "anthropic/claude-sonnet-4",
Messages = [...],
Provider = new ProviderPreferences
{
Order = ["anthropic", "google-vertex"],
Sort = "throughput",
AllowFallbacks = true,
DataCollection = "deny",
},
Models = ["anthropic/claude-haiku-4", "openai/gpt-4o-mini"], // fallback chain
Route = "fallback",
};
Structured outputs
var schema = JsonDocument.Parse("""
{ "type": "object",
"properties": {
"city": { "type": "string" },
"country": { "type": "string" } },
"required": ["city", "country"] }
""").RootElement;
var request = new ChatCompletionRequest
{
Model = "openai/gpt-4o-mini",
Messages = [...],
ResponseFormat = ResponseFormat.JsonSchemaFormat("city_lookup", schema, strict: true),
};
Use ResponseFormat.JsonObject() for the looser "must be valid JSON" mode.
Reasoning / extended thinking
var request = new ChatCompletionRequest
{
Model = "anthropic/claude-sonnet-4",
Messages = [...],
Reasoning = new ReasoningOptions
{
Effort = "high",
MaxTokens = 4096,
},
};
Reasoning text and provider-specific reasoning details surface on
AssistantMessage.Reasoning and AssistantMessage.ReasoningDetails. To preserve thinking
state across turns, replay ReasoningDetails unchanged on the assistant message in the
next request.
Not every model takes every effort, and some cannot be asked to stop thinking at all, so
ModelInfo.Reasoning carries what the catalog says about one model — use it to offer only
the efforts a model accepts rather than discovering the rest as rejected requests:
var model = (await client.ListModelsAsync()).Value!.First(m => m.Id == "anthropic/claude-opus-5");
model.Reasoning?.SupportedEfforts; // [Max, XHigh, High, Medium, Low] — null when unstated
model.Reasoning?.DefaultEffort; // High
model.Reasoning?.Mandatory; // false — were it true, ReasoningEffort.None would be rejected
SupportedEfforts is null on models that take an effort without enumerating one, so treat
null as "unknown", never as the empty set. supported_parameters remains the coarse
"does this model reason at all" signal.
Prompt caching
CacheControl markers opt a message or content part into provider-side ephemeral caching
(currently Anthropic and Gemini). You can stamp them by hand:
new SystemMessage("Long static system prompt …")
{
CacheControl = CacheControl.Ephemeral,
};
…or let the agent layer stamp them automatically — see AgentOptions.EnableCaching below.
Account & metadata
var models = await client.ListModelsAsync();
var endpoints = await client.GetModelEndpointsAsync("anthropic", "claude-sonnet-4");
var credits = await client.GetCreditsAsync();
var key = await client.GetKeyAsync();
var gen = await client.GetGenerationAsync("gen-abc123");
GetGenerationAsync returns detailed cost and routing telemetry for any prior generation,
keyed by the id field on ChatCompletionResponse.
Agents library — OpenRouter.Net.Agents
The Agent class wraps IOpenRouterClient, owns its message history, drives the
chat-completions ↔ tool-execution loop, and exposes a Tool<TParams> base class with
auto-generated JSON schema. Bundled file-system tools and a sub-agent tool ship in the
box.
Quick start
using OpenRouter.Net.Agents;
using OpenRouter.Net.Agents.Tools;
using OpenRouter.Net.Agents.Tools.FileSystem;
var workspace = new Workspace("/path/to/sandbox");
var agent = new Agent(
client,
new AgentOptions
{
Model = "anthropic/claude-sonnet-4",
SystemPrompt = "You are a careful coding assistant.",
MaxIterations = 8,
EnableCaching = true,
},
tools:
[
new ReadFileTool(workspace),
new WriteFileTool(workspace),
new ListDirectoryTool(workspace),
new SearchTool(workspace),
new DiffTool(workspace),
]);
var result = await agent.ChatAsync("Find all TODOs in the workspace and summarise them.");
Console.WriteLine(result.FinalText);
The agent's history persists across calls. Use ChatAsync to append a user prompt and
run, ChatMultiModalAsync for an image-bearing turn, ContinueAsync to run without
appending a new user message, Reset() to start over (the system prompt is restored),
and LoadHistory(...) / AddMessage(...) to manipulate state directly.
Authoring custom tools
Derive from Tool<TParams> and supply a parameter type — its JSON schema is generated
automatically from TParams using System.Text.Json.Schema.
public sealed record AddParams
{
[JsonPropertyName("a")] public required double A { get; init; }
[JsonPropertyName("b")] public required double B { get; init; }
}
public sealed class AddTool : Tool<AddParams>
{
public override string Name => "add";
public override string Description => "Add two numbers.";
protected override Task<ToolResult> ExecuteAsync(
AddParams p, ToolInvocationContext ctx, CancellationToken ct)
=> Task.FromResult(ToolResult.Success((p.A + p.B).ToString(CultureInfo.InvariantCulture)));
}
ToolResult is constructed via Success(...), Failure(...), or MultiModal(...).
The multimodal form lets a tool return both a textual summary (for the model to read in
the tool message) and image / audio parts that the agent will inject as a follow-up
UserMessage — a workaround for providers that drop images from tool-role messages.
This injection is controlled by AgentOptions.InjectToolImages (on by default).
ToolInvocationContext carries the tool_call_id, the 1-based iteration number, and the
IServiceProvider (when the agent was built via DI), letting tools resolve their own
dependencies.
Vision support & blind mode
Some models can't accept image input, and OpenRouter rejects requests that send one to
them ("No endpoints found that support image input"). Set AgentOptions.SupportsVision = false to make the agent strip image parts from tool results before they reach the model —
the tool's text body is always preserved, and no follow-up image UserMessage is injected.
To let a text-only model still "see", supply an ImageDescriber hook. When set and
SupportsVision is false, every image bound for the model — user input, replayed
history, and tool results — is replaced with a TextPart holding its description instead
of being dropped:
var options = new AgentOptions
{
Model = "some/text-only-model",
SupportsVision = false,
ImageDescriber = async (image, ct) =>
{
// Delegate to a separate vision-capable model (cache results yourself).
var caption = await visionClient.DescribeAsync(image.ImageUrl.Url, ct);
return $"[image] {caption}";
},
};
The describer is expected to cache its own results, so repeated history replays stay cheap.
Bundled tools
| Tool | Purpose |
|---|---|
ReadFileTool |
Read a UTF-8 file, optionally truncated to N chars |
WriteFileTool |
Write a UTF-8 file (respects Workspace.ReadOnly); creates parent dirs |
ListDirectoryTool |
Top-level or recursive listing, capped at 500 entries |
SearchTool |
Regex search over workspace files; optional path / glob scope |
MoveTool |
Move or rename a file or directory |
CopyTool |
Copy a file or directory (recursive for dirs) |
DeleteTool |
Delete a file or directory; refuses to remove the workspace root |
DiffTool |
Unified-style line diff between two text files; output capped at 64 KB |
SubAgentTool |
Delegate a self-contained subtask to a fresh agent |
All filesystem tools sandbox to a Workspace root and reject path-traversal attempts
(UnauthorizedAccessException is converted to a ToolResult.Failure).
Workspace sandboxing
var ws = new Workspace(
root: "/var/agent/sandbox",
readOnly: false,
maxFileSizeBytes: 10L * 1024 * 1024);
Resolve(path) normalizes any caller-supplied path against the root and throws
UnauthorizedAccessException if it would escape. ReadOnly makes the mutating tools
refuse outright. MaxFileSizeBytes is enforced by ReadFileTool.
Sub-agents
Use Agent.CreateSubAgent(...) to spawn a fresh agent that shares the same client but
has its own message history — handy for delegating a self-contained subtask, or running
a cheaper model for a piece of the work. The bundled SubAgentTool exposes this pattern
to the model itself:
var orchestrator = new Agent(
client,
options,
tools:
[
new ReadFileTool(workspace),
new SubAgentTool(client, new AgentOptions
{
Model = "anthropic/claude-haiku-4",
SystemPrompt = "You are a focused subtask worker.",
MaxIterations = 4,
},
tools: [new ReadFileTool(workspace)]),
]);
Observability
Every agent run produces a stream of AgentEvents — IterationStarted,
CompletionStart, AssistantTurn, ToolCallStarted, ToolCallCompleted,
ToolCallSkipped, RoundEnd, AgentCompleted. Subscribe via either IProgress<AgentEvent>
or the AgentOptions.OnEvent callback:
var progress = new Progress<AgentEvent>(evt =>
{
if (evt is ToolCallStartedEvent t)
{
Console.WriteLine($"[{t.Iteration}] {t.ToolName}({t.ArgumentsJson})");
}
});
var result = await agent.ChatAsync("…", progress);
RoundEndEvent carries the serving provider and finish-reason detail for each completion —
Provider (OpenRouter's upstream, e.g. "DeepInfra"), FinishReason (normalized, null
for non-standard values), RawFinishReason (the literal finish_reason string, preserved
even for provider-specific moderation codes), and NativeFinishReason (the provider's own
stop reason). These help diagnose a cut-off completion — e.g. a length truncation or a
moderation block the normalized enum hides. The same RawFinishReason / NativeFinishReason
are also exposed on Choice for raw chatCompletion calls.
RoundDetail.Provider carries the same routing fact into the result, so a transcript written
after the run can still say which provider answered each round — routing is decided per
request, and a round that behaves unlike its neighbours usually came from somewhere else.
Alongside it, ToolExecutionDetail.ImagesDelivered / ImageBytesDelivered record how many of
a tool's images actually reached the model. A tool's text body arrives whether or not its
pictures do (SupportsVision = false strips them, blind mode swaps them for descriptions), so
without the count there is nothing to tell a model that saw a picture from one that only read
about it.
AgentResult aggregates the run:
| Field | Description |
|---|---|
StopReason |
Completed, MaxIterationsReached, UnknownToolRequested, or ApiError |
Iterations |
Number of completion → tool-execution cycles executed |
FinalMessage, FinalText |
The closing assistant message (when one exists) |
History |
Full message history including tool round-trips |
Rounds |
Per-iteration breakdown (RoundDetail): assistant message, usage, duration, tool executions |
Events |
Every AgentEvent emitted during the run, in order |
AggregatedUsage |
Summed Usage across all rounds |
AggregatedReasoning / ReasoningDetails |
Concatenated reasoning text and provider-specific details |
ApiError |
The underlying Result<T>.Failure when StopReason == ApiError |
Iteration cap with extension hook
Set AgentOptions.MaxIterations to bound the loop. To recover gracefully when the model
needs more rounds, supply OnMaxRoundsReached — a callback that receives an
AgentSnapshot and returns the number of additional rounds to grant:
options.MaxIterations = 8;
options.OnMaxRoundsReached = async snapshot =>
{
Console.WriteLine($"Hit cap after {snapshot.IterationsExecuted} rounds.");
return await UserConfirmsAsync("Grant 4 more?") ? 4 : null;
};
Returning null or a non-positive value stops the loop with StopReason == MaxIterationsReached.
Auto prompt caching
AgentOptions.EnableCaching = true stamps cache_control: ephemeral on the system
prompt and the most recent user message before each request, without copying or mutating
the underlying history. On supported providers (Anthropic, Gemini), this lets the
provider serve the static prefix from cache on follow-up turns.
DI registration
services.AddOpenRouter(o => o.ApiKey = "…");
services.AddSingleton(new Workspace("/var/sandbox"));
services.AddTool<ReadFileTool>();
services.AddTool<WriteFileTool>();
services.AddTool<SearchTool>();
services.AddOpenRouterAgent(o =>
{
o.Model = "anthropic/claude-sonnet-4";
o.SystemPrompt = "You are a careful coding assistant.";
o.MaxIterations = 8;
o.EnableCaching = true;
});
AddTool<T> registers the type as a singleton ITool. The agent factory pulls every
registered ITool automatically.
Architecture
src/
├── OpenRouter.Net/ # Core API client
│ ├── Contracts/ # IOpenRouterClient
│ ├── Client/ # OpenRouterClient, options
│ ├── Extensions/ # AddOpenRouter() DI extension
│ ├── Models/Common/ # Result<T>, ApiError, CacheControl
│ ├── Models/Messages/ # Sealed-record union: System/User/Assistant/Tool/Developer
│ ├── Models/Content/ # Text/Image/Audio parts + ImageEncodeOptions
│ ├── Models/Requests/ # ChatCompletionRequest, ReasoningOptions, ResponseFormat,
│ │ # ToolDefinition, ToolChoice, ProviderPreferences
│ ├── Models/Responses/ # ChatCompletionResponse, Chunk, ModelInfo, CreditsInfo,
│ │ # GenerationInfo, KeyInfo, ProviderEndpoint
│ ├── Internal/ # JSON converters, SSE reader, error parser
│ └── OpenRouterException.cs
└── OpenRouter.Net.Agents/ # Agent loop + bundled tools
├── Agent/ # Agent, IAgent, AgentOptions, AgentResult, AgentEvent…
├── Extensions/ # AddOpenRouterAgent() / AddTool<T>()
└── Tools/
├── Tool.cs / ITool.cs # Tool<TParams> base class
├── Workspace.cs # Path-sandbox primitive
├── FileSystem/ # Read/Write/List/Search/Move/Copy/Delete/Diff
└── SubAgent/ # SubAgentTool
tests/
├── OpenRouter.Net.Tests/
└── OpenRouter.Net.Agents.Tests/
samples/
└── OpenRouter.Net.Sample/ # Minimal non-streaming + streaming demo
Contributing
Currently a personal project; PRs and issues welcome once published.
License
MIT.