Dependency injection
In an application built on the .NET generic host, such as an ASP.NET Core site or a worker service, you do not create
the client yourself. You register it once, and the container hands it to every class that asks for an IDecisionClient.
The registering is done by two packages. Minos.NET.SystemOne has AddSystemOneDecisionClient, which registers a
SystemOneDecisionClient over IHttpClientFactory, reads its options from code or from configuration, and checks them
when the host starts. It depends on Minos.NET.DependencyInjection, the provider-neutral part, so one install covers
both:
dotnet add package Minos.NET.SystemOne
The extension methods live in the Microsoft.Extensions.DependencyInjection namespace, so no extra using is needed
beyond the one you already have for the container. OpenAI registers OpenAI's client from
Minos.NET.OpenAI the same way, and the neutral AddDecisionClient is for any other
client.
Register a client
A class asks for IDecisionClient in its constructor. Here is a small one that judges a message as urgent or not.
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Minos;
using Minos.SystemOne;
// One yes/no question, so the example stays short.
[Questions]
public partial record InboxCheck
{
[Noul("Does this convey urgency?")]
public partial Noul IsUrgent { get; }
}
// A class asks for IDecisionClient in its constructor, and the container supplies the shared client.
public sealed class InboxTriage(IDecisionClient client)
{
public async Task<string> TriageAsync(string message, CancellationToken cancellationToken)
{
var result = await client.EvaluateAsync<InboxCheck>(message, cancellationToken);
if (result.IsFailure)
{
return $"The call failed, {result.Error.Kind}";
}
return result.Value.IsUrgent.Value ? "urgent" : "can wait";
}
}
AddSystemOneDecisionClient registers the client. The overload with an Action<SystemOneDecisionClientOptions> sets
the options in code. Here the key comes from configuration, such as user secrets, so it is never written into the
program.
// The key is read from configuration, such as user secrets, and never written into the code.
public static SystemOneDecisionClientServiceBuilder AddDecision(IHostApplicationBuilder builder)
{
builder.Services.AddSingleton<InboxTriage>();
return builder.Services.AddSystemOneDecisionClient(options => options.ApiKey = builder.Configuration["TypeSafe:ApiKey"]);
}
After that, the container builds an InboxTriage with the shared client whenever one is needed, and calls
TriageAsync as before.
AddSystemOneDecisionClient returns a SystemOneDecisionClientServiceBuilder, the registration's pipeline builder. Its
HttpClient property is the IHttpClientBuilder of the client's HttpClient, which is where handlers are added, as
below shows. Its Use methods add stages around the client, as The client
pipeline shows.
The six overloads
AddSystemOneDecisionClient has six overloads. Three register the default client, and three register a client under a
name.
| Overload | Registers | Options come from |
|---|---|---|
AddSystemOneDecisionClient() | the default client | the defaults and the environment variables |
AddSystemOneDecisionClient(Action<SystemOneDecisionClientOptions>) | the default client | your delegate |
AddSystemOneDecisionClient(IConfiguration) | the default client | a configuration section |
AddSystemOneDecisionClient(string name) | a keyed client | the defaults and the environment variables |
AddSystemOneDecisionClient(string name, Action<SystemOneDecisionClientOptions>) | a keyed client | your delegate |
AddSystemOneDecisionClient(string name, IConfiguration) | a keyed client | a configuration section |
"The defaults and the environment variables" are the ones from the client page: the key
comes from TYPESAFE_API_KEY, the base address from TYPESAFE_BASE_URL when it is set, and everything else has its
default.
// With no options at all, the key comes from TYPESAFE_API_KEY, the base address from TYPESAFE_BASE_URL when set,
// and everything else from the defaults.
public static void AddFromEnvironment(IServiceCollection services)
{
services.AddSystemOneDecisionClient();
services.AddSystemOneDecisionClient("backup");
}
The name must not be empty. It is both the service key and the name of the options.
Several clients
An application sometimes needs more than one client, for example one for TypeSafe and one for a server you run, or two
keys with different retry settings. Register each under a name, and ask for each by that name with
[FromKeyedServices]. Each client takes its own provider, so the two here need nothing in common.
public static void AddKeyedClients(IHostApplicationBuilder builder)
{
builder.Services.AddSystemOneDecisionClient("typesafe", options => options.ApiKey = builder.Configuration["TypeSafe:ApiKey"]);
builder.Services.AddSystemOneDecisionClient("local", options =>
{
// A server you run: Custom has no default address or model, and sends no key unless ApiKey is set.
options.Provider = SystemOneProvider.Custom;
options.BaseAddress = new Uri("http://localhost:8080/");
options.Model = "local-model";
});
builder.Services.AddSingleton<InboxRouter>();
}
// A keyed client is asked for by its key.
public sealed class InboxRouter([FromKeyedServices("local")] IDecisionClient client)
{
public async Task<string> TriageAsync(string message, CancellationToken cancellationToken)
{
var result = await client.EvaluateAsync<InboxCheck>(message, cancellationToken);
if (result.IsFailure)
{
return $"The call failed, {result.Error.Kind}";
}
return result.Value.IsUrgent.Value ? "urgent, via the local server" : "can wait, via the local server";
}
}
A keyed client is a separate singleton with its own options and its own HttpClient. A name you register does not
register the default client, so IDecisionClient without a key still fails to resolve unless you also called one of the
default overloads.
Options from configuration
Instead of a delegate, you can bind a client's options from configuration, such as appsettings.json. The keys are the
SystemOneDecisionClientOptions property names, and every key is optional.
{
"Minos": {
"Provider": "TypeSafe",
"Model": "jev-latest",
"Timeout": "00:01:00",
"MaxRetries": 2,
"InitialBackoff": "00:00:00.500",
"MaxRetryDelay": "00:00:30",
"Jitter": true
},
"OpenRouter": {
"Provider": "OpenRouter",
"MaxRetries": 0
}
}
Pass the section to AddSystemOneDecisionClient. The first call binds Minos to the default client, and the second
binds OpenRouter to a client keyed openrouter.
// Each client reads its own section.
public static void AddFromConfiguration(IHostApplicationBuilder builder)
{
builder.Services.AddSystemOneDecisionClient(builder.Configuration.GetSection("Minos"));
builder.Services.AddSystemOneDecisionClient("openrouter", builder.Configuration.GetSection("OpenRouter"));
}
The keys for the other providers are Path and Capabilities, with the capabilities in a section of their
own. QuestionKinds is a comma-separated list. A capability you leave out keeps the preset's
default.
{
"Local": {
"Provider": "Custom",
"BaseAddress": "http://localhost:8080/",
"Path": "api/v1/systemone",
"Model": "local-model",
"Capabilities": {
"QuestionKinds": "Noul, Choice",
"MaxQuestions": 16
}
}
}
// Provider, Path and Capabilities bind like any other option.
public static void AddProvidersFromConfiguration(IHostApplicationBuilder builder)
{
builder.Services.AddSystemOneDecisionClient("local", builder.Configuration.GetSection("Local"));
}
Local sends no key at all.
Things to know when you bind:
- Time spans are written
hh:mm:ss, with fractions of a second after a dot, asInitialBackoffshows. - Keep the key out of
appsettings.json. LeaveApiKeyunset to use the provider's variable, such asTYPESAFE_API_KEY, or supply it from user secrets or an environment variable. The setting isMinos:ApiKey, and as an environment variable the colon becomes a double underscore,Minos__ApiKey. - A delegate registered later overrides bound values. Calling
AddSystemOneDecisionClient(options => ...)after binding changes only the properties it sets. - A value the binder cannot convert, such as
"MaxRetries": "abc", fails with the binder'sInvalidOperationException, not with anOptionsValidationException. A value that converts but is out of range, such as"MaxRetries": 11, is checked like any other, as the next section says. - Binding uses no reflection, so it works under Native AOT without any setup.
- Changes are not picked up. The client is a singleton that reads its options once, so a change to the configuration after it is built has no effect.
dotnet user-secrets set "Minos:ApiKey" "your-key"
Checking the options at start-up
Each registration checks its options when the host starts, so a wrong value stops the program at launch instead of on
the first request. It runs SystemOneDecisionClientOptions.Validate(), the same check the client's constructor runs,
and the failure is an OptionsValidationException that carries the core's own message. That covers a missing API key,
an invalid base address, MaxRetries outside 0 to 10, a backoff out of range, a blank model, and an invalid Timeout.
It covers the provider settings too: a provider with no model, a missing BaseAddress for Custom, and a
Capabilities override that allows no question kind.
// Invalid options fail here, when the host starts, not on the first request.
public static async Task<string> TryStartAsync(IHost host)
{
try
{
await host.StartAsync();
return "started";
}
catch (OptionsValidationException exception)
{
return $"Minos is misconfigured: {string.Join(' ', exception.Failures)}";
}
}
Without a host, with a plain BuildServiceProvider(), there is no start-up step, so the same exception comes when the
client is first resolved.
Some things still need valid options, an API key included:
- Creating the client's named
HttpClientfromIHttpClientFactoryyourself. It reads the same options, so a call such asCreateClient("Minos.NET")fails with the same exception when they are invalid. - A test host that replaces the client. If the application registers its own
IDecisionClientbeforeAddSystemOneDecisionClient, the application's client wins, but the options are still validated. A test that swaps the client needs a placeholder key, for exampleAddSystemOneDecisionClient(options => options.ApiKey = "test"), or aMinos:ApiKeysetting.
What gets registered
- One singleton per registration. It is a
SystemOneDecisionClient, wrapped in any stages you added to the registration's builder, and is built once, when the container first resolves it. The client reads its named options then. The default client reads the default options, and a keyed client reads the options named by its key. The client logs through the container'sILoggerFactory, as Logging, traces and metrics describes. Its own retries wait on the container'sTimeProviderwhen one is registered. - A repeat call for the same name adds its delegate, and the delegates run in order. It returns the same builder, so stages from either call land on one pipeline, and it does not register a second client.
- Your own registration wins.
AddSystemOneDecisionClientregisters the client withTryAdd, so anIDecisionClientyou registered first stays.AddSystemOneDecisionClientstill sets up the namedHttpClientand the options in that case. - Disposal. The container disposes the client with the provider. The
HttpClientbelongs to the factory and is left alone.
The HttpClient from the factory
The client's HttpClient comes from IHttpClientFactory. It is named Minos.NET, or Minos.NET: followed by
the key for a keyed client. You rarely need the name, except to change what is behind it in a test.
- Its handler. The primary handler is a
SocketsHttpHandlerthat recycles its connections every two minutes, so a change in DNS is picked up. The factory never rotates it, because the singleton keeps itsHttpClientfor life. - Its settings.
SystemOneDecisionClient.ConfigureHttpClientgives theHttpClientits base address, the per-attemptTimeoutand theMinos.NETUser-Agent. - Its handlers. Add your own through the
HttpClientproperty of the builderAddSystemOneDecisionClientreturns. A handler sees every request and every retry.ConfigureHttpClientDefaultsadds a handler to everyHttpClientthe factory makes, the Minos clients included, whether you call it before or afterAddSystemOneDecisionClient.
// A handler of your own sees every request the client sends, and every retry.
public sealed class TraceHeaderHandler : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Add("X-Trace", "inbox");
return base.SendAsync(request, cancellationToken);
}
}
public static class HandlerRegistration
{
// The HttpClient property of the builder AddSystemOneDecisionClient returns is the client's HttpClient builder, so handlers
// are added the usual way.
public static IHttpClientBuilder AddTracedDecision(IServiceCollection services, string apiKey)
{
services.AddTransient<TraceHeaderHandler>();
return services
.AddSystemOneDecisionClient(options => options.ApiKey = apiKey)
.HttpClient.AddHttpMessageHandler<TraceHeaderHandler>();
}
// ConfigureHttpClientDefaults adds a handler to every HttpClient the factory makes, the Minos clients included.
public static void AddTracedEverywhere(IServiceCollection services, string apiKey)
{
services.AddTransient<TraceHeaderHandler>();
services.ConfigureHttpClientDefaults(defaults => defaults.AddHttpMessageHandler<TraceHeaderHandler>());
services.AddSystemOneDecisionClient(options => options.ApiKey = apiKey);
}
// To keep a defaults handler off the Minos client, clear the handlers of its builder. This also clears any you
// added through that builder, so add those inside the delegate, after the Clear.
public static IHttpClientBuilder AddDecisionWithoutDefaultHandlers(IServiceCollection services, string apiKey)
{
services.AddTransient<TraceHeaderHandler>();
services.ConfigureHttpClientDefaults(defaults => defaults.AddHttpMessageHandler<TraceHeaderHandler>());
return services
.AddSystemOneDecisionClient(options => options.ApiKey = apiKey)
.HttpClient.ConfigureAdditionalHttpMessageHandlers((handlers, _) => handlers.Clear());
}
// When a handler of yours retries, such as a standard resilience handler, turn the client's own retries off,
// so the two do not multiply.
public static void AddWithOwnRetries(IServiceCollection services, string apiKey)
=> services.AddSystemOneDecisionClient(options =>
{
options.ApiKey = apiKey;
options.MaxRetries = 0;
});
}
Host-wide defaults
ConfigureHttpClientDefaults is how a host applies one setup to every HttpClient, and it reaches the Minos clients
too. Two things in it do not:
- A primary handler set in the defaults is replaced. The Minos client always uses its own
SocketsHttpHandler, whether the defaults are registered before or afterAddSystemOneDecisionClient. A defaults primary handler never sees the client's requests. - The defaults' loggers are removed from the Minos clients, in either order, as the next section says.
The usual cause of trouble is a defaults handler that retries, most often Aspire ServiceDefaults'
AddStandardResilienceHandler(). Its retries multiply with the client's own, so set MaxRetries = 0, as the snippet
does and as the cost of retrying explains. It has time-outs of its own too,
and the advice is to keep them at or above Timeout. That last point is advice, not something the tests here check.
To keep a defaults handler off the Minos client, clear the additional handlers of its builder, as the last method in the
snippet does. This also removes every handler you added yourself through that builder, so add yours in the same call
with ConfigureAdditionalHttpMessageHandlers, after the Clear(), if you want them.
The factory's request logs
The factory has request logs of its own, with the categories System.Net.Http.HttpClient.*. They are turned off for
the Minos clients. The client already logs each operation and each retried attempt, in the Minos.DecisionClient
category, and the factory's logging handlers add work to every request. Call AddDefaultLogger() on the HttpClient
property of the builder AddSystemOneDecisionClient returns to bring the factory's logs back.
Without a logging provider, or with the Minos log levels turned off, the client logs nothing. Logging, traces and metrics lists the events and their levels.
OpenAI
Minos.NET.OpenAI has AddOpenAIDecisionClient, which registers an OpenAIDecisionClient for OpenAI's Decisions API.
It works as AddSystemOneDecisionClient does: the same six overloads, default and keyed, options from code or
configuration, checked at start-up, over a named HttpClient from IHttpClientFactory. It returns an
OpenAIDecisionClientServiceBuilder, whose HttpClient property is the builder of that HttpClient.
The client's own retries wait on the container's TimeProvider when one is registered.
dotnet add package Minos.NET.OpenAI
// TypeSafe as the default client, and OpenAI beside it under the key "openai". Each key comes from configuration;
// without one, the OpenAI client reads OPENAI_API_KEY, and the host fails at start-up when there is none.
public static OpenAIDecisionClientServiceBuilder AddBothProviders(IHostApplicationBuilder builder)
{
builder.Services.AddSystemOneDecisionClient(options => options.ApiKey = builder.Configuration["TypeSafe:ApiKey"]);
return builder.Services.AddOpenAIDecisionClient(
"openai", options => options.ApiKey = builder.Configuration["OpenAI:ApiKey"]);
}
- The key is required. Without
ApiKeythe client readsOPENAI_API_KEY, and the host fails at start-up when neither is set.BaseAddressfalls back onOPENAI_BASE_URL, thenhttps://api.openai.com/v1/. It is the versioned API root, as in OpenAI's official SDKs: requests go todecisionsunder it. - Configuration binds the same way. The keys are the property names of
OpenAIDecisionClientOptions, such asApiKey,ModelandCapabilities:QuestionKinds. - A name holds one provider. Registering the same name with
AddSystemOneDecisionClientandAddOpenAIDecisionClientthrowsInvalidOperationException. Use different names, as the snippet does, to have both. - Its provider name is
openai, in its logs, spans and metrics.
OpenAI has the options, what OpenAI cannot express, and refusals.
The neutral AddDecisionClient
AddDecisionClient is in Minos.NET.DependencyInjection, and it knows no provider. It takes a factory that makes the
IDecisionClient, and the container calls it once, when it first resolves the client. Use it for a client of your
own, a fake in a test host, or a client from another protocol package. It has two overloads,
AddDecisionClient(factory) for the default client and AddDecisionClient(name, factory) for a keyed one.
// AddDecisionClient takes a factory and knows no provider. Use it for a client of your own, or one from another
// protocol package. It returns the neutral DecisionClientServiceBuilder: Use stages, but no HttpClient property.
public static DecisionClientServiceBuilder AddOwnClient(IServiceCollection services)
=> services.AddDecisionClient(provider => new AlwaysUrgent());
It returns a DecisionClientServiceBuilder, which has Use and the Use… extensions for
stages and a Name, and nothing else. It has no HttpClient property,
because the factory may make a client with no HttpClient. That property, the named HttpClient from
IHttpClientFactory, the options and the start-up check belong to AddSystemOneDecisionClient, whose
SystemOneDecisionClientServiceBuilder derives from DecisionClientServiceBuilder and adds HttpClient.
A name holds one provider's registration. Registering the same name with AddSystemOneDecisionClient and with
AddDecisionClient throws InvalidOperationException, so two registrations cannot disagree about what the name is. A
repeat call of the same kind returns the first call's builder.
Using Minos without the package
AddSystemOneDecisionClient is a convenience. SystemOneDecisionClient.ConfigureHttpClient(httpClient, options)
configures any HttpClient the way the client configures its own: it applies the per-attempt Timeout, the base
address when the HttpClient has none, and the User-Agent. It needs no API key and leaves the handler alone. Call it
before the client sends a request, for instance on a named client of your own.
// A named HttpClient of your own, set up the way AddSystemOneDecisionClient sets up its client.
public static void AddMinosHttpClient(IServiceCollection services, SystemOneDecisionClientOptions options)
=> services.AddHttpClient("minos")
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2) })
.SetHandlerLifetime(Timeout.InfiniteTimeSpan)
.ConfigureHttpClient(http => SystemOneDecisionClient.ConfigureHttpClient(http, options));
public static SystemOneDecisionClient Create(IHttpClientFactory factory, SystemOneDecisionClientOptions options)
=> new(factory.CreateClient("minos"), options);
The SystemOneDecisionClient that Create makes still needs an API key. ConfigureHttpClient sets up the HttpClient
only, so the key comes from options.ApiKey or the environment variable, as for any client.
The SocketsHttpHandler and the infinite handler lifetime do for this client what the package does for its own: a
long-lived SystemOneDecisionClient keeps one handler for life, so the connections recycle themselves and the factory
does not rotate the handler.
Next
- The client pipeline: the stages around a registered client, and how to add your own.
- Logging, traces and metrics: what a registered client reports about each call.
- Client and errors: the options a registration sets, and every
DecisionError. - Typed evaluation: the calls a resolved client makes.
- Performance: what a call costs, measured, including through dependency injection.