Skip to content
Tracon

ModelProviderContract

Namespace Tracon.Testing.Contracts.Providers · Assembly Tracon.Testing.Contracts.Xunit.dll

Behavior tests for the IModelProvider contract.

public abstract class ModelProviderContract : IAsyncLifetime, IAsyncDisposable

objectModelProviderContract

ModelProviderCredentialContract, ModelProviderSettingsContract

IAsyncLifetime, IAsyncDisposable

object.GetType(), object.MemberwiseClone(), object.ToString(), object.Equals(object?), object.Equals(object?, object?), object.ReferenceEquals(object?, object?), object.GetHashCode()

A provider Tracon does not ship a package for is registered with AddModelProvider and then carries the same runtime obligations the four shipped providers do. Most of those obligations cannot be checked by a compiler: returning an already-wrapped chat client, capturing a scoped service, rejecting a model the catalog does not list, or building a client factory that is not safe to run twice all compile cleanly and misbehave only later. Deriving this class asserts the ones observable from outside.

No scenario here performs a model call. Every test uses IModelProvider.CreateChatClient, IModelProvider.Name and IModelProvider.Models only, so the suite runs with no network access and no API key. An implementation whose CreateChatClient reaches the network to construct a client is itself breaking the contract: construction is on the compile path.

Two obligations are not checked here because they are not observable from outside a single provider instance: that the provider holds no captured scoped service, and that the registry rejects two providers sharing a name. Both are stated in IModelProvider’s own documentation.

This class carries only what every provider owes. Behavior that is a provider’s choice lives in its own opt-in class, so that deriving it is the statement of intent and no scenario has to silently pass: ModelProviderCredentialContract for honoring a per-tenant credential (BYOK), and ModelProviderSettingsContract for validating ModelBinding.ProviderSettings. A provider that offers neither derives neither, and records that in its ContractCoverage exemptions.

protected ModelProviderContract()

The provider under test.

protected IModelProvider Provider { get; }

IModelProvider

A model name the provider’s catalog does not list. The contract requires the provider to accept it: the catalog is metadata, not an allow list.

protected virtual string UnknownModelName { get; }

string

A_client_that_is_never_disposed_does_not_stop_the_provider()

Section titled “ A_client_that_is_never_disposed_does_not_stop_the_provider()”
[Fact("/Users/farukatasoy/Desktop/projects/Tracon/src/Tracon.Testing.Contracts.Xunit/Contracts/Providers/ModelProviderContract.cs", 190)]
public void A_client_that_is_never_disposed_does_not_stop_the_provider()

Tracon never disposes the client this method returns: the compiled agent holds it and implements neither IDisposable nor IAsyncDisposable, and evicting an agent from the compile cache drops the reference without disposing. A provider must therefore stay usable after handing out a client nobody disposes.

A_model_the_catalog_does_not_list_is_not_rejected()

Section titled “ A_model_the_catalog_does_not_list_is_not_rejected()”
[Fact("/Users/farukatasoy/Desktop/projects/Tracon/src/Tracon.Testing.Contracts.Xunit/Contracts/Providers/ModelProviderContract.cs", 159)]
public void A_model_the_catalog_does_not_list_is_not_rejected()

The catalog drives the console’s picker, capability hints and cost reporting; it is not an allow list. Rejecting an unlisted model would mean a newly published model needs a new release before it can be used.

A binding aimed at this provider.

protected ModelBinding Binding(string? model = null, string? providerName = null)

model string?

The model name. Defaults to the first catalog entry, or ModelProviderContract.UnknownModelName when the catalog is empty — an empty catalog is a supported state.

providerName string?

The provider name to put in the binding. Defaults to IModelProvider.Name.

ModelBinding

The binding.

Catalog_model_names_are_unique_and_not_empty()

Section titled “ Catalog_model_names_are_unique_and_not_empty()”
[Fact("/Users/farukatasoy/Desktop/projects/Tracon/src/Tracon.Testing.Contracts.Xunit/Contracts/Providers/ModelProviderContract.cs", 98)]
public void Catalog_model_names_are_unique_and_not_empty()

Concurrent_create_chat_client_calls_all_return_a_client()

Section titled “ Concurrent_create_chat_client_calls_all_return_a_client()”
[Fact("/Users/farukatasoy/Desktop/projects/Tracon/src/Tracon.Testing.Contracts.Xunit/Contracts/Providers/ModelProviderContract.cs", 167)]
public Task Concurrent_create_chat_client_calls_all_return_a_client()

Task

Providers are registered as singletons and the registry keeps one instance, so this method is called concurrently from the compile path.

Produces the provider to test.

protected abstract ValueTask<IModelProvider> CreateProviderAsync()

ValueTask<IModelProvider>

A provider ready for use.

Create_chat_client_accepts_a_binding_whose_provider_name_differs_in_case()

Section titled “ Create_chat_client_accepts_a_binding_whose_provider_name_differs_in_case()”
[Fact("/Users/farukatasoy/Desktop/projects/Tracon/src/Tracon.Testing.Contracts.Xunit/Contracts/Providers/ModelProviderContract.cs", 143)]
public void Create_chat_client_accepts_a_binding_whose_provider_name_differs_in_case()

ModelBinding.Provider is matched against IModelProvider.Name case-insensitively, so a binding may legitimately carry a different casing than the provider registered. A provider that compares the two with an ordinal check and throws breaks on a binding the registry considers valid.

Create_chat_client_returns_a_client_for_the_setup_time_credential()

Section titled “ Create_chat_client_returns_a_client_for_the_setup_time_credential()”
[Fact("/Users/farukatasoy/Desktop/projects/Tracon/src/Tracon.Testing.Contracts.Xunit/Contracts/Providers/ModelProviderContract.cs", 107)]
public void Create_chat_client_returns_a_client_for_the_setup_time_credential()

Create_chat_client_returns_a_raw_client_that_builds_no_tool_call_loop()

Section titled “ Create_chat_client_returns_a_raw_client_that_builds_no_tool_call_loop()”
[Fact("/Users/farukatasoy/Desktop/projects/Tracon/src/Tracon.Testing.Contracts.Xunit/Contracts/Providers/ModelProviderContract.cs", 122)]
public void Create_chat_client_returns_a_raw_client_that_builds_no_tool_call_loop()

The single most consequential rule in IModelProvider: the common pipeline belongs to ModelProviderRegistry. A provider that builds its own UseFunctionInvocation loop puts the turn carrying a tool result back into the model beneath the content guard, which is exactly the path prompt injection takes — and nothing in the reply text or the tool-call count reveals it. GetService is Microsoft.Extensions.AI’s own discovery protocol and surfaces the loop through any number of nested rings, so this holds however the provider decorates its client.

public ValueTask DisposeAsync()

ValueTask

Called immediately after the class has been created, before it is used.

public ValueTask InitializeAsync()

ValueTask

Hook for a derived class to release its own resources.

protected virtual ValueTask OnDisposeAsync()

ValueTask

A completed task.

[Fact("/Users/farukatasoy/Desktop/projects/Tracon/src/Tracon.Testing.Contracts.Xunit/Contracts/Providers/ModelProviderContract.cs", 94)]
public void Provider_name_is_not_empty()