MarginFuse 0.3.1

dotnet add package MarginFuse --version 0.3.1
                    
NuGet\Install-Package MarginFuse -Version 0.3.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="MarginFuse" Version="0.3.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="MarginFuse" Version="0.3.1" />
                    
Directory.Packages.props
<PackageReference Include="MarginFuse" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add MarginFuse --version 0.3.1
                    
#r "nuget: MarginFuse, 0.3.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package MarginFuse@0.3.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=MarginFuse&version=0.3.1
                    
Install as a Cake Addin
#tool nuget:?package=MarginFuse&version=0.3.1
                    
Install as a Cake Tool

MarginFuse for .NET

NuGet ci license

Server-side SDK for MarginFuse: profitability guardrails for AI SaaS. Connect revenue to per-request AI cost, see gross margin per customer, and stop loss-making requests before they run.

  • Metadata only, by construction. The event shape has no field for prompts or responses, so they cannot be sent. Not a policy, an absence.
  • Never breaks your app. It does not throw into your code, and it does not block your request on MarginFuse being up. If MarginFuse is unreachable, your requests proceed unchanged.
  • Zero dependencies. net8.0, using System.Text.Json from the box. Nothing to conflict with what your application already references.

Server side only. This SDK carries a secret API key. Never ship it in a desktop or mobile application, or anything else a user can read.

Install

dotnet add package MarginFuse

Track an AI call

Monitoring. One call after each AI request, metadata only.

await using var mf = new MarginFuseClient(new MarginFuseOptions
{
    ApiKey = Environment.GetEnvironmentVariable("MARGINFUSE_KEY")!,
});

mf.Track(new TrackParams
{
    CustomerId = "cus_8x2m91",   // your Stripe customer id, or your own
    Feature = "ai_chat",
    Provider = "openai",
    Model = "gpt-4.1",
    Usage = new Usage { InputTokens = 1204, OutputTokens = 388 },
});

Track returns immediately and sends in the background with retries. In a worker or a short-lived process, flush before exiting. The client is IAsyncDisposable, so await using does it for you.

A null property in Usage means not reported, not "used none": it is left off the request entirely, because claiming a call used zero input tokens is a different statement from not knowing what it used.

With dependency injection

The client is safe to share, so register it once:

builder.Services.AddSingleton(_ => new MarginFuseClient(new MarginFuseOptions
{
    ApiKey = builder.Configuration["MarginFuse:ApiKey"]!,
    OnError = (error, context) => logger.LogWarning(error, "marginfuse {Context}", context),
}));

Guard a call

Protection. Ask before the call runs, and act on the answer.

var outcome = await mf.GuardAsync(
    new DecideParams
    {
        CustomerId = "cus_8x2m91",
        Feature = "ai_chat",
        Provider = "openai",
        Model = "gpt-4.1",
    },
    async decision =>
    {
        // decision.Model is the one to call: a downgrade verdict changes it.
        var response = await client.ChatAsync(decision.Model, messages);
        var cached = response.CachedTokens; // default to 0 when absent
        return new ProviderCall<ChatResponse>
        {
            Result = response,
            Usage = new Usage
            {
                InputTokens = response.PromptTokens - cached,
                CachedInputTokens = cached,
                OutputTokens = response.CompletionTokens,
            },
        };
    });

switch (outcome.Kind)
{
    case GuardKind.Completed: Use(outcome.Result!); break;
    case GuardKind.TopupRequired: ShowTopup(outcome.Decision.TopupContext); break;
    case GuardKind.Blocked: ShowLimitReached(); break;
}

The provider client in this example is illustrative. For OpenAI usage, report inputTokens = prompt_tokens - cached_tokens and cachedInputTokens = cached_tokens (default cached tokens to zero when absent); map these fields from your provider client's response. The example assumes a same-provider downgrade. If policies can switch providers, dispatch using the decision's provider as well as its model.

One call does the whole loop: ask, run with the resolved model, report the real cost, acknowledge what your application did.

Why a callback

Enforcement must not depend on you remembering to check anything. If GuardAsync returned a decision for you to act on, forgetting the check once would mean a blocked request reaches the provider anyway. With a callback that is structurally impossible: when the verdict is Block, your lambda is never invoked.

Why DecideAsync has no failure path

There is no failure a caller should branch on. A decision that times out or errors is an allow with Degraded set, because MarginFuse being unreachable must never become your outage. Transport failures go to OnError.

Tell MarginFuse what a customer pays

Margin needs a revenue side: Stripe for web billing, RevenueCat for App Store and Google Play proceeds, or declared plan prices. RevenueCat joins by App User ID; use that same ID in your events. Without a billing connection, declare your plans in MarginFuse and say which plan each customer is on. Declared revenue is unverified and does not confirm payment:

Identity id = await mf.IdentifyAsync(new IdentifyParams
{
    CustomerId = "user_8x2m91",
    Plan = "pro",            // the key of a plan you declared in Settings
    Name = "Acme Studio",
    Metadata = new Dictionary<string, string> { ["tier"] = "legacy" },
});

if (!id.Ok) logger.LogWarning("MarginFuse identify: {Error}", id.Error);

Safe to call on every sign-in: sending the plan the customer is already on changes nothing. Sending a different one ends the current cycle and prorates what accrued. PeriodStart backdates the cycle for a customer who has been paying since an earlier date; ClearPlan takes them off plans.

This is the one call that does not fail open. Track retries later and DecideAsync allows, because both have a safe default; "I could not record what this customer pays" has none, and a wrong plan is a wrong margin. So it reports the failure to you instead of swallowing it. It still never throws.

TrackParams and DecideParams also take a Plan, so it can ride along with usage rather than needing its own call. There it is a hint: a key that does not resolve is ignored rather than failing your event.

OpenRouter and other gateways

Gateways report the real cost of every call. Forward it and your figures are exact instead of estimated.

// usage is the decoded "usage" object from the response
var mapped = OpenRouter.From(usage);

mf.Track(new TrackParams
{
    CustomerId = "cus_8x2m91",
    Feature = "ai_chat",
    Provider = "openrouter",
    Model = "anthropic/claude-sonnet-4.5",
    Usage = mapped.Usage,
    CostUsd = mapped.CostUsd,
});

OpenRouter.From takes a JsonElement, so no particular HTTP client is implied. Use it rather than mapping the fields yourself: OpenRouter's prompt_tokens already includes cached reads and cache writes, which MarginFuse prices separately, so passing it through directly charges every cached token twice at the full input rate. The helper also formats the cost as a decimal string, because the default numeric formatting produces 1.2E-07 for small costs and the API rejects that.

Configuration

new MarginFuseOptions
{
    ApiKey = Environment.GetEnvironmentVariable("MARGINFUSE_KEY")!,
    BaseUrl = "https://api.marginfuse.com",       // your own deployment in dev
    Timeout = TimeSpan.FromMilliseconds(1500),    // decide budget before failing open
    OnError = (error, context) => log.Warn(error, context),
    HttpClient = myClient,                        // proxies, IHttpClientFactory
}

OnError is the only place transport failures surface. The SDK swallows them so they cannot become your outage; without the handler they are silent.

What it sends

Everything, and nothing else:

eventId  customerId  feature  provider  model  requestedModel  plan
usage { inputTokens, outputTokens, cachedInputTokens,
        cacheCreationTokens, images, audioSeconds }
costUsd  occurredAt  outcome  decisionId  retryOfEventId  correctsEventId

There is no field for message content anywhere in the wire types. The conformance suite checks this against the bytes that actually leave the process, on every scenario.

Conformance

This SDK is verified against marginfuse/sdk-contract, the same contract every MarginFuse SDK in every language is held to. It is a submodule here, so the pinned commit records exactly which contract a release passed, and Contract.Version reports it at runtime.

git clone --recurse-submodules https://github.com/marginfuse/marginfuse-dotnet
cd marginfuse-dotnet
dotnet test                       # unit tests, plus the shared gateway vectors
dotnet build tools/ConformanceRunner/ConformanceRunner.csproj -c Release
npm --prefix contract/harness install
npm --prefix contract/harness run conformance dotnet

MIT, Pemira Labs.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net8.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.3.1 47 9/6/2026
0.3.0 47 9/4/2026
0.2.0 52 9/3/2026
0.1.0 51 9/1/2026