No description
Find a file
2026-07-30 13:31:54 +02:00
.github/workflows ci: add CI pipeline with build, test, coverage, and sticky PR comment 2026-06-28 18:19:04 +00:00
samples/OpenRouter.Net.Sample Initial commit: OpenRouter.Net v0.2 2026-04-27 19:27:13 +02:00
src fix: a provider's own handle on its thought survives the round-trip 2026-07-30 13:08:25 +02:00
tests test: the discriminator count needs no helper of its own 2026-07-30 13:30:01 +02:00
.editorconfig Initial commit: OpenRouter.Net v0.2 2026-04-27 19:27:13 +02:00
.gitignore Initial commit: OpenRouter.Net v0.2 2026-04-27 19:27:13 +02:00
Directory.Build.props chore: fix RepositoryUrl to TeamAI org 2026-06-27 13:09:10 +02:00
Directory.Packages.props Replace ImageSharp with SkiaSharp (optional OpenRouter.Net.Imaging package) (#2) 2026-05-29 18:22:11 +02:00
OpenRouter.Net.slnx Replace ImageSharp with SkiaSharp (optional OpenRouter.Net.Imaging package) (#2) 2026-05-29 18:22:11 +02:00
README.md feat(agents): the result says who served a round and what its images weighed 2026-07-29 14:52:44 +02:00

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 / required properties for every request and response model
  • Polymorphic message and content unions tagged via role and type discriminators
  • 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.Imaging package: auto-resize and re-encode image attachments (JPEG / PNG / WebP) to fit provider byte / dimension budgets — keeps native deps out of core
  • Stateful Agent with 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.