Skip to content

Structured Completion — Typed LLM Output as a Primitive

Most “AI feature” code is not the prompt — it is the plumbing around it: send the prompt, hope the model returns JSON, strip the Markdown fences it wrapped anyway, deserialize, guess whether an empty answer was a refusal or a crash, and make sure a parse error never logs the user’s data. IStructuredCompletion is the framework primitive that owns all of that, once, so every .AI module turns a prompt into a typed T the same safe way.

It lives in Granit.AI and is the canonical path for typed output (ADR-064). Every module that produces a structured object — translation suggestions, query filters, moderation verdicts, document extraction — calls it instead of reaching for IChatClient.GetResponseAsync() + a hand-rolled JsonSerializer.Deserialize<T>().

One injected service serves every result type — the generic is per call, not per interface, so there is a single DI registration:

namespace Granit.AI;
public interface IStructuredCompletion
{
Task<StructuredCompletionResult<T>> CompleteAsync<T>(
StructuredCompletionRequest request,
CancellationToken cancellationToken = default)
where T : class;
}

AddGranitAI() registers it (scoped). Inject it directly:

public sealed class SeoMetadataGenerator(IStructuredCompletion completion)
{
public async Task<SeoExtraction?> SuggestAsync(string pageBody, CancellationToken ct)
{
var request = new StructuredCompletionRequest
{
Instruction = "Generate SEO title, description, and keywords for the page.",
Content = pageBody,
ContentLabel = "Page body",
};
StructuredCompletionResult<SeoExtraction> result = await completion
.CompleteAsync<SeoExtraction>(request, ct)
.ConfigureAwait(false);
return result.Status == StructuredCompletionStatus.Succeeded ? result.Value : null;
}
}

StructuredCompletionRequest separates what the developer controls from what the user controls — the framework’s first line of defence against prompt injection (OWASP LLM01):

MemberTrustRole
InstructionDeveloperThe task prompt (e.g. a per-locale generation instruction). Never sanitized — appended verbatim. Supply only code- or template-controlled text.
ContentUntrustedThe text to analyze, or null for an attachment-only request. Sanitized and wrapped in a <data> block by the primitive before it reaches the model.
ContentLabelDeveloperLabel for the <data> block (defaults to "Document").
ContextUntrustedAdditional named sections (title, locale, source culture…). Each key is a developer label; each value is sanitized and delimited.
AttachmentsTrusted by callerOptional IReadOnlyList<DataContent> (Microsoft.Extensions.AI) of binary parts — images for a vision-capable model — appended to the user message after the prompt. Not routed through the <data> envelope, so the caller owns size caps and provenance (Granit.Imaging.AI, for example, enforces MaxImageBytes and a content-type allowlist). The type is pinned to DataContent so untrusted text can never be smuggled in as a raw message part.
WorkspaceNameDeveloperWhich AI workspace to run against. null uses the configured default.

At least one of Content or Attachments must be supplied — a request with neither throws ArgumentException. Content and Attachments compose: a request can carry sanitized text and an image in the same call.

Set Content to null and pass the image bytes as an attachment to get a typed result from a vision-capable model — the same schema-enforced path, no manual JSON. This is how Granit.Imaging.AI turns a picture into a typed ImageAnalysis:

var request = new StructuredCompletionRequest
{
Instruction = "Describe the image, list detected objects, and suggest alt text.",
Content = null, // image-only — no text to analyze
Attachments = [new DataContent(imageBytes, contentType)], // "image/jpeg", "image/png", …
WorkspaceName = "vision",
};
StructuredCompletionResult<ImageAnalysis> result = await completion
.CompleteAsync<ImageAnalysis>(request, ct)
.ConfigureAwait(false);

DataContent is trusted by the caller — enforce a size cap and a content-type allowlist before building the request. Granit.Imaging.AI does exactly this (see Image Analysis).

StructuredCompletionResult<T> carries the typed value plus the provenance a caller needs to react — and to score, without re-issuing the call:

public sealed record StructuredCompletionResult<T> where T : class
{
public required StructuredCompletionStatus Status { get; init; }
public T? Value { get; init; } // non-null only on Succeeded
public string? ModelId { get; init; } // provider-reported, for audit
public string? ErrorMessage { get; init; } // always PII-safe
public ChatFinishReason? FinishReason { get; init; } // why the model stopped
public IReadOnlyDictionary<string, object?>? Metadata { get; init; } // provider AdditionalProperties
public UsageDetails? Usage { get; init; } // token usage, when reported
}

The status is deliberately four-valued — a single success/failure boolean cannot drive a consumer’s own status mapping:

StructuredCompletionStatusMeaningTypical caller reaction
SucceededTyped value presentUse Value.
ModelRefusedModel declined, returned empty, or hit a safety filterNo suggestion — fall back to a default, never an error.
SchemaViolationOutput did not match the schema / failed to deserializeLog and fail; treat as a possible injection attempt.
TransportFailureTimeout, provider, or network errorRetry / back off / degrade gracefully.
return result.Status switch
{
StructuredCompletionStatus.Succeeded => Map(result.Value!),
StructuredCompletionStatus.ModelRefused => DefaultSuggestion(),
StructuredCompletionStatus.SchemaViolation => None(), // + injection metric
StructuredCompletionStatus.TransportFailure => None(), // retried by the caller's policy
_ => None(),
};

ErrorMessage is always PII-safe: a JsonException or a provider 4xx can embed a fragment of the (LLM-produced, possibly personal) payload, so the primitive surfaces a fixed description and logs only the exception type, never its message.

Provider-enforced schema, with a graceful fallback

Section titled “Provider-enforced schema, with a graceful fallback”

This is the core of the value. The primitive does not ask the model for JSON in the prompt and hope — it pins the schema at the provider when it can, and falls back to a single audited in-prompt path when it cannot:

flowchart TD
    A["CompleteAsync&lt;T&gt;(request)"] --> B{"Model advertises\nAIModelCapabilities\n.StructuredOutput?"}
    B -->|Yes| C["ChatOptions.ResponseFormat =\nChatResponseFormat.ForJsonSchema&lt;T&gt;()"]
    B -->|No| D["Inject AIJsonUtilities.CreateJsonSchema(T)\ninto the prompt"]
    C --> E["GetResponseAsync"]
    D --> E
    E --> F{"Fallback path?"}
    F -->|Yes| G["StripMarkdownCodeFences"]
    F -->|No| H["Raw text"]
    G --> I["JsonSerializer.Deserialize&lt;T&gt;"]
    H --> I
    I --> J["StructuredCompletionResult&lt;T&gt;"]

The capability is resolved per workspace via IAIWorkspaceCapabilityResolver against the existing AIModelCapabilities.StructuredOutput flag. The upshot: no consumer ever strips a Markdown fence by hand again, and the robust, provider-enforced path is the default rather than the exception.

Routing every typed call through one place means the cross-cutting plumbing is applied once, uniformly — including on paths that were untracked before:

ConcernHow
Usage trackingRecords token usage via IAIUsageTracker / IAIUsageRecordFactory — typed calls are now tracked, not just string completions.
Quota guardApplies the tenant quota guard before the call; a denial returns TransportFailure.
TimeoutOne global ceiling (AI:StructuredCompletion:TimeoutSeconds). Per-consumer deadlines stay caller-side via a linked CancellationTokenSource.
Rate limitingIAICallRateLimiter.TryAcquireAsync(bucketKey, maxCallsPerHour, ct)caller-supplied cap, so it stays a per-consumer concern. Not embedded in the primitive.
Content samplingAIContentSampler truncates oversized content on a code-point boundary.
PII redactionIAIContentRedactor seam (no-op by default) lets a host scrub content before it leaves the process.

The primitive emits an AIActivitySource span (ai.structured_completion, tagged with workspace, provider, model, and result type) and reuses the existing AIMetrics counters rather than a parallel tree:

MetricMeaning
granit.ai.requests.completedTagged with an outcome (succeeded, model_refused, schema_violation, transport_failure, quota_denied).
granit.ai.tokens.input / granit.ai.tokens.outputToken usage, when the provider reports it.
granit.ai.request.durationWall-clock duration of the call.

All are tagged tenant_id (coalesced to "global"). Each consuming module keeps its own domain metrics ({Module}AIMetrics) on top.

Document Extraction is now a thin “document + confidence” surface over the primitive. DefaultDocumentExtractor delegates to IStructuredCompletion, maps the status onto ExtractionStatus, and re-derives its confidence estimate from the result’s FinishReason and Metadata. The public IDocumentExtractor contract and ExtractionResult<T> (data, confidence, warnings, ModelId) are unchanged — extraction is one layer over structured completion, not a competing path. Confidence stays an extraction concern: the primitive surfaces the provenance, the extractor scores it.

If you maintain code on top of the older AI surface, three things moved when the primitive landed.

1. The 13 .AI modules now route through the primitive

Section titled “1. The 13 .AI modules now route through the primitive”

Every .AI module that produced typed output — Localization.AI, Validation.AI, Authorization.AI, Workflow.AI, Privacy.AI, BlobStorage.AI, QueryEngine.AI, DataExchange.AI, Notifications.AI, Observability.AI, Timeline.AI, LanguageDetection.AI, Indexing.AI — was migrated off the IAIChatClientFactory + GetResponseAsync + fence-strip + Deserialize<T> pattern onto IStructuredCompletion.CompleteAsync<T>. Behavior is preserved per module; the fragile plumbing (~100 LoC each) is gone. An architecture test now forbids the bypass so it cannot silently return; Granit.AI (the primitive’s own implementation) is the only exemption. Granit.Imaging.AI — once exempt because the image rides as DataContent — now routes its typed image analysis through the primitive too, since Attachments lets the request carry binary parts (see Multimodal).

2. Shared plumbing relocated into Granit.AI

Section titled “2. Shared plumbing relocated into Granit.AI”

The per-tenant rate limiter, content sampler, redactor seam, and untrusted-document envelope moved up from Granit.AI.Extraction so every consumer shares one home (pre-1.0 namespace break):

TypeWasNow
IAICallRateLimiter / AICallRateLimiterGranit.AI.Extraction.RateLimitingGranit.AI.RateLimiting
AIContentSamplerGranit.AI.Extraction.SamplingGranit.AI.Sampling
IAIContentRedactorGranit.AI.Extraction.RedactionGranit.AI.Redaction
UntrustedDocumentEnvelopeGranit.AI.Extraction.PromptingGranit.AI.Prompting

3. The distributed rate-limiter binding was renamed

Section titled “3. The distributed rate-limiter binding was renamed”

The Redis-backed IAICallRateLimiter binding follows the relocated interface:

WasNow
PackageGranit.AI.Extraction.StackExchangeRedisGranit.AI.StackExchangeRedis
ModuleGranitAIExtractionStackExchangeRedisModuleGranitAIStackExchangeRedisModule
DI helper(extraction-scoped)services.AddGranitAIRedisRateLimiter(...)

This supersedes the rate-limiting arrangement described in ADR-062. No net package change — the binding is renamed, not added, and the primitive lives in the existing Granit.AI package.