Phase 67b — `IAIProvider.SendStructuredMessage` (consumer migration)
Phase 67b — IAIProvider.SendStructuredMessage (consumer migration)
What changes. IAIProvider gains a sibling abstract SendStructuredMessage for JSON-Schema-respecting structured output. Schema rides as a string parameter (same convention as AIProviderToolDef.InputSchema); providers translate to their native wire format (Gemini responseSchema, OpenAI response_format: json_schema, Claude tool-based workaround).
Scope. Forge SDK ships the interface change, the IAIProviderDefaults.sendStructuredViaFallback module helper for non-native providers, native implementations for the three shipped providers (Claude, OpenAI, Gemini), forwarding members on the three SDK-internal IAIProvider decorators (quota gate, metering, quota-enforcing), and the updated docs/ai/api-reference.md + docs/ai/extending.md. Existing SendMessage-only consumers are byte-identical post-upgrade — no migration action required.
This is an additive opt-in. Deployments that don't call SendStructuredMessage carry no extra IL beyond a vtable slot. The SDK-ADOPTION matrix row is all-⛔ N-A.
Before / after — interface
type IAIProvider =
abstract Capabilities: AIProviderCapabilities
abstract SendMessage:
messages: AIProviderMessage list *
tools: AIProviderToolDef list *
systemPrompt: string option *
onStream: (string -> unit) option *
retryPolicy: RetryPolicy ->
Async<Result<AIProviderResponse, AIProviderError>>
+ abstract SendStructuredMessage:
+ messages: AIProviderMessage list *
+ tools: AIProviderToolDef list *
+ systemPrompt: string option *
+ schema: string *
+ retryPolicy: RetryPolicy ->
+ Async<Result<AIProviderResponse, AIProviderError>>
+
+module IAIProviderDefaults =
+ val sendStructuredViaFallback:
+ provider: IAIProvider ->
+ messages: AIProviderMessage list ->
+ tools: AIProviderToolDef list ->
+ systemPrompt: string option ->
+ schema: string ->
+ retryPolicy: RetryPolicy ->
+ Async<Result<AIProviderResponse, AIProviderError>>
A new AIProviderError case is added; SchemaUnsupported of feature: string * detail: string is not-retryable.
type AIProviderError =
| TransientNetwork of message: string
| TransientServer of statusCode: int * message: string
| PermanentClient of statusCode: int * message: string
| MalformedResponse of detail: string
| StreamingAborted of partialText: string * detail: string
| RetriesExhausted of attempts: int * lastError: AIProviderError
| UnsupportedCapability of feature: string * detail: string
+ | SchemaUnsupported of feature: string * detail: string
Per-provider native implementation strategy
Gemini
generationConfig.responseSchema + generationConfig.responseMimeType: "application/json". The forge-side wire layer already accepted a structuredOutputSchema: JsonElement option parameter — SendStructuredMessage parses the incoming schema string into a JsonElement, then dispatches a non-streaming :generateContent request with the schema attached.
OpenAI
response_format: { type: "json_schema", json_schema: { name: "structured_response", schema, strict: true } }. Requires gpt-4o-2024-08-06+ or gpt-4o-mini; older models reject with HTTP 400 (surfaced as PermanentClient). Wire-side buildRequestBody extended with the schema parameter; existing SendMessage call site passes None for a byte-identical legacy request body.
Anthropic (Claude)
No native mode. The documented workaround is the tool-based pattern:
- Synthesise a tool whose
input_schemais the supplied schema (StructuredResponseToolName = "structured_response"). - Force
tool_choice = { type: "tool", name: "structured_response", disable_parallel_tool_use: true }. - The forced tool-call's
inputfield IS the structured response —SendStructuredMessageextracts it and surfaces it asAIProviderResponse.Contentfor shape parity with the Gemini / OpenAI native paths.
Limitation: with tool_choice forcing the schema-tool, user-supplied tools become unreachable in the same turn. Canonical multi-turn pattern: run free-form tool-dispatch turns on SendMessage first, then a final SendStructuredMessage for the structured response.
Backward-compat contract for non-native providers
External providers (or future shipped providers added later) MAY delegate to the helper in one line:
interface IAIProvider with
member _.Capabilities = ...
member _.SendMessage(...) = ...
member this.SendStructuredMessage(messages, tools, systemPrompt, schema, retryPolicy) =
IAIProviderDefaults.sendStructuredViaFallback
(this :> IAIProvider)
messages tools systemPrompt schema retryPolicy
The fallback:
- Prepends the schema as a system-prompt instruction (
"You MUST respond with a single JSON document..."). - Calls
provider.SendMessagewith the augmented prompt. - Post-validates the response
Contentis parseable JSON. Non-JSON / empty content returnsAIProviderError.SchemaUnsupported("structured-output", detail).
No external schema-validator dependency — System.Text.Json.JsonDocument.Parse is the only validator. Best-effort: a malformed JSON returns the error; schema-conformance against the supplied schema is not enforced by the fallback (only by native impls).
Verification steps
Solution build:
cd toolup-forge dotnet build ToolUp.Forge.slnExpected: 0 errors. Existing
IAIProviderimplementers will receive aFS0366 No implementation was given for ... SendStructuredMessageerror — see "Implementer migration" below.Existing
SendMessagebehaviour unchanged:# Run the AI test suites (Expecto console runners — never `dotnet test`). dotnet run --project src/ToolUp.Platform.Tests/ToolUp.Platform.Tests.fsprojNative structured-output round-trip — three providers:
let schema = """{ "type": "object", "properties": { "ok": { "type": "boolean" } }, "required": ["ok"] }""" let messages = [ AIProviderMessage.text "user" "Reply per the schema." ] let! result = provider.SendStructuredMessage( messages, [], None, schema, RetryPolicy.defaults) match result with | Ok r -> use doc = JsonDocument.Parse(r.Content) assert (doc.RootElement.TryGetProperty("ok") |> fst) | Error err -> failwith (AIProviderError.toMessage err)Repeat against Claude / OpenAI / Gemini with valid API keys in
_platformscope.Fallback round-trip:
// Wrap any IAIProvider as a non-native implementer that delegates to the helper. // The default behaviour: response.Content is JSON, or Error SchemaUnsupported.
Rollback
Revert the interface commit (1fedfa8 and follow-ups f382e60 Claude / ffe10b7 OpenAI). The change is additive — reverting the interface change reverts every implementer; no consumer-side data migration required.
Implementer migration (external IAIProvider impls)
External impls — provider companions outside the forge repo, custom test mocks, decorators — break with FS0366. Two adoption paths:
Option 1 — One-line fallback delegation (recommended for non-native providers and test mocks):
member this.SendStructuredMessage(messages, tools, systemPrompt, schema, retryPolicy) =
IAIProviderDefaults.sendStructuredViaFallback
(this :> IAIProvider)
messages tools systemPrompt schema retryPolicy
Option 2 — Native impl (for providers whose vendor supports structured-output natively). Mirror one of the three shipped providers' shapes in the forge tree as a worked example.
Decorators (quota gates, metering, proxies) forward to inner:
member _.SendStructuredMessage(messages, tools, systemPrompt, schema, retryPolicy) =
inner.SendStructuredMessage(messages, tools, systemPrompt, schema, retryPolicy)
…optionally adding pre-/post-call gating identical to whatever the decorator does for SendMessage.
Out of scope (this phase)
- Streaming structured-output (Gemini and OpenAI both support it; deferred until a consumer demands it).
- Schema-feature parity audit (
oneOf/anyOf/$refsupport varies). Advanced features produceProviderError.SchemaUnsupportedrather than silent miss. - An engine-level dispatch hook on
AIAgentEngine— primary v1 consumers (closed-loop authoring orchestrator, eval suite) are one-shot structured calls. The canonical multi-turn pattern is documented indocs/ai/extending.md.