Build agents
Model providers
A provider registration opens a model route in the host. An agent definition selects that route by name. The definition never contains the credential.
Mental model: register once, select per agent
Section titled “Mental model: register once, select per agent”flowchart LR
accTitle: Model provider selection flow
accDescr: Host configuration registers a named provider, each agent binds that provider and model, and the resolved chat client performs the request.
C["Host configuration<br/>credential and endpoint"] --> R["Use... registration"]
R --> N["Provider registry<br/>stable provider name"]
D["Agent definition<br/>ModelBinding"] --> N
N --> P["IChatClient pipeline"]
P --> M["Selected provider and model"]
This separation has two useful effects. You can register several providers in one process. You can also move an agent to another provider without moving a secret into the database or the console.
| Package | Registration | Provider name in ModelBinding |
Surface |
|---|---|---|---|
Tracon.OpenAI |
UseOpenAI() |
openai |
OpenAI Chat Completions |
Tracon.OpenAI |
UseOpenAI() |
openai-responses |
OpenAI Responses |
Tracon.OpenAI |
UseOpenAICompatible(name, …) |
your name |
Compatible Chat Completions |
Tracon.Anthropic |
UseAnthropic() |
anthropic |
Anthropic Messages |
Tracon.Google |
UseGoogle() |
google |
Gemini Developer API; speech with UseGoogleSpeech() |
Tracon.Azure |
UseAzureOpenAI() |
azure-openai |
Azure OpenAI Chat Completions |
The Tracon meta package includes Tracon.OpenAI. Add the Anthropic, Google,
or Azure package only when the host uses it.
Register providers
Section titled “Register providers”Read credentials from configuration. Put their values in user-secrets, environment variables, or a secret manager.
dotnet user-secrets set "Tracon:Providers:OpenAI:ApiKey" "<key>"dotnet user-secrets set "Tracon:Providers:Anthropic:ApiKey" "<key>"dotnet user-secrets set "Tracon:Providers:Google:ApiKey" "<key>"dotnet user-secrets set "Tracon:Providers:AzureOpenAI:ApiKey" "<key>"Register only the providers for which your host has complete configuration:
using Tracon;
var tracon = builder.AddTracon() .UseOpenAI(builder.Configuration.GetSection(OpenAIProviderOptions.SectionName)) .UseAnthropic(builder.Configuration.GetSection(AnthropicProviderOptions.SectionName)) .UseGoogle(builder.Configuration.GetSection(GoogleProviderOptions.SectionName)) .UseAzureOpenAI(builder.Configuration.GetSection(AzureOpenAIProviderOptions.SectionName));Each options type validates at startup. Missing required configuration stops the host before the first run. A named compatible endpoint can be keyless, and Azure can use a credential factory instead of an API key.
Live voice providers
Section titled “Live voice providers”OpenAI can also register an ILiveVoiceProvider, which is a different kind of
registration again: it opens an outbound connection billed by the second, so it is a
separate call rather than a flag on UseOpenAI().
tracon .UseOpenAI(builder.Configuration.GetSection(OpenAIProviderOptions.SectionName)) .UseOpenAILive(builder.Configuration.GetSection(OpenAILiveOptions.SectionName)) .UseLiveVoice();The provider runs the whole spoken conversation and carries the audio straight to the
browser; your agents handle the work it delegates. The API key stays on the server.
The tenant’s egress policy and its own openai binding apply to a live session as to
a model call; see tenant policy and credentials.
See provider-hosted live voice for what
that trade gives up.
Image generation providers
Section titled “Image generation providers”OpenAI, Azure OpenAI, and Google can also register an IImageGenerator. These are
separate from chat-model registrations because an image model or Azure deployment is
not safely inferred from an agent’s chat model.
tracon .UseOpenAIImages(options => { options.Enabled = true; options.Model = "gpt-image-1"; });
// Azure uses an image deployment name.// tracon.UseAzureOpenAIImages(options => options.Model = "image-deployment");
// Google uses a provider image model. It does not support WIDTHxHEIGHT in this surface.// tracon.UseGoogleImages(options => options.Model = "gemini-2.5-flash-image");Each extension shares the authenticated client factory already created by UseOpenAI,
UseAzureOpenAI, or UseGoogle; it adds no second credential path or package. The
extensions register generators by provider name. When more than one is registered,
set Tracon:Images:Provider to openai, azure-openai, or google to select
the generator and matching price table. An unkeyed IImageGenerator that your
application registers remains the fallback for a custom provider.
UseOpenAICompatible() does not imply image support. Register a supported image
extension only after you verify that its provider API supports the selected model.
A generated image is billed by the piece or by output tokens, not by the chat model’s
tokens. generate_image records the measurement on its tool call with the provider
and model that made the image, and the amount joins the run’s non-token total beside the
token cost (Report what a tool spent beyond tokens).
The speech tools of Tracon.Voice record theirs the same way.
Google generates images through the generateContent surface, so the model you
name must be one that supports it — a Gemini image model. The older Imagen
predict surface is not served by the Gemini API, and a model that only
supports it fails every request. Two option limits follow from that surface:
Count is passed as the candidate count and an image model commonly returns one
image whatever you ask for, and a requested media type is passed to the model
and then checked — if the model answers in another format the call fails
rather than handing back bytes that do not match the request. Omit the media
type to accept the model’s own choice.
Then bind an agent to one stable provider name:
tracon.AddAgent(new AgentDefinition{ Name = "support", Instructions = "Resolve support requests. State uncertainty clearly.", Model = new ModelBinding { Provider = AnthropicProviderNames.Anthropic, Model = "your-current-model-name", MaxOutputTokens = 2_048, },});Use a current model name from the provider. Tracon does not pin one for you.
Provider registration is host configuration, not a console operation. The console
never accepts or displays provider secrets. Its Models screen shows the catalog and
cached health for providers that the host already registered. The agent editor can
select a provider and model, but it does not currently edit ProviderSettings; set
vendor-specific keys in code or through the management HTTP API.
OpenAI and compatible endpoints
Section titled “OpenAI and compatible endpoints”UseOpenAI() always registers both official OpenAI routes. The
OpenAIProviderOptions.EnableResponsesSurface option does not change this behavior.
That option applies only to compatible endpoints.
tracon.UseOpenAICompatible("ollama", options =>{ options.Endpoint = new Uri("http://localhost:11434/v1"); // A local server can run without an API key.});A compatible registration creates only the Chat Completions route by default. Set
EnableResponsesSurface = true only if the server implements /v1/responses. The
second provider is then named {name}-responses.
The name must match [a-z0-9][a-z0-9-]{0,31}. The names openai and
openai-responses are reserved. An absolute Endpoint is required. A compatible
server with no key is valid; Tracon supplies only the fixed placeholder required
by the OpenAI client library.
Sampling and tool-use fields
Section titled “Sampling and tool-use fields”Beyond Temperature, TopP, MaxOutputTokens, ReasoningEffort, and
ResponseFormat, a ModelBinding sets eight more request fields. They belong to the
definition, so a stored version, an experiment variant, and a replay carry them. A field
left empty sends nothing, and the provider default applies.
| Field | Type | What it does |
|---|---|---|
Seed |
long? |
Asks for repeatable sampling |
TopK |
int? |
Limits sampling to the k likeliest tokens; above zero |
StopSequences |
IReadOnlyList<string>? |
Ends the answer when one appears; no entry may be empty |
FrequencyPenalty |
float? |
Lowers the odds of a token that already appeared often |
PresencePenalty |
float? |
Lowers the odds of a token that already appeared |
ToolMode |
string? |
auto, none, required, or required:<tool> |
AllowMultipleToolCalls |
bool? |
Whether the model may ask for several tool calls in one turn |
ReasoningOutput |
string? |
None, Summary, or Full: how much reasoning the response carries |
Model = new ModelBinding{ Provider = OpenAIProviderNames.ChatCompletions, Model = "your-current-model-name", Seed = 42, StopSequences = ["END"], ToolMode = "required:get_order_status",},ToolNames = ["get_order_status"],A provider sends only some of them. The model adapters of each API surface drop the
fields they have no wire member for, without an error. A field that never reaches the model
looks exactly like a field that worked, so Tracon refuses it instead. A v marks a field the
surface sends:
| Field | openai |
openai-responses |
anthropic |
google |
azure-openai |
|---|---|---|---|---|---|
Seed |
v | v | v | ||
TopK |
v | v | |||
StopSequences |
v | v | v | v | |
FrequencyPenalty |
v | v | v | ||
PresencePenalty |
v | v | v | ||
ToolMode |
v | v | v | v | v |
AllowMultipleToolCalls |
v | v | v | v | |
ReasoningOutput |
v | v | v |
- A
UseOpenAICompatible()registration follows the same two columns: its Chat Completions provider is theopenaicolumn, and its{name}-responsesprovider is theopenai-responsescolumn. - The table is what the API surface sends, not what a model accepts. A model can still reject
a field its surface sends: some OpenAI reasoning models answer a request that carries
stopwith400 unsupported_parameter. That error is the provider’s own and comes back as the run’s failure. - A definition that sets a field the provider does not send fails to compile. The error
names the field, the provider, and the providers that send it.
POST /api/agents/validatereports it assampling_field_unsupportedat the field’s path (for examplemodel.seed), and saving the definition returns400. Nothing runs without the field. - A
Fallbackslink gets the request options of the primary call, so it is held to the same table: a fallback on a provider that dropsSeedmakes the definition invalid. - A provider you write sends none of these fields until it states them. Implement
ISamplingSupportnext toIModelProviderand return theSamplingFieldsits surface sends. The table above is what the shipped adapters send; Tracon measures it again when it upgrades a provider SDK. ToolModerequiredmakes the model call a tool;required:<tool>makes it call that one, and the tool must be in the definition’sToolNames. The requirement holds for the first model call of a turn only. The tool-call loop lifts it for the calls that follow, because the model could never answer otherwise. A request with no tools carries no tool mode. A value other than the four above fails to compile.ReasoningOutputgoes withReasoningEffort, and either stands alone. A value outsideNone,Summary, andFullfails to compile, as an unknown effort does. Onanthropicit sets how the thinking is shown, and with noReasoningEffortit turns adaptive thinking on by itself; it is not sent whenReasoningEffortisNoneor when ananthropic.thinking.*provider setting is set. Onopenai-responsesit becomes the reasoning summary.AllowMultipleToolCallsonanthropictravels in the request’stool_choice. A definition that sets it with noToolModesendsautoas the mode, which is what the provider does without one.StopSequenceshas a limit on the number of entries that depends on the provider. Tracon does not check it; the provider’s own error comes back when it is exceeded.- An agent extension and the options of one run cannot set these fields. See Use any Microsoft Agent Framework feature.
Hosted tools
Section titled “Hosted tools”A web search, a code interpreter, or a file search can run on the provider’s side, inside the
model call. A host registers one with AddHostedTool and a definition selects it in ToolNames;
see Hosted tools. Each API surface runs only some kinds, and the adapter
drops the others without an error, so a provider states what its surface runs. A v marks a kind
the surface runs:
| Kind | openai |
openai-responses |
anthropic |
google |
azure-openai |
|---|---|---|---|---|---|
WebSearch |
v | v | v | ||
CodeInterpreter |
v | v | v | ||
FileSearch |
v |
- The table is the request body each adapter writes (2026-10-20), as the sampling table above is. A model can still refuse a tool its surface sends.
- A definition that selects a kind the provider does not run fails to compile, and
POST /api/agents/validatereports it ashosted_tool_unsupportedat the path of the tool name. AFallbackslink is held to the same table. - Chat Completions maps only a web search, to a field that a model without search answers with
400. Tracon does not offer the kind on that surface. - A
UseOpenAICompatible()registration follows the same two OpenAI columns. An endpoint that is not OpenAI can still reject the tool. - A provider you write runs no kind until it states it. Implement
IHostedToolSupportnext toIModelProviderand return theHostedToolKindsits surface runs. - Each call is billed by the provider. Set
Tracon:Pricing:HostedTools:{provider}:{kind}:PerCall.
Provider-specific settings
Section titled “Provider-specific settings”Portable settings live directly on ModelBinding: Temperature, TopP,
MaxOutputTokens, ReasoningEffort, ResponseFormat, and the fields above.
Vendor-only settings live in ProviderSettings. Unknown keys fail compilation instead of
being ignored.
using System.Text.Json;
var anthropicBinding = new ModelBinding{ Provider = AnthropicProviderNames.Anthropic, Model = "your-current-claude-model", MaxOutputTokens = 64_000, ProviderSettings = new Dictionary<string, JsonElement>( StringComparer.OrdinalIgnoreCase) { [AnthropicProviderNames.EffortSetting] = JsonSerializer.SerializeToElement("high"), [AnthropicProviderNames.ThinkingModeSetting] = JsonSerializer.SerializeToElement("adaptive"), [AnthropicProviderNames.PromptCachingSetting] = JsonSerializer.SerializeToElement(true), [AnthropicProviderNames.PromptCachingTtlSetting] = JsonSerializer.SerializeToElement("1h"), [AnthropicProviderNames.PromptCachingPlacementSetting] = JsonSerializer.SerializeToElement("system"), },};
var googleBinding = new ModelBinding{ Provider = GoogleProviderNames.Google, Model = "your-current-gemini-model", ProviderSettings = new Dictionary<string, JsonElement>( StringComparer.OrdinalIgnoreCase) { [GoogleProviderNames.SafetyHarassmentSetting] = JsonSerializer.SerializeToElement("BLOCK_ONLY_HIGH"), [GoogleProviderNames.ThinkingBudgetTokensSetting] = JsonSerializer.SerializeToElement(512), [GoogleProviderNames.ThinkingIncludeThoughtsSetting] = JsonSerializer.SerializeToElement(true), },};Anthropic supports six keys. Each one is off or absent by default, so an existing agent sends the same request as before:
| Key | Values | Sent as |
|---|---|---|
anthropic.effort |
low, medium, high, xhigh, max |
output_config.effort |
anthropic.thinking.mode |
enabled, adaptive |
thinking.type |
anthropic.thinking.budgetTokens |
integer above zero | thinking.budget_tokens (mode enabled only) |
anthropic.promptCaching |
true, false |
prompt caching on or off |
anthropic.promptCaching.ttl |
5m, 1h |
cache_control.ttl |
anthropic.promptCaching.placement |
request, system |
where the cache breakpoint goes; system with no instructions falls back to request |
- Effort controls how many tokens the model spends, thinking included, and it is the main cost lever on current models. The values come from the Anthropic SDK, so a level the SDK adds is accepted without a Tracon release. A model that does not support a level rejects the request.
- Thinking.
adaptivelets the model decide how much to think;effortsets the depth.enabledneedsbudgetTokens, and current Claude models reject a budget with400. A budget on its own still meansenabled, as before. Settingadaptivetogether with a budget is an error. Withenabled, the budget must be smaller than the output limit, and temperature must be absent or1. - Prompt caching.
request(the default) caches the whole request.systemmarks the end of the agent’s instructions instead, so requests that carry a different document after the same instructions read the same cached prefix; withrequest, each of them writes an entry the next one cannot read. A request with no instructions has nothing to mark, sosystemthen sends therequestbreakpoint. A1hentry costs more to write and pays off when the same prefix returns more than five minutes apart.ttlandplacementwithoutanthropic.promptCaching = trueare an error, not a silent no-op. - Thinking tokens count against the output limit. At
higheffort or above, setMaxOutputTokensexplicitly. Without it, the request goes out withDefaultMaxOutputTokens(4096), and the validation report warns when the catalog states a higher maximum for the model. See Truncated output.
ModelBinding.ReasoningEffort maps differently on Anthropic: it becomes adaptive
thinking (thinking.type = adaptive) plus output_config.effort (low, medium,
high, xhigh), which every current Claude model accepts. A model that predates
adaptive thinking (for example claude-haiku-4-5) rejects that form with 400:
select it with anthropic.thinking.mode = enabled and anthropic.thinking.budgetTokens.
When anthropic.thinking.mode or budgetTokens is set, the provider setting wins and
ReasoningEffort is not sent.
Provider settings do not reach a Fallbacks link: a link gets the binding’s
portable fields only.
Google supports five google.safety.* thresholds plus
google.thinking.budgetTokens and google.thinking.includeThoughts. The thinking
budget range is -1..65535; -1 lets the model decide and 0 disables thinking.
Valid safety values are BLOCK_LOW_AND_ABOVE, BLOCK_MEDIUM_AND_ABOVE,
BLOCK_ONLY_HIGH, BLOCK_NONE, and OFF. A safety-filtered empty response becomes a
failed run with error type content_filtered.
OpenAI and Azure OpenAI support one ProviderSettings key each, and both reject every
other key:
| Key | Values | Sent as |
|---|---|---|
openai.strictJsonSchema |
true, false (default) |
strict: true on the response format |
azure-openai.strictJsonSchema |
true, false (default) |
strict: true on the response format |
The OpenAI key covers openai, openai-responses, and every UseOpenAICompatible()
registration, whatever its name; a server that does not know strict mode can refuse the
request. The setting reaches the response format of an agent whose ResponseFormat is a
JSON schema, and not its tools. See
Strict mode. A key written for another
provider, such as anthropic.promptCaching left over after a provider switch, is
rejected too. POST /api/agents/validate reports it as invalid_setting at
model.providerSettings, saving the definition returns 400, and an agent defined in
code fails compilation. The console does not edit providerSettings; remove a stray
key through the HTTP API or in code.
A refusal names the key and, for a setting with a fixed set of values, the accepted values. It never repeats the value you wrote: a value can be a credential pasted into the wrong field, and the message reaches logs and run records.
Azure OpenAI uses deployments
Section titled “Azure OpenAI uses deployments”For azure-openai, ModelBinding.Model is the deployment name, not the base model
name. A wrong deployment produces an Azure error even when provider health is good.
The provider calls the resource’s v1 API ({endpoint}/openai/v1/) with the plain
OpenAI client; the deployment name travels in the request’s model field. The
resource must support the v1 API.
Use an API key, or supply an Azure Core credential factory. Managed identity wins if both are present.
// Add Azure.Identity to the consumer project for DefaultAzureCredential.using Azure.Identity;
tracon.UseAzureOpenAI(options =>{ options.Endpoint = new Uri("https://my-resource.openai.azure.com/"); options.CredentialFactory = static () => new DefaultAzureCredential(); options.DefaultDeployment = "support-production";});Tracon.Azure depends on Azure.Core, not Azure.Identity. The consumer chooses
the credential implementation. The Entra token is sent only over HTTPS. Azure OpenAI
Responses, On Your Data, and Azure AI Foundry Agents are not exposed by this provider.
Per-tenant credentials (BYOK)
Section titled “Per-tenant credentials (BYOK)”By default every tenant shares the credential a Use...() call registered at startup.
A multi-tenant host can instead let each tenant bring its own key — its usage and its
bill stay separate from every other tenant’s.
BYOK is an optional provider capability, not a universal contract. A provider
opts in by implementing ITenantCredentialModelProvider in addition to
IModelProvider; the four built-in providers (OpenAI, Anthropic, Google, Azure
OpenAI) all do. A provider that does not implement it simply never sees a tenant
credential, and Tracon never falls back to the setup-time key on its behalf: a
tenant binding saved against a provider that does not support BYOK fails every
run with a stable tenant_provider_refused error (class TenantProviderRefused)
instead of silently billing the tenant’s traffic to the host’s own account. A run
recorded before that error type existed carries provider_credential_unsupported.
A tenant’s binding stores only the name of a configuration key, never the value:
dotnet user-secrets set "Tracon:ProviderKeys:Acme:OpenAI" "<acme's key>"curl -X PUT "http://localhost:5081/tracon/api/tenants/acme/providers/openai" \ -H "Authorization: Bearer $TRACON_TOKEN" \ -H "Content-Type: application/json" \ -d '{"apiKeyConfigurationName": "Tracon:ProviderKeys:Acme:OpenAI"}'The name must sit under the configured prefix (default Tracon:ProviderKeys:) and
inside the tenant’s own segment — Tracon:ProviderKeys:acme: for tenant acme; the
segment matches without regard to case. A flat name directly under the prefix belongs
to the default tenant. A name outside that space is rejected with 400 both when it is
saved and again when it is resolved, so one tenant cannot bind another tenant’s key.
Managing a tenant other than your own requires platform authority: an API key with the
PlatformAdmin scope, the static token, or the TraconPolicies.PlatformAdmin
policy. GET /api/tenants/acme/providers reports whether the name currently
resolves to a value (resolved: true/false) — never the value itself. A tenant with
no binding for a provider keeps using the global setup-time credential; nothing changes
until a binding is written. A binding that exists but resolves to no value does not
fall back to the global credential silently — the run fails with a clear error instead,
so a misconfigured tenant is never billed to the wrong account.
Restrict which providers a tenant’s agents may call with an egress policy:
curl -X PUT "http://localhost:5081/tracon/api/tenants/acme/egress" \ -H "Authorization: Bearer $TRACON_TOKEN" \ -H "Content-Type: application/json" \ -d '{"allowedProviders": ["openai", "anthropic"]}'A tenant with no saved policy is unrestricted — saving one is an additive
restriction, not a default wall. An agent definition naming a provider outside the
saved list is rejected at compile time, before any request reaches the network;
the same check also protects PUT .../providers/{provider} itself, so both surfaces
agree. The console’s Settings screen exposes both panels; no field there accepts a
credential value, only a configuration key name and an optional endpoint override.
The same policy and the same bindings cover speech providers. A text-to-speech or
speech-to-text call is refused when the policy does not name the provider name the
engine reports (elevenlabs for the built-in client), and a tenant binding for that
name reaches only an engine that declares SupportsTenantCredentials; any other engine
is refused for that tenant rather than billed to the host. The console lists speech
providers next to the model providers, by the name a policy uses. See
tenant policy and credentials for speech.
A provider without a package
Section titled “A provider without a package”AddModelProvider() registers a provider Tracon does not ship a package for.
Implement IModelProvider — a stable Name, a Models catalog, and
CreateChatClient(ModelBinding) returning a raw IChatClient — and register it. If
the provider should also accept a per-tenant credential (see Per-tenant
credentials above), additionally implement
ITenantCredentialModelProvider, whose CreateChatClient(ModelBinding, ModelProviderCredential) takes a non-null credential; a provider that does not
implement it is never called with one — Tracon fails the run instead of
falling back to the setup-time key on its behalf:
public sealed class ContosoModelProvider(HttpClient httpClient, string setupApiKey) : ITenantCredentialModelProvider{ public string Name => "contoso";
public IReadOnlyList<ModelDescriptor> Models { get; } = [new ModelDescriptor { Name = "contoso-large", SupportsTools = true }];
public IChatClient CreateChatClient(ModelBinding binding) => Create(binding, setupApiKey);
public IChatClient CreateChatClient(ModelBinding binding, ModelProviderCredential credential) => Create(binding, credential.ApiKey);
private ContosoChatClient Create(ModelBinding binding, string apiKey) { // Contoso supports no ProviderSettings key, so every key is rejected. ModelProviderSettings.Validate(binding, Name, []); return new ContosoChatClient(httpClient, binding.Model, apiKey); }}
services.AddHttpClient();
tracon.AddModelProvider(services => new ContosoModelProvider(services.GetRequiredService<HttpClient>(), setupApiKey));A provider that never wants to support BYOK simply implements only IModelProvider
and stops there — it still works with the setup-time credential for every tenant.
A complete, runnable version of this provider — including per-tenant credentials
and provider-settings validation — lives in the repository at
samples/Tracon.Samples.CustomModelProvider. It takes Tracon by
PackageReference only, which is what makes it a proof rather than an
illustration.
The runtime contract
Section titled “The runtime contract”The rules below are what the registry and the compile path actually rely on. An
implementation that breaks one still compiles and still passes its own unit
tests; it misbehaves under a real deployment. Verify them by deriving
ModelProviderContract from the
Tracon.Testing.Contracts.Xunit package rather than by reading carefully.
If your provider supports BYOK, derive its opt-in
ModelProviderCredentialContract too. Its required assertion must inspect the
provider request boundary (for example a recording transport), not merely a
different client object; otherwise a new wrapper can still send the setup-time
key and bill the wrong tenant. Derive ModelProviderSettingsContract for every
provider, including one that supports no settings key.
Lifetime and threading. Your provider is a singleton. It must not capture
a scoped service, and the provider set is fixed once the container is built.
CreateChatClient is called concurrently and must be thread-safe. If you
cache a client per credential in a ConcurrentDictionary, remember that
GetOrAdd may run its factory more than once for the same key under a race and
discard the extras — so building a client must be side-effect free.
Return a raw client. UseFunctionInvocation(), OpenTelemetry, the content
guard, the circuit breaker, the concurrency limiter, attachment resolution,
response caching, the fallback chain and content-filter detection are all added
by ModelProviderRegistry around whatever you return. Building any of them
yourself is not merely redundant: the registry places the content guard directly
above your client so that every turn of the tool-call loop is inspected. With
your own inner loop, the turn that carries a tool result back into the model runs
beneath the guard — the exact path prompt injection takes — while the reply text
and the tool-call count stay identical, so nothing else reveals it.
Tracon never disposes the client you return. It is built once per
compiled agent and held by it; the compiled agent implements neither
IDisposable nor IAsyncDisposable, and evicting one from the compile cache
drops the reference without disposing. You own the lifetime of what you return,
and it must tolerate never being disposed — which is why the shipped providers
return clients backed by a long-lived, shared SDK client.
Provider settings. Reject every ModelBinding.ProviderSettings key you do not
support. A provider that supports no key rejects them all. Call
ModelProviderSettings.Validate(binding, prefix, supportedKeys) from
CreateChatClient, and pass an empty list when you support none. Its exception is
the one the registry passes through with the key named. The registry masks the
message of any other exception thrown while a client is built, so a hand-rolled
check reaches the author as a generic provider failure. When you refuse a value of a
supported key yourself, name the key and the accepted values, not the value:
ModelProviderSettingsContract fails a provider whose message repeats it.
Default output limit. If your client sends an output-token limit of its own
when ModelBinding.MaxOutputTokens is empty, return a ModelOutputLimit from
GetService(typeof(ModelOutputLimit)) on the client you build for that binding,
with HighReasoningEffort = true when the binding’s settings ask for deep
reasoning. POST /api/agents/validate reads it through the whole pipeline and
warns when the limit is below the catalog’s MaxOutputTokens for the model. A
client that sends no limit returns nothing.
Naming. Name is matched against ModelBinding.Provider case-insensitively.
Registering two providers under one name is not last-one-wins: the host fails at
startup.
The catalog is not an allow list. See the next section.
Credentials. CreateChatClient(ModelBinding, ModelProviderCredential) — the
ITenantCredentialModelProvider overload — is called only when a tenant supplies a
credential and your provider implements that interface; without it, Tracon
never calls your provider with a tenant credential at all, and it does not fall back
to calling the plain CreateChatClient(ModelBinding) overload with the tenant’s
request either — the run fails closed with tenant_provider_refused
instead. If you do implement the interface, the key must never fall back to your
setup-time key: a tenant that supplied a credential is billed on it, or the call
fails. An endpoint may fall back, so a globally configured base address still
applies when a tenant overrides only its key.
How failures are classified
Section titled “How failures are classified”Tracon decides whether to move to the next Fallbacks link by reading the
exception’s type name and message text across the whole exception graph,
because the core holds no compile-time reference to any provider SDK’s exception
types. The one typed value it reads is HttpRequestException.StatusCode, which
belongs to .NET itself. What that means when you write a provider:
| Failure | Next fallback link is tried |
|---|---|
A timeout — a TimeoutException in the graph, or a message that says it timed out |
Yes |
OperationCanceledException with no timeout signal, while the caller’s token is cancelled |
No — the caller asked to stop |
Message carries HTTP 401 or HTTP 403 |
No — switching providers would hide a configuration mistake |
Any other permanent 4xx (every 4xx except 408 and 429), in an HTTP nnn message or in HttpRequestException.StatusCode |
No — the next provider gets the same request, so a bad body or an unknown model name fails there too |
Message carries 429, too many requests, or rate limit |
Yes |
Message carries HTTP 5xx |
Yes |
A transport/SDK-client type with no permanent 4xx (usually no status at all) |
Yes — treated as a connection failure |
| Anything else | No — the retry set is closed and positive |
Two consequences are worth stating plainly. A provider-side safety filter is
not an exception here: return an ordinary ChatResponse with
ChatFinishReason.ContentFilter and Tracon raises
TraconContentFilteredException itself. The same holds for the output limit:
report ChatFinishReason.Length, and Tracon raises
TraconOutputTruncatedException for a structured request or records a
tracon.output.truncated event for plain text. Throwing instead leaves the decision to
your exception: one with no readable HTTP status counts against the provider’s
circuit as a connection failure does, so a healthy provider can look like it is
failing. And a TraconException thrown
from CreateChatClient is wrapped as a compilation error naming the agent, while
every other exception propagates raw — so use it for configuration or binding
problems the host author can act on.
Model catalog is metadata, not permission
Section titled “Model catalog is metadata, not permission”Every provider options type has a Models collection. It drives the console model
picker, capability hints, and cost calculation. It is not an allow list. A definition
can use a model that is absent from the catalog.
The capability flags do not carry equal weight, and only two of them are gates:
| Flag | What it does |
|---|---|
SupportsStructuredOutput |
Enforced. An agent requesting JSON output fails compilation when the model is found in the catalog with this explicitly false. A model absent from the catalog is not checked. See Structured output |
SupportsSampling |
Enforced. null (the default) means unknown and checks nothing. When it is false, a definition that sets Temperature or TopP fails compilation, the validation report names the field as sampling_unsupported, and saving returns 400. Fallbacks links are checked too, because they get the same parameters |
MaxOutputTokens |
Read by the validation report: when a binding leaves its own limit empty and the provider’s default is lower, a structured or high-effort agent gets an output_truncation_risk warning |
SupportsTools |
Advisory metadata — nothing enforces it at run time |
SupportsStreaming |
Advisory metadata — nothing enforces it at run time |
SupportsReasoning |
Advisory metadata — nothing enforces it at run time |
The advisory flags still drive the console’s picker and capability hints, so an inaccurate catalog misleads a human even where it cannot fail a run.
Response caching
Section titled “Response caching”Enable it per agent. Tracon forces the cache key with three inputs beyond the messages and options themselves: the tenant, the sorted tool names, and the provider — a hit never crosses a tenant boundary and never lands on an agent with a different tool set, even when the prompt and instructions are otherwise identical.
builder.Services.AddDistributedMemoryCache(); // or a real distributed cache: Redis, SQL Server, ...
tracon.AddAgent(new AgentDefinition{ Name = "cached-support", Instructions = "Resolve support requests. State uncertainty clearly.", Model = new ModelBinding { Provider = AnthropicProviderNames.Anthropic, Model = "your-current-model-name", ResponseCache = new ResponseCacheSettings { Enabled = true, Lifetime = TimeSpan.FromMinutes(10) }, },});A hit skips the model call entirely: no token usage, no cost, and no new trace span
for that turn. It does not skip the tool-call loop — if the cached response
carries a tool call, the tool still runs; a hit is not a shortcut around side
effects. A run’s own usage field reports null for a hit, not 0: Tracon
distinguishes “not measured” from “measured as zero” everywhere it reports usage.
Turning ResponseCache.Enabled on without an IDistributedCache registered fails
validation and fails to compile the agent; the error names the missing registration.
Nothing runs uncached silently. A store failure (a timeout, an oversized payload the
store rejects) is logged and treated as a miss on read, or simply dropped on write —
a cache problem never fails a call that would otherwise have succeeded.
Tool-call loop
Section titled “Tool-call loop”ModelBinding.ToolLoop configures the loop that runs the tools the model asks for and
sends their results back. Every member is optional and keeps the loop’s own default when
it is left unset, so an agent without it behaves as before. A Fallbacks link runs the
same agent’s tools, so it takes the whole ToolLoop of the primary model.
| Member | Default | What it does |
|---|---|---|
AllowConcurrentToolCalls |
false |
Runs the independent tool calls of one turn at the same time |
MaximumConsecutiveErrors |
3 |
How many failed tool turns in a row the loop tolerates before it rethrows; 0 rethrows the first |
TerminateOnUnknownCalls |
false |
Ends the loop and hands the call back when the model asks for a tool the agent does not have; by default the model sees an error result |
Concurrent tool calls
Section titled “Concurrent tool calls”By default, independent tool calls returned in the same turn run one after another.
Turn AllowConcurrentToolCalls on to run them at the same time instead — useful when
a turn calls several independent, I/O-bound tools and their combined latency matters
more than a small increase in peak concurrency.
Model = new ModelBinding{ Provider = AnthropicProviderNames.Anthropic, Model = "your-current-model-name", ToolLoop = new ToolLoopSettings { AllowConcurrentToolCalls = true },},Off by default: with no change, calls still run one at a time exactly as they do today. Turn it on only for tools whose bodies are safe to run concurrently with themselves — a tool that shares mutable state across calls without its own synchronization should not opt in.
It is not AllowMultipleToolCalls. That field decides whether the model may ask for
several calls in one turn; AllowConcurrentToolCalls decides whether Tracon runs them at the
same time.
Check a prompt against the context window before running it
Section titled “Check a prompt against the context window before running it”The pre-flight check is TraconPreflightOptions, bound from
Tracon:Preflight. It is off until Enabled is set, and ReserveRatio decides
how much of the window is held back for the answer.
Outgoing concurrency is TraconModelConcurrencyOptions, bound from
Tracon:ModelConcurrency: MaxConcurrentCallsPerProvider caps how many calls
Tracon has in flight against one provider at a time.
ContextWindowTokens on a catalog ModelDescriptor powers two features:
derivation for ContextWindow compaction, and an optional pre-flight check on
POST /api/agents/{name}/run that rejects an oversized prompt before any
provider is called.
{ "Tracon": { "Preflight": { "Enabled": true, "ReserveRatio": 0.2 } }}Preflight.Enabled is off by default: a wrong estimate stops a run that
would have succeeded, and that risk needs an explicit opt-in.
ReserveRatio (default 0.2) sets aside a share of the window for the answer;
a prompt estimated above the remaining budget returns 400 with the counted
and allowed token numbers, and no provider is contacted.
The count is approximate — it uses a single fixed OpenAI encoding regardless of the bound provider, because Anthropic and Google publish no equivalent offline tokenizer. Diagnose the estimate for any agent, independent of whether the check is enabled, with:
curl -X POST "http://localhost:5081/tracon/api/agents/support/estimate" \ -H "Authorization: Bearer $TRACON_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"message":"..."}'contextWindowTokens and allowedPromptTokens come back null when the
agent’s model is not in the catalog — a missing catalog entry is never treated
as a rejection, since there is nothing to compare the prompt against.
Defaults and operational behavior
Section titled “Defaults and operational behavior”| Setting | Default or rule |
|---|---|
| Model fallback | Provider options can define a default model or Azure deployment for programmatic use; the management HTTP API still requires model.model |
| Sampling fields | null uses the provider default |
| Anthropic output limit | DefaultMaxOutputTokens = 4096 because Anthropic requires max_tokens |
| Compatible Responses route | Off |
| Health cache | 60 seconds |
| Background health checks | Off; checks run on request unless an interval is configured |
| Circuit breaker | On; 5 consecutive failures; one half-open attempt after 30 seconds |
| Model catalog | Empty until the host supplies entries |
| Pre-flight context-window check | Off; POST /api/agents/{name}/estimate still works when off |
| Fallback chain | Empty; an unavailable primary throws, same as before this feature existed |
| Tenant provider binding | None; every tenant uses the global setup-time credential until one is saved |
| Tenant egress policy | Unrestricted; saving one is an additive restriction, never a default wall |
| Allowed configuration prefix for a binding | Tracon:ProviderKeys:{tenant}:; a flat name belongs to the default tenant, and any other name is rejected with 400 |
| Response cache | Off; a binding with ResponseCache.Enabled = true and no registered IDistributedCache fails to compile |
| Response cache lifetime | 10 minutes, when caching is enabled |
| Concurrent tool calls | Off; independent tool calls in one turn run one after another |
Force a current, cost-free reachability check with:
curl -H "Authorization: Bearer $TRACON_TOKEN" \ "http://localhost:5081/tracon/api/models/health/openai?refresh=true"The health call reads a model list. It does not run a completion.
Troubleshooting
Section titled “Troubleshooting”“Provider is not registered.” Confirm that the matching Use...() call ran and
that ModelBinding.Provider uses the exact stable name from the table above.
The host fails during startup. Check the provider’s configuration section. Keep
the secret value out of appsettings.json, but make sure the environment or secret
manager supplies it. Azure also requires an absolute resource endpoint.
The model does not appear in the console. Add it to the provider’s Models
collection. The absence does not stop a definition from using it.
Azure health is good, but a run returns 404. Health verifies the resource and
credential, not a deployment. Check the deployment name in ModelBinding.Model.
Azure health reports Credential error (...). The CredentialFactory credential
could not return a token. A common cause is DefaultAzureCredential on a machine
without an Azure sign-in. The detail shows only the exception type; the credential
library’s message stays out of the response. The other providers’ health is not
affected.
A compatible run has no token count or cost. The upstream server probably omitted streaming usage. Configure no estimate unless you can label it as an estimate.
A compatible provider returns 402. Some gateways reserve credit against the
maximum possible output. Set a realistic ModelBinding.MaxOutputTokens value.
Anthropic rejects a thinking request. Current models reject
budget_tokens and sampling parameters with 400: switch to
anthropic.thinking.mode = adaptive with anthropic.effort, remove Temperature
and TopP, and mark the model SupportsSampling = false in the catalog so the next
definition fails at compile time. On an older model, keep the thinking budget below
the output limit and set temperature to 1 or leave it out.
A long Anthropic answer stops in the middle. The response hit the output limit.
A structured response fails the run with output_truncated; a text answer completes
with a tracon.output.truncated run event, which a streaming client also receives as a
custom frame before done, and /v1/chat/completions reports
finish_reason: "length". Raise MaxOutputTokens. When the message
and the event’s cause name the context window instead (Anthropic’s
model_context_window_exceeded), the conversation filled the window: shorten it with
context compaction or use a model with a larger window.
An Anthropic turn with web search takes several calls. The API pauses a long turn
of its own server tools (pause_turn). Tracon sends the paused answer back as the API
returned it and the model continues; each call is billed and counted. A turn still
paused after five continuations fails the run with upstream_error, fault
AnthropicTurnPausedException.
A working prompt gets rejected by the pre-flight check. The token estimate is
approximate. Raise ReserveRatio toward zero, or call /estimate to see the
counted value against the model’s real ContextWindowTokens before deciding.
In the reference
Section titled “In the reference”- Model health HTTP API
ModelBindingAPIResponseCacheSettingsAPIUseOpenAIAPIUseOpenAICompatibleAPIUseAnthropicAPIUseGoogleAPIUseAzureOpenAIAPI
Read next
Section titled “Read next”- Reliable runs — provider fallback chains and outgoing concurrency limits
- Choosing packages — select the packages needed by your host and its integrations.
- Agents and definitions — configure and version the definitions that the catalog resolves.