Onboarding guide
27. Common AI Integration
What this chapter covers. This group is the framework's language-model boundary: the optional
MMCA.Common.AI package that turns a call to an LLM provider into an ordinary, governed external
dependency. Three questions shape every type in it. What is this call allowed to do? (a pinned
model, a clamped output ceiling, a wall-clock budget, a tool gate, an optional input ceiling).
What did it cost, and which prompt spent it? (two counters on one framework-wide meter, tagged by
prompt name and version). Which exact prompt produced this answer? (a versioned contract whose
SHA-256 hash is what an evaluation gate records against). The package answers all three with eight
types: a settings class and its provider enum, a composition entry point that builds one delegating
client pipeline, two delegating clients (bounds, then metering), an optional estimator interface,
a meter, and the prompt contract. The decision record is
ADR-120, which extends
the feature-level record
ADR-111.
If you have not yet met the extension(T) DI convention or the fail-fast options pattern, skim
primer section 4 first: this chapter composes both
and does not re-teach them.
[Rubric section 16, AI-Native Application Architecture] is the category this whole group exists to
answer. Section 16 asks whether a model dependency is isolated, versioned, evaluated, observed and
bounded in what it may do, rather than being a free-form HTTP call buried in a feature. The package's
shape is a literal reading of that list: isolation is the single IChatClient registration, versioning
is PromptContract, observation is AiUsageMeter, and the bounds are
BoundedChatClient. [Rubric section 2, Design Patterns] also applies throughout,
because the mechanism is the decorator: every layer is a DelegatingChatClient wrapping the next.
The configuration surface is the contract
AiSettings (MMCA.Common/Source/Core/MMCA.Common.AI/AiSettings.cs:31) binds from the
Ai section (AiSettings.cs:34) and holds every knob the package has: Enabled (:48), Provider
(:51), Model (:59), ApiKey (:70), MaxOutputTokens (:77, defaulting to 1024 at :37 and
range-checked 1 to 1,000,000 at :76), Timeout (:83, defaulting to 30 seconds at :40),
AllowTools (:90), EnableCache (:96) and the optional PerCallInputTokenBudget (:104, range
1 to int.MaxValue at :103). Every one of those is a bound rather than a suggestion: the settings
doc comment says so outright (AiSettings.cs:22-29), and the runtime enforcement lives one layer out
in BoundedChatClient, so a caller cannot opt out by passing different
ChatOptions.
Validation is conditional, which is what lets the Ai section ship in every appsettings file.
AiSettings implements IValidatableObject (AiSettings.cs:31) and its Validate yields nothing
at all when Enabled is false (:115-118); once the dependency is switched on it requires Model
(:120-125), requires ApiKey (:127-133) and rejects a non-positive Timeout (:135-140). The
registration chains that onto ValidateDataAnnotations().ValidateOnStart()
(MMCA.Common/Source/Core/MMCA.Common.AI/DependencyInjection.cs:84-87), so a misconfigured host
fails at startup rather than on the first user request: the same fail-fast configuration contract the
rest of the framework follows
(ADR-070).
[Rubric section 11, Security] is in play at ApiKey: the property is documented as binding from Key
Vault in production and from user secrets locally (AiSettings.cs:63-68), so the package itself never
decides where the secret comes from.
AiProvider (AiSettings.cs:11) is the small enum naming the providers the package can
construct for itself, and today it has exactly one member, Anthropic = 0 (:17). That is not a
ceiling on what a host can use: the enum's own doc comment points a host with another provider at the
factory overload of AddMmcaChatClient (AiSettings.cs:6-9), because the governance pipeline is
provider-agnostic and wraps whatever inner client it is handed.
One entry point, one pipeline, outermost first
AiServiceCollectionExtensions
(MMCA.Common/Source/Core/MMCA.Common.AI/DependencyInjection.cs:49) is the only composition surface.
It exposes two AddMmcaChatClient overloads inside an extension(IServiceCollection services) block
(DependencyInjection.cs:51), the framework's standard public DI shape
(ADR-106): the
one-argument form (:59-60) delegates to the factory form (:74-76) with a known provider factory,
so the Anthropic path is nothing more than the general path with its innermost client supplied.
The registration reads top to bottom as a policy. Bind and validate (:84-87). Then, if the section
is disabled, return having registered no client at all (:89-93). That absence is the API: a
consumer gates on GetService<IChatClient>() returning null rather than on reading a flag
(DependencyInjection.cs:38-43), so a feature whose key is missing in a given environment is off by
construction. When enabled, the meter is registered (AddMetrics plus
TryAddSingleton<AiUsageMeter>(), :95-96) and the client is built with AddChatClient plus two
Use calls, first-registered being outermost because ChatClientBuilder applies its factories in
reverse (:98-108). The resulting order, outermost first, is
BoundedChatClient, then UsageRecordingChatClient,
then optional distributed caching, then OpenTelemetry, then logging, then the provider client
(DependencyInjection.cs:22-29). The ordering is deliberate: bounds sit outside everything, so a
rejected call is rejected before anything logs or caches it, and a cache hit still records "what this
call would have cost" (:30-36).
Two details in that block are worth reading closely. Caching needs both halves, the EnableCache
switch and an actually-registered IDistributedCache, because a cache the host never registered would
turn every call into a resolve-time throw (:110-115). And EnableSensitiveData on the OpenTelemetry
layer is driven by IsDevelopmentHost (:117-122, implementation at :172-176), which fails closed:
an environment it cannot positively identify as Development reads as not-Development, so prompt and
completion text never reach telemetry by accident (:166-171). That is the same dev-only-relaxation
rule recorded in
ADR-122, and it is
[Rubric section 11, Security] and [Rubric section 13, Observability and Operability] pulling in
opposite directions with the safe default winning. The built-in provider path itself constructs an
AnthropicClient and adapts it with the official SDK's own AsIChatClient
(DependencyInjection.cs:149-153), so nothing here hand-rolls the Messages API over HttpClient;
an unrecognized provider throws a NotSupportedException that names the factory overload as the way
out (:154-157).
The outer layer: what a call is allowed to do
BoundedChatClient
(MMCA.Common/Source/Core/MMCA.Common.AI/Chat/BoundedChatClient.cs:51) is a DelegatingChatClient
that applies four bounds to both the buffered and the streaming path. Bound works on a clone of
the caller's options (BoundedChatClient.cs:149), clamps MaxOutputTokens down with Math.Min
(:151-153) so a caller asking for less keeps its smaller number, and nulls out Tools and
ToolMode while AllowTools is false (:155-159). CreateLinkedTimeout links the caller's token to
one cancelled after Timeout (:164-169), so neither cancellation source masks the other, and on the
streaming path the timeout deliberately covers the whole stream rather than just its first update
(:104-111). [Rubric section 12, Performance and Scalability] and
[Rubric section 31, Cost and FinOps] both live here: the output ceiling is a spend bound and the
timeout is a thread-occupancy bound.
The fourth bound is input size, and it is the one with a caveat baked into its own doc comment.
EnforceInputBudget (:171-186) runs only when PerCallInputTokenBudget is set, estimates the
request, and throws an InvalidOperationException naming the setting when the estimate exceeds it
(:184-185), which fails the call locally instead of paying for it remotely. The estimate comes from
the public static EstimateInputTokens (:121-145), which concatenates the options' instructions and
every message's text and then either defers to an IAiTokenEstimator or falls
back to one token per four characters (:144). IAiTokenEstimator
(MMCA.Common/Source/Core/MMCA.Common.AI/Chat/IAiTokenEstimator.cs:14) is optional and is resolved
through the client pipeline rather than from DI, via this.GetService<IAiTokenEstimator>()
(BoundedChatClient.cs:178), so any inner client that knows its provider's tokenizer can offer one.
The package declines to take a tokenizer dependency for this on purpose (IAiTokenEstimator.cs:8-12),
and the source is blunt that the number is a guardrail and not a billing figure: images, tool schemas,
provider-side system additions and non-Latin scripts are all under-counted
(BoundedChatClient.cs:37-44). The under-count direction is chosen too: it lets a call through, it
never blocks one that would have fit (:141-144). Both entry points bound and budget-check eagerly,
the streaming one outside the iterator (:90-96), so a forbidden request is refused when it is made,
not when somebody starts reading the stream.
The inner layer: what the call actually cost
UsageRecordingChatClient
(MMCA.Common/Source/Core/MMCA.Common.AI/Chat/UsageRecordingChatClient.cs:21) sits just inside the
bounds and reports the provider's own numbers. On the buffered path it records after awaiting the
response (:44-53), passing response.Usage, the model (response.ModelId falling back to the
options' ModelId, :48) and the prompt identity read back off the options (:49-50). On the
streaming path it accumulates from the update stream, recording whenever a UsageContent item appears
(:64-79), which is how providers typically deliver usage on a final update. The split of
responsibility with the outer layer is stated in its remarks (:10-14): a bound has to be decided
before the call, a cost has to be measured after it.
AiUsageMeter
(MMCA.Common/Source/Core/MMCA.Common.AI/Observability/AiUsageMeter.cs:20) owns the two counters:
mmca.ai.input_tokens (:29) and mmca.ai.output_tokens (:32), published under the meter name
MMCA.Common.AI (:26), which is the same name the pipeline's OpenTelemetry layer uses as its
activity source (DependencyInjection.cs:121), so one name enables both traces and metrics. Every
adopting app reports to that same meter, which is what makes one dashboard query cover every service
(AiUsageMeter.cs:12-18). Record (:74-103) tags each measurement with model, prompt_name,
prompt_version and provider (:86-92), and it is careful in two places: a null usage records
nothing (:81-84) and a count the provider did not report is skipped rather than written as zero
(:94-102), because an absent number must not read as a zero on a spend dashboard. The meter is
created through IMeterFactory and deliberately neither retained nor disposed (:49-62, rationale at
:39-44), since disposing a factory-owned meter would kill the instrument for every other holder of
the name. [Rubric section 13, Observability and Operability].
The prompt is a versioned artifact
PromptContract (MMCA.Common/Source/Core/MMCA.Common.AI/PromptContract.cs:23) is a
sealed record of the four things that decide what a model will answer: Name, Version, Model and
SystemPrompt. Its Hash (:46) is the lowercase hex SHA-256 of those four joined by a pipe
character (:90-100), with every component's line endings normalized to LF first (:102-103) so a
Windows checkout and a Linux checkout of the same prompt hash identically and a CI gate cannot be
tripped by core.autocrlf. The hash is computed on each read rather than cached, and the reason is
recorded in the remarks (:40-45): a record's generated copy constructor copies fields verbatim, so a
cached hash would survive a with expression and describe the prompt the copy was made from.
That hash is the evaluation hook. ToChatOptions (:53-54) builds fresh options carrying the model
and the system prompt as instructions, Apply (:62-72) stamps mmca.prompt.name,
mmca.prompt.version and mmca.prompt.hash (:26,29,32) onto options a caller already built, and the
static ReadName / ReadVersion (:77,82) are what
UsageRecordingChatClient calls to turn a spend number into a per-prompt
signal. Change any of the four components and the hash moves, so a golden-replay gate that recorded
answers for the old hash demands a re-evaluation instead of silently approving a prompt nobody scored
(PromptContract.cs:11-17). ADC's evaluation suite does exactly that, pinning a recorded hash in
MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Scoring.Evaluation.Tests/PromptContractTests.cs:104.
[Rubric section 16] again, and this is the part of it most systems skip.
Where it runs today, and how it is tested
Exactly one feature in the workspace calls a model: ADC's organizer-facing session scoring. The
Conference service host registers the client with a single
builder.Services.AddMmcaChatClient(builder.Configuration) call
(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:135) and subscribes the framework
meter by literal name (Program.cs:148); the host also bridges the already-deployed Anthropic:ApiKey
secret onto Ai:ApiKey and derives Ai:Enabled from its presence (Program.cs:129-134), so no live
Key Vault secret had to be renamed. On the module side,
AnthropicScoringService
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/AnthropicScoringService.cs:9)
consumes the injected IChatClient and PromptContract, and Conference's Infrastructure registration
resolves the client with GetService rather than GetRequiredService precisely because the disabled
host registers none
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:35-38).
[Rubric section 3, Clean Architecture]: an ADC architecture fitness test asserts that Infrastructure
is the only layer naming a language-model SDK type or the package
(MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Layering/AiDependencyIsolationTests.cs:5).
[Rubric section 14, Testability] is served by the factory overload. Because the innermost client is
just a Func<IServiceProvider, IChatClient> (DependencyInjection.cs:74-76), the whole governance
pipeline can be exercised with no network at all, which is what the package's own suite does:
MMCA.Common/Tests/Core/MMCA.Common.AI.Tests holds AiServiceCollectionExtensionsTests,
AiSettingsTests, BoundedChatClientTests, PromptContractTests and UsageRecordingChatClientTests.
Read the per-type sections next in level order: the two Level 0 contracts
(AiProvider, IAiTokenEstimator) and
PromptContract first, then AiSettings and
AiUsageMeter, then the two clients, and finally the registration that assembles them.
AiProvider
MMCA.Common.AI ·
MMCA.Common.AI·MMCA.Common/Source/Core/MMCA.Common.AI/AiSettings.cs:11· Level 0 · enum
- What it is: the enum of language-model providers the governed chat-client pipeline can build a
client for. Today it has exactly one member,
Anthropic = 0(AiSettings.cs:31, doc commentAiSettings.cs:28-30): Anthropic's Messages API, constructed through the official Anthropic .NET SDK and adapted toIChatClientby that SDK's ownAsIChatClientextension. - Depends on: nothing first-party.
- Concept introduced, the provider switch.
[Rubric §16, AI-Native Application Architecture](assesses whether LLM integration is a governed, swappable dependency rather than a hard-wired SDK call): the enum exists soAiSettings.Provider(AiSettings.cs:199) andAiServiceCollectionExtensions's provider switch (MMCA.Common/Source/Core/MMCA.Common.AI/DependencyInjection.cs:746-761) can select an implementation by configuration value instead of by type reference, even though only one arm is implemented today. - Walkthrough: a single explicit member,
Anthropic = 0, so it is also the implicit default ofAiSettings.Provider(AiSettings.cs:199). - Why it's built this way: a
switchon this enum inAiServiceCollectionExtensions.CreateProviderChatClient(DependencyInjection.cs:746-761) throwsNotSupportedExceptionfor any unimplemented value and directs the caller to theFunc<IServiceProvider, IChatClient>overload instead, so an unsupported provider fails at startup with an actionable message rather than a null-reference deep in the pipeline. SeeADR-120for the governed-boundary rationale this enum feeds into. - Where it's used:
AiSettings.Provider(AiSettings.cs:199), theUsageRecordingChatClientconstructor'sproviderparameter (MMCA.Common/Source/Core/MMCA.Common.AI/Chat/UsageRecordingChatClient.cs:579), and the tagAiUsageMeter.Recordstamps on every measurement (MMCA.Common/Source/Core/MMCA.Common.AI/Observability/AiUsageMeter.cs:381).
IAiTokenEstimator
MMCA.Common.AI.Chat ·
MMCA.Common.AI.Chat·MMCA.Common/Source/Core/MMCA.Common.AI/Chat/IAiTokenEstimator.cs:14· Level 0 · interface
- What it is: a one-method extension point for estimating how many input tokens a piece of prompt
text will cost:
int EstimateTokenCount(string text)(IAiTokenEstimator.cs:57). - Depends on: nothing first-party beyond the caller that resolves it.
- Concept introduced, a pluggable estimator behind a cheap built-in default.
[Rubric §1, SOLID](assesses whether a dependency is inverted behind an abstraction rather than hard-coded):BoundedChatClientresolves this interface from DI (this.GetService<IAiTokenEstimator>(),MMCA.Common/Source/Core/MMCA.Common.AI/Chat/BoundedChatClient.cs:541) and falls back to a built-in heuristic when nothing is registered (BoundedChatClient.EstimateInputTokens,BoundedChatClient.cs:507), so a host that wants provider-accurate tokenization can register a real tokenizer without changing the bounding logic. - Walkthrough: the interface has no other members; the contract is the single method plus its two
doc-comment lines describing the parameter and return value (
IAiTokenEstimator.cs:54-56). - Why it's built this way:
BoundedChatClient.EstimateInputTokensispublic staticprecisely so a caller (or the estimator implementation itself) can reproduce the same estimate the budget check uses (BoundedChatClient.cs:484-508), and it deliberately rounds up with a cheap four-characters-per-token heuristic when no estimator is registered, an under-count that only ever lets a call through, never blocks one that would have fit (BoundedChatClient.cs:504-507). - Where it's used:
BoundedChatClient.EnforceInputBudget(BoundedChatClient.cs:534-549) is the only production call site; the test fixtureMMCA.Common/Tests/Core/MMCA.Common.AI.Tests/Fixtures/StubChatClient.cssupplies a stub implementation for unit tests. - Caveats / not-in-source: no built-in implementation ships in
MMCA.Common.AI; every consumer today relies on the character-based fallback rather than registering one.
PromptContract
MMCA.Common.AI ·
MMCA.Common.AI·MMCA.Common/Source/Core/MMCA.Common.AI/PromptContract.cs:23· Level 0 · record
- What it is: an immutable, hashable identity for one prompt:
Name,Version,Model,SystemPrompt(PromptContract.cs:78). It both builds theChatOptionsa call needs (ToChatOptions,PromptContract.cs:108-109) and stamps its own identity onto telemetry so every call can be traced back to the exact prompt that produced it. - Depends on:
ChatOptions(Microsoft.Extensions.AI),System.Security.Cryptography.SHA256, andSystem.Text.Encoding. - Concept introduced, prompt-as-versioned-contract.
[Rubric §16, AI-Native Application Architecture](assesses whether prompts are governed, versioned artifacts rather than inline strings): the record'sHashproperty (PromptContract.cs:101) is a lowercase hex SHA-256 ofName|Version|Model|SystemPrompt, with every component's line endings normalized to LF first (NormalizeLineEndings,PromptContract.cs:157-158) so the same prompt checked out on Windows and on Linux hashes identically and a CI gate cannot be tripped bycore.autocrlf(PromptContract.cs:90-93). The three identity values are stamped ontoChatOptions.AdditionalPropertiesunder fixed keys (NamePropertyKey/VersionPropertyKey/HashPropertyKey,PromptContract.cs:81-87), so a request carries its own provenance through the pipeline into telemetry. - Walkthrough:
Hash(PromptContract.cs:101) is computed on every read, deliberately not cached in a field, because a record's generated copy constructor copies fields verbatim and a cached hash would survive awithexpression describing the prompt the copy was made FROM (PromptContract.cs:96-100, exactly the drift the type exists to prevent).ToChatOptions(PromptContract.cs:108-109) builds a freshChatOptionswithModelIdandInstructionsset, then delegates toApply.Apply(PromptContract.cs:117-127) stampsName,Version, andHashonto an existingChatOptionsin place and returns it for chaining, for a caller that already built options and needs its own temperature or response format alongside the prompt identity.ReadName/ReadVersion(PromptContract.cs:132,137) and the privateReadProperty(PromptContract.cs:139-143) reverse the stamp, reading back whateverApplywrote (ornullwhen nothing was stamped). - Why it's built this way: hashing four short strings on every read is cheap enough that correctness
wins outright over caching (
PromptContract.cs:96-100). SeeADR-120for how this identity feeds the governed pipeline, andADR-111for a concrete scoring consumer. - Where it's used:
UsageRecordingChatClientreads the stamped name and version back offChatOptionsto tag every usage measurement (MMCA.Common/Source/Core/MMCA.Common.AI/Chat/UsageRecordingChatClient.cs:598-599,624-625); ADC'sAnthropicScoringService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/AnthropicScoringService.cs) is the production caller that builds aPromptContractfor session scoring, and ADC's architecture fitness testAiDependencyIsolationTests(MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Layering/AiDependencyIsolationTests.cs) asserts the isolation boundary this contract type crosses.
AiSettings
MMCA.Common.AI ·
MMCA.Common.AI·MMCA.Common/Source/Core/MMCA.Common.AI/AiSettings.cs:31· Level 1 · class
- What it is: the bound, validated
Aiconfiguration section (SectionName = "Ai",AiSettings.cs:182) that gates and configures the entire AI integration: whether it is on at all (Enabled,AiSettings.cs:196), which provider and model to call (Provider/Model,AiSettings.cs:199,207), the API key, the output-token ceiling, the per-call timeout, and two opt-ins (AllowTools,EnableCache) plus an optional input-token pre-flight budget. - Depends on:
System.ComponentModel.DataAnnotations(IValidatableObject,[Range]); no first-party dependency of its own, though its doc comments nameAddCommonKeyVaultConfigurationandPersistence:EnableSensitiveDataLoggingas sibling conventions elsewhere in Common. - Concept introduced, an off-by-default dependency with conditional validation.
[Rubric §16, AI-Native Application Architecture](assesses whether an LLM dependency is safe to ship in every environment):Enableddefaults tofalse, and when it is falseAddMmcaChatClientregisters noIChatClientat all, so resolving one yieldsnulland a consumer gates on the service being present rather than reading a flag itself (AiSettings.cs:190-196).[Rubric §11, Security](assesses secret handling):ApiKeyis required only whenEnabled, and its doc comment records that production binds it from Key Vault viaAddCommonKeyVaultConfiguration, never from a checked-in settings file (AiSettings.cs:209-218). - Walkthrough: two constants,
DefaultMaxOutputTokens = 1024(AiSettings.cs:185) andDefaultTimeout = TimeSpan.FromSeconds(30)(AiSettings.cs:188), back the two settings that ship with a value even when the section is silent.Modelis required whenEnabledbecause it is part of the prompt contract, hashed intoPromptContract.Hash, so an implicit provider default would silently change evaluated behavior on the provider's own schedule rather than on a reviewed version bump (AiSettings.cs:201-206).MaxOutputTokens([Range(1, 1_000_000)],AiSettings.cs:224-225) is the hard ceilingBoundedChatClient.Boundclamps every call down to.Timeout(AiSettings.cs:231) is enforced with a token linked to the caller's own, so a caller cancelling early still wins.PerCallInputTokenBudget([Range(1, int.MaxValue)],AiSettings.cs:251-252) isnullby default, leaving input unbounded, and is an ESTIMATE never a billing figure.Validate(AiSettings.cs:261-289) is the conditional half: ityield breaks immediately when!Enabled(AiSettings.cs:263-266), so a host that leaves AI off is valid with nothing else set, which is what lets the section ship in everyappsettingsfile; when enabled it requiresModel,ApiKey, and a positiveTimeout(AiSettings.cs:268-288). - Why it's built this way:
IValidatableObject.Validateruns through.ValidateDataAnnotations().ValidateOnStart()inAiServiceCollectionExtensions.AddMmcaChatClient(MMCA.Common/Source/Core/MMCA.Common.AI/DependencyInjection.cs:688-691), so a misconfiguredAisection fails at host startup, not on the first call. SeeADR-120. - Where it's used: bound and consumed by
AiServiceCollectionExtensions(DependencyInjection.cs:686-712,744), enforced byBoundedChatClient(BoundedChatClient.cs:416-426,510-549), and itsProvidervalue is threaded intoUsageRecordingChatClient(DependencyInjection.cs:712).
AiUsageMeter
MMCA.Common.AI.Observability ·
MMCA.Common.AI.Observability·MMCA.Common/Source/Core/MMCA.Common.AI/Observability/AiUsageMeter.cs:20· Level 1 · class
- What it is: the
System.Diagnostics.Metricswrapper that records token usage for the AI dependency: twoCounter<long>instruments, one for input (prompt) tokens and one for output (completion) tokens (AiUsageMeter.cs:324-325). - Depends on:
System.Diagnostics.Metrics(Meter,Counter<long>,TagList),IMeterFactory(Microsoft.Extensions.Diagnostics.Metrics), andMicrosoft.Extensions.AI.UsageDetails. - Concept introduced, factory-owned meter lifetime.
[Rubric §13, Observability & Operability](assesses whether a dependency's cost/usage is measurable in production): the constructor takesIMeterFactoryand creates itsMeterthrough it, but deliberately does NOT retain or dispose that meter itself (AiUsageMeter.cs:327-352); the factory owns the meter's lifetime, and disposing a factory-owned meter from a consumer would silently kill the instrument for every other holder of the same name, so this type is notIDisposable, matching how the rest of the framework's meters work (AiUsageMeter.cs:329-334,[SuppressMessage("CA2000", ...)]atAiUsageMeter.cs:335-338documents the deliberate suppression). - Walkthrough:
MeterName = "MMCA.Common.AI"(AiUsageMeter.cs:316) is shared with theActivitySourcename the pipeline's OpenTelemetry layer publishes under (AiUsageMeter.MeterNamereused atDependencyInjection.cs:725), so a host enables traces and metrics for the AI dependency with one name.InputTokensCounterName/OutputTokensCounterName(AiUsageMeter.cs:319,322,mmca.ai.input_tokens/mmca.ai.output_tokens) name the two counters, created with unit{token}and a description each (AiUsageMeter.cs:344-351).Record(AiUsageMeter.cs:364-393) takes a possibly-nullUsageDetails, model, prompt name/version, and provider; anullusage, or one whose counts the provider did not report, records nothing (an absent number must not read as a zero on a spend dashboard,AiUsageMeter.cs:354-357,371-374). When present, it tags every add withmodel,prompt_name,prompt_version(each falling back to"unknown"), andprovider(AiUsageMeter.cs:376-382), then addsusage.InputTokenCount/OutputTokenCountindividually when each is present (AiUsageMeter.cs:384-392). - Why it's built this way: recording input and output as two separate counters, tagged by model and
prompt identity, lets a spend dashboard break down cost by exactly the dimensions a prompt-versioning
workflow cares about. See
ADR-041for the framework's general telemetry conventions andADR-120for this meter's place in the governed pipeline. - Where it's used: registered as a singleton and resolved by
AiServiceCollectionExtensions(DependencyInjection.cs:700,711);Recordis called exclusively fromUsageRecordingChatClient(UsageRecordingChatClient.cs:595-600,621-627).
BoundedChatClient
MMCA.Common.AI.Chat ·
MMCA.Common.AI.Chat·MMCA.Common/Source/Core/MMCA.Common.AI/Chat/BoundedChatClient.cs:51· Level 2 · class
- What it is: a
DelegatingChatClient(Microsoft.Extensions.AI) that enforces the governance bounds fromAiSettingson every call: output-token clamping, tool stripping, a linked per-call timeout, and an optional input-token pre-flight budget. - Depends on:
Microsoft.Extensions.AI.DelegatingChatClient/IChatClient/ChatOptions,AiSettings,IAiTokenEstimator(resolved, not injected),System.Globalization.CultureInfo,System.Text.StringBuilder. - Concept introduced, a governance layer as a chat-client decorator.
[Rubric §12, Performance & Scalability](assesses whether a resource-bounded dependency is protected against runaway cost or latency): every bound applied here (output tokens, timeout, input budget) exists to keep one call from exceeding what the host budgeted for it.[Rubric §29, Resilience & Business Continuity](assesses whether an external dependency is time-bounded): the linkedCancellationTokenSource(CreateLinkedTimeout,BoundedChatClient.cs:527-532) guarantees the provider call cannot hang pastAiSettings.Timeoutregardless of what the caller's own token does. This is the first client wrapped inAiServiceCollectionExtensions's pipeline, matching the outermost-first ordering the type's own remarks describe (DependencyInjection.cs:702-703). - Walkthrough: the constructor takes the inner client and the
AiSettingsto enforce (BoundedChatClient.cs:421-426).GetResponseAsync(BoundedChatClient.cs:429-443) materializes the message enumerable once, callsBoundto clamp options, callsEnforceInputBudget, then races the base call against a linked timeout.GetStreamingResponseAsync(BoundedChatClient.cs:446-460) bounds and budget-checks eagerly, OUTSIDE the iterator methodStreamBoundedAsync, so a request the configuration forbids is refused when it is made, not when somebody starts reading the stream (BoundedChatClient.cs:453-454);StreamBoundedAsync(BoundedChatClient.cs:462-475) then wraps the whole stream, not just its first update, in the linked timeout, because a provider that opens a response and then stalls is exactly the failure mode a per-call budget exists to bound (BoundedChatClient.cs:469-470). The staticEstimateInputTokens(BoundedChatClient.cs:484-508) concatenatesoptions.Instructionsplus every message's text and either runs the resolvedIAiTokenEstimatoror falls back to a four-characters- per-token heuristic (BoundedChatClient.cs:504-507).Bound(BoundedChatClient.cs:510-525) clones the incomingChatOptions, clampsMaxOutputTokensto the smaller of the request's own value and_settings.MaxOutputTokens, and nulls outTools/ToolModeentirely whenAllowToolsis false: a model that cannot be handed a tool cannot be talked into using one (BoundedChatClient.cs:233-237inAiSettings).EnforceInputBudget(BoundedChatClient.cs:534-549) is a no-op whenPerCallInputTokenBudgetis unset, otherwise it estimates and throwsInvalidOperationExceptionwith the estimated count, the configured budget, and a reminder that the estimate is approximate when the estimate exceeds the budget. - Why it's built this way: bounding happens BEFORE the linked timeout is created and BEFORE the base
call, so a call this client is going to refuse never reaches the network. Clamping the smaller of the
request's and the settings'
MaxOutputTokens, rather than always overriding, lets a caller ask for less without losing that choice. SeeADR-120. - Where it's used: registered first (outermost is applied last by
ChatClientBuilder, so it ends up wrapping everything butUsageRecordingChatClient) byAiServiceCollectionExtensions.AddMmcaChatClient(DependencyInjection.cs:706-708).
UsageRecordingChatClient
MMCA.Common.AI.Chat ·
MMCA.Common.AI.Chat·MMCA.Common/Source/Core/MMCA.Common.AI/Chat/UsageRecordingChatClient.cs:21· Level 2 · class
- What it is: a
DelegatingChatClientthat records every call's token usage toAiUsageMeter, tagged with the model, the prompt name/version read offPromptContract, and the configuredAiProvider. - Depends on:
Microsoft.Extensions.AI.DelegatingChatClient/IChatClient/UsageContent,AiUsageMeter,PromptContract(ReadName/ReadVersion),AiProvider. - Concept introduced, cross-referenced.
[Rubric §13, Observability & Operability]: same category asAiUsageMeter; this type is the call site that turns a response into a measurement, closing the loop betweenPromptContract's stamped identity and the meter's counters. - Walkthrough: the constructor takes the inner client, the meter, and the provider tag to apply to
every measurement (
UsageRecordingChatClient.cs:579-585).GetResponseAsync(UsageRecordingChatClient.cs:588-603) awaits the base call, then recordsresponse.Usagetagged withresponse.ModelId ?? options?.ModelIdand the prompt name/version read back offoptionsviaPromptContract.ReadName/ReadVersion(UsageRecordingChatClient.cs:595-600), returning the response unchanged.GetStreamingResponseAsync(UsageRecordingChatClient.cs:606-632) tracks the first non-nullmodelIdseen across streamed updates (UsageRecordingChatClient.cs:611,615), and for eachUsageContentitem found in an update'sContents, records that item'sDetailswith the same tagging (UsageRecordingChatClient.cs:617-628), yielding every update through unchanged. - Why it's built this way: recording happens after the base call returns (or per streamed
UsageContent), so this client never alters the response, only observes it; that keeps it safely composable withBoundedChatClient, which does alter requests. SeeADR-111andADR-120. - Where it's used: registered last (innermost of the two governance wrappers, so it sees the actual
provider response) by
AiServiceCollectionExtensions.AddMmcaChatClient(DependencyInjection.cs:709-712); ADC'sAnthropicScoringService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/AnthropicScoringService.cs) andMMCA.ADC.Conference.Service/Program.csare downstream of the registered pipeline.
AiServiceCollectionExtensions
MMCA.Common.AI ·
MMCA.Common.AI·MMCA.Common/Source/Core/MMCA.Common.AI/DependencyInjection.cs:49· Level 3 · class
- What it is: the DI entry point for the whole AI integration: two
extension(IServiceCollection)members,AddMmcaChatClient(IConfiguration)and the provider-agnosticAddMmcaChatClient(IConfiguration, Func<IServiceProvider, IChatClient>), that bindAiSettings, and when enabled, build the full governed pipeline. - Depends on:
AiSettings,AiUsageMeter,BoundedChatClient,UsageRecordingChatClient,Microsoft.Extensions.AI.ChatClientBuilder(AddChatClient,.Use,.UseDistributedCache,.UseOpenTelemetry,.UseLogging),Microsoft.Extensions.Caching.Distributed.IDistributedCache,Microsoft.Extensions.Hosting.IHostEnvironment, and (only inCreateProviderChatClient) the Anthropic .NET SDK'sAnthropicClient/ClientOptionsplus its ownAsIChatClientadapter. - Concept introduced, cross-referenced to the primer's
extension(T)note (00-primer.md#c-extensiont-types--read-this-once).[Rubric §2, Design Patterns](assesses whether a composition pipeline is idiomatic): the method builds aChatClientBuilderdecorator chain where "first registered is outermost", explicitly documented to read exactly like the ordering diagram in the type's own remarks (DependencyInjection.cs:702-703):BoundedChatClientwraps first (outermost), thenUsageRecordingChatClient, then optionally a distributed cache layer, then OpenTelemetry, then logging.[Rubric §16, AI-Native Application Architecture]: this is the single composition point that turns raw provider access into the framework's governed AI dependency. - Walkthrough:
AddMmcaChatClient(IConfiguration)(DependencyInjection.cs:663-664) is the Anthropic overload, nothing more than the provider-agnostic overload withCreateProviderChatClientas the factory.AddMmcaChatClient(IConfiguration, Func<...>)(DependencyInjection.cs:678-730) is the real extension point a test uses to exercise the pipeline without a network, and the one a future provider plugs into (DependencyInjection.cs:673-677): it binds and validatesAiSettings(.Bind(section).ValidateDataAnnotations().ValidateOnStart(),DependencyInjection.cs:688-691), reads the settings once withsection.Get<AiSettings>() ?? new AiSettings()(DependencyInjection.cs:693), and returns early, registering nothing, when!settings.Enabled(DependencyInjection.cs:694-697). When enabled it registersAddMetrics()and a singletonAiUsageMeter(DependencyInjection.cs:699-700), then builds the chain viaAddChatClient+.Use(...)forBoundedChatClientthenUsageRecordingChatClient(DependencyInjection.cs:704-712). It adds a distributed-cache layer only whenEnableCacheis set AND anIDistributedCacheis actually registered (DependencyInjection.cs:716-719), because a cache the host never registered would make every call throw at resolve time, so the opt-in needs both halves: the switch AND a store to write to. It then applies OpenTelemetry withEnableSensitiveDatagated byIsDevelopmentHost(DependencyInjection.cs:721-727), and logging last. The privateCreateProviderChatClient(DependencyInjection.cs:742-762) switches onAiSettings.Provider: forAiProvider.Anthropicit constructsAnthropicClientwith the configured API key and timeout and adapts it with the SDK's ownAsIChatClient(settings.Model, settings.MaxOutputTokens), passing model and output ceiling as the client's defaults (BoundedChatClientstill clamps per call, because a default is a suggestion and a bound is not,DependencyInjection.cs:748-752); any other provider throwsNotSupportedExceptionnaming the functional overload as the escape hatch (DependencyInjection.cs:758-760). The privateIsDevelopmentHost(DependencyInjection.cs:776-780) fails CLOSED: an environment it cannot positively identify asDevelopment(by scanning already-registeredIHostEnvironmentinstances) reads as not-Development, so prompt and completion text never reach telemetry by accident, mirroring the same gate onPersistence:EnableSensitiveDataLoggingelsewhere in the framework (DependencyInjection.cs:770-775). - Why it's built this way: an early return for
!Enabledmeans a host that ships theAisection disabled pays zero DI registration cost, not just a runtime no-op. Reading settings once viasection.Get<AiSettings>()(rather than resolvingIOptions<AiSettings>before the container is built) lets the method branch onEnabledsynchronously during registration. SeeADR-120. - Where it's used: the entry point every AI-enabled host calls; no other type in
MMCA.Common.AIcalls it (#### Usage siteslists none outside its own file), so its consumers are downstream application hosts such as ADC'sMMCA.ADC.Conference.Service.
⬅ Device Capability Abstraction Layer (Native Contracts, MAUI, Browser & Fallback Adapters) • Index • Testing & Quality Infrastructure ➡