The qualified text output-policy mappings below passed 180/180 focused running-server cases
and 1,545/1,545 full integration cases, with 92.45% production line coverage. Tests use
OpenAI 2.14.0, Azure.AI.OpenAI 2.9.0-beta.1, Azure.AI.Inference 1.0.0-beta.5, Anthropic 12.58.0
with strict response validation, Google.GenAI 1.26.1 and the package's Microsoft.Extensions.AI
client. Raw HTTP supplements native protocol and admission boundaries. Other E6 outcomes and
provider families remain open. A fresh installed-package consumer passed 24/24, including
the seven Azure/Foundry routes. The .NET SDK-built linux/arm64 container passed native policy,
streaming, authorization, management and recovery acceptance.
LlmTestServerScenarioResponse.OutputDisposition accepts an immutable
LlmTestServerOutputDisposition from ManagedCode.LlmTestServer.Scenarios. Kind is
LlmTestServerOutputDispositionKind.Filtered or .Refused. Optional Category and Explanation
carry bounded outcome metadata. A null disposition preserves the ordinary completion behavior.
Scenario and conversation-step builders expose WithOutputDisposition(...), applied after a response:
using ManagedCode.LlmTestServer.Models;
using ManagedCode.LlmTestServer.Scenarios;
await control.ConfigureAsync(config => config
.UseGpt55()
.UseChatScenario("policy-and-recovery", scenario => scenario
.ForModel(LlmTestServerKnownModelIds.Gpt55)
.Responds("I cannot provide that response.", "I cannot ", "provide that response.")
.WithOutputDisposition(new()
{
Kind = LlmTestServerOutputDispositionKind.Refused,
Category = "general_harms",
Explanation = "Explicit deterministic refusal fixture.",
})
.Responds("Explicit next attempt.")));Use .Filtered instead for a filtering fixture and configure an explicit generated prefix or empty
content. Content and StreamChunks supply deterministic fixture output, including refusal text;
the disposition selects its native representation. The server does not generate a refusal explanation,
execute a policy service or call an upstream model. Existing configure/reset APIs remain the control surface.
WithOutputDisposition requires a preceding response. JSON configuration accepts the same typed shape:
{
"content": "Explicit refusal text.",
"streamChunks": ["Explicit ", "refusal text."],
"outputDisposition": {
"kind": "Refused",
"category": "general_harms",
"explanation": "Explicit fixture metadata."
}
}This is a response entry inside the existing configuration, not a complete configuration document.
Kindmust be one of the two named values; the default/unknown enum value is invalid.Categoryis optional and limited to 256 C# string characters;Explanationis optional and limited to 4,096. These are local fixture bounds, not advertised upstream limits.- Explicit chunks must concatenate exactly to
Content. Empty output is supported. - A policy response cannot also contain a scripted
Error,StreamDelivery, ordinaryToolCallsor ordered/nativeOutputItems. - Invalid configuration is rejected atomically with HTTP 400, leaving the previous configuration usable.
- A valid fixture selected by an unsupported route/kind pair, tool declaration or tool-bearing/native history, native includes or structured-output request is rejected with a fixture-mismatch HTTP 409 before queue/graph consumption and cache population.
For Anthropic refusal, Category must be null or cyber, bio, frontier_llm, reasoning_extraction
or general_harms. A different bounded generic category is rejected at Anthropic fixture admission;
the strict SDK's declared category contract is the reference. Other surfaces retain generic metadata
in diagnostics rather than inventing provider fields. See the official
RefusalStopDetails type.
These are normal HTTP 200 output outcomes. Inspect their native fields; do not infer an exception from the policy outcome alone.
| Exact provider route | Filtered |
Refused |
|---|---|---|
/openai/v1/chat/completions |
Final finish_reason: content_filter; explicit content/prefix |
message.refusal, null ordinary content; streamed delta.refusal, then normal stop/terminal sequence |
/deepseek/v1/chat/completions |
Final finish_reason: content_filter; explicit content/prefix |
Unsupported pairing: fixture mismatch |
/openai/v1/responses |
status: incomplete, incomplete_details.reason: content_filter; SSE ends with response.incomplete |
Typed refusal content part; refusal delta/done events; completed response |
/azure-openai/openai/deployments/{deployment}/chat/completions |
Final finish_reason: content_filter; explicit content/prefix |
Native message.refusal / streamed delta.refusal, null ordinary content, normal stop |
/azure-openai/openai/v1/chat/completions |
Final finish_reason: content_filter; explicit content/prefix |
Native message.refusal / streamed delta.refusal, null ordinary content, normal stop |
/azure-openai/openai/v1/responses |
status: incomplete, reason content_filter; SSE ends with response.incomplete |
Native refusal part/deltas; completed response |
/microsoft-foundry/chat/completions |
Final finish_reason: content_filter; explicit content/prefix |
Unsupported pairing: fixture mismatch |
/microsoft-foundry/models/chat/completions |
Final finish_reason: content_filter; explicit content/prefix |
Unsupported pairing: fixture mismatch |
/microsoft-foundry/openai/v1/chat/completions |
Final finish_reason: content_filter; explicit content/prefix |
Native message.refusal / streamed delta.refusal, null ordinary content, normal stop |
/microsoft-foundry/openai/v1/responses |
status: incomplete, reason content_filter; SSE ends with response.incomplete |
Native refusal part/deltas; completed response |
/anthropic/v1/messages |
Unsupported pairing: fixture mismatch | stop_reason: refusal and stop_details with type refusal and required nullable category/explanation; final message_stop |
/gemini/v1beta/models/{model}:generateContent and :streamGenerateContent |
Terminal candidate finishReason: SAFETY; terminal content omitted |
Unsupported pairing: fixture mismatch |
The OpenAI mappings follow the official Chat completion contract
and Responses streaming events.
Anthropic refusal is a returned outcome distinct from an SSE error event; see
refusals and fallback
and streaming.
Gemini output SAFETY is separate from prompt blocking with promptFeedback; this fixture implements
only the candidate output case described by the GenerateContent API.
Both ordinary JSON responses and text SSE are qualified. Gemini's existing JSON-array streaming
transport also accepts this policy outcome; its separate post-header delivery-fault restriction to
alt=sse does not apply here. A filtered Gemini JSON/final candidate has no content, so its SDK
Text can be empty. Earlier SSE candidates can already have delivered the explicitly configured
prefix. Runtime usage describes the configured generated fixture text; omitted final JSON content
does not retroactively erase generated prefix tokens. This is deterministic fixture accounting,
not a claim of upstream billing or inference equivalence.
The exact /deepseek/v1/chat/completions route accepts Filtered; it does not accept Refused.
The original OpenAI SDK verifies its compatible JSON/SSE responses against the running server.
The DeepSeek Chat contract specifies
content_filter as a native finish reason and a distinct usage layout: the final chunk always has one
choice, a non-null finish reason and usage, followed by [DONE]. No separate usage-only chunk is emitted.
With include_usage: true, intermediate chunks carry usage: null; false or omitted leaves that
field absent until the final chunk. This applies to both ordinary and filtered DeepSeek completions.
Other provider routes retain their own streaming behavior.
Nineteen additional real-server cases cover empty and prefix output, the three usage options,
unsupported refusal admission without consumption, recovery, graph replay and cache evidence.
Non-streaming requests with a non-null stream_options object return HTTP 400 before fixture
consumption, including explicit false and omitted stream. Together with the initial policy slice,
the earlier DeepSeek checkpoint was 79/79, including three successful raw JSON configuration
cases. At that checkpoint the full integration result was 1,438/1,438, and the .NET SDK-built
linux/arm64 container passed native DeepSeek JSON/SSE, usage, invalid-options and recovery acceptance. These historical package/container results
do not qualify the subsequent Azure extension.
The seven exact Azure/Foundry routes in the table are independently qualified for JSON and text
SSE. Deployment Chat and both v1 Chat routes return native ChatFinishReason.ContentFilter for
filtering or native refusal text with a normal stop for refusal. Both v1 Responses routes return
native incomplete/content-filter or completed/refusal outcomes. The two Model Inference routes
accept only Filtered, parsed as CompletionsFinishReason.ContentFiltered; Refused returns
fixture-mismatch HTTP 409 before consumption. Empty output and explicitly generated prefixes are
covered separately.
Deployment tests use the original AzureOpenAIClient.GetChatClient(...) with parameterless
AzureOpenAIClientOptions: the pinned 2.9.0-beta.1 SDK selects 2025-04-01-preview. A transparent
request observer records that outgoing URI without rewriting it. Supplemental raw HTTP tests cover
both supported deployment schemas, 2024-10-21 and 2025-04-01-preview, for both outcomes and
transports. Model Inference tests use the original ChatCompletionsClient with parameterless
AzureAIInferenceClientOptions: version 1.0.0-beta.5 selects 2024-05-01-preview for both roots.
Consumers do not set ServiceVersion to satisfy the server. The four v1 routes use original OpenAI
Chat/Responses clients with the documented v1 endpoint and no dated version selection. See the
Azure OpenAI SDK options,
Model Inference SDK options
and Azure v1 lifecycle.
On the two exact Model Inference SSE routes, the server places usage on the final existing choice
with its non-null finish reason, followed by [DONE]; it emits no empty-choice usage-only frame.
This applies to ordinary and filtered completions. The original SDK reads that final typed usage,
and input/output/total counts reconcile with the public request journal without a stream_options
extension. Usage is an optional native field supplied by this deterministic fixture contract; this
is not a claim that every upstream stream must include it. Other Azure/OpenAI SSE routes keep their
own usage-frame behavior. See the Model Inference REST contract.
Policy completion remains HTTP 200, while the existing input filter returns HTTP 400. Unsupported
tools/structured requests reject before selecting the policy response. Running-server tests also
cover explicit recovery, canonical SDK refusal history, stored/manual Responses continuation,
JSON-to-SSE graph replay, committed cache usage and provider/session isolation. Freeform fixture
Category/Explanation remain diagnostics; they do not fabricate optional Azure classifier
annotations. Asynchronous moderation offset events, service safety configuration and classifier
annotation fixtures are outside this qualified subset. See the Azure Chat reference
and content filtering concepts.
SimulateContentFilter(...) retains its existing request/history filtering behavior: a matching input
fails with HTTP 400 and content_filter before generation. Fails(...) remains an explicit provider
HTTP failure. Neither is a substitute for the native HTTP 200 output outcomes above.
A policy outcome has a valid protocol ending. OpenAI Chat still finishes its SSE sequence; Responses
uses response.incomplete for filtering or response.completed for refusal; Anthropic emits
message_stop; Gemini emits its terminal SAFETY candidate. Once the final sequence is actually
flushed, TerminalMarkerObserved is true and terminal-marker verification can pass. Responses
incomplete describes the logical output, not a missing transport terminal.
Use stream delivery faults for early EOF, post-frame delay or connection
abort. Combining a delivery plan with a policy outcome is rejected rather than assigning an
unqualified precedence. This slice does not simulate Responses failed, a provider SSE error
event, Gemini prompt blocking, or a native tool's own error result.
A native policy outcome uses the existing successful inference commit path. It consumes the selected queued response or graph step, records actual explicit output usage, populates the normal qualified prompt cache and retains an eligible stored response. No separate queue, executor, retry service or history store is introduced. An explicit next attempt against a flat queue selects its next fixture. Graph replay returns the committed policy outcome without executing another step; session/provider/ credential scope continues to use the existing owners.
Anthropic max_tokens: 0 remains cache-population-only. It returns before response selection and
produces a new planless result: no output disposition is selected, no queued policy response is
consumed and no graph step executes. A subsequent nonzero generation still selects the configured
policy fixture.
History preserves the input representation available in the native protocol:
- An OpenAI refusal is retained as assistant
ContentplusLlmTestServerMessage.OutputDisposition.Kind = Refused. In history, category and explanation are stripped because native refusal input parts do not carry them. - Filtered visible text is retained as ordinary assistant prefix input. The prior output-envelope
filter status is not an input-part field, so history has no invented
Filteredmarker. - The original result, runtime event and request trace retain the full configured outcome metadata. Input history is not a replacement for that output diagnostic evidence.
This canonical projection is shared by stored-response restoration, history snapshots, cache keys,
conversation fingerprints and message matching. Automatic previous_response_id continuation and
canonical manual SDK history therefore replay the same graph turn. Bounds/enums are validated before
projection. Metadata is excluded from model text tokens while retained objects/strings remain charged
to their graph/history/event/trace budgets; exceptional diagnostic truncation remains visibly marked.
OpenAI Chat accepts pure native refusal fields or pure typed refusal content parts, including empty refusal text. Responses accepts its native refusal part. Mixed ordinary text/refusal history and ambiguous simultaneous refusal representations are outside this qualified subset and are rejected; do not flatten them into an unmarked assistant answer. Native ordered output items are unchanged.
The package-owned LlmTestServerChatClient returns refusal text as ErrorContent with
ErrorCode = "Refusal", preserving it separately from ordinary TextContent. A streamed refusal is
accumulated into that typed content, and a follow-up request maps it back to native refusal history.
Filtering retains the native content_filter finish reason. HTTP 200 policy outcomes do not become
fabricated HTTP failures or automatic thrown exceptions in this wrapper.
ErrorContent is an official AI-content abstraction; this client uses it for the refusal convention above. See its
upstream source.
Applications can choose their own handling of that returned content. This wrapper convention does
not replace the official SDK's distinct native refusal and filtering types.
Focused tests under tests/ManagedCode.LlmTestServer.Tests/Policy start Kestrel and exercise real
HTTP/SDK behavior: typed JSON/SSE outcomes, valid terminal evidence, empty/prefix text, strict refusal
details, explicit recovery, graph replay, session/cache isolation, manual/stored canonical history,
metadata bounds, unsupported combinations and the existing input-filter distinction. Raw HTTP
supplements configuration, Gemini array boundaries, both documented Azure deployment versions and
Model Inference SSE frame layout. SDK automatic retries are disabled; Google
automatic continuation is also disabled. The focused result is 180/180, including malformed
refusal-history admission, bounded diagnostic retention, native DeepSeek usage, successful named-enum
JSON configuration and the seven exact Azure/Foundry routes. The full suite passed 1,545/1,545
with 92.45% production line coverage. A fresh installed-package consumer passed 24/24,
including the seven Azure/Foundry routes. All 19 v1.0.0 packages were packed. The .NET SDK-built
linux/arm64 container passed standalone health, dashboard, authorization, management, JSON/SSE,
native-tool, DeepSeek and seven-route Azure/Foundry policy/recovery acceptance.
The larger E6 objective remains open for additional provider-specific outcomes and transports, including in-stream API error events and separately qualified semantics for other provider families. This document does not claim output-policy support for Bedrock, Groq, Mistral, Ollama, Cohere, OpenRouter, Perplexity, System One decision profiles, Cloudflare decision profiles, additional Azure/Foundry operations or media/native-tool/structured-output combinations. Shared mapper code alone is not qualification.