Feature/union response schemas - #597
Conversation
7a21b47 to
b474f59
Compare
…xplicit variants Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ypes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Step 4 verification (strict-mode coverage of response-format schemas): SchemaSupport.normalizeForStrict is applied on the ResponseFormat.JsonSchema encoder path, gated on strict = Some(true): - openai/src/main/scala/sttp/ai/openai/json/OpenAIManualCodecs.scala:132-134 (chatResponseFormatEncoder, ChatRequestBody.ResponseFormat.JsonSchema) - openai/src/main/scala/sttp/ai/openai/json/OpenAIManualCodecs.scala:153-155 (responsesRequestFormatEncoder, ResponsesRequestBody.Format.JsonSchema) Not blocked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- ResponseSchema.oneOf: kind discriminator now includes "type": "string" alongside "enum", and the assembled root carries a single "$schema" key matching ResponseSchema.derived's output; scaladoc notes encoder dispatch is first-match by declaration order on runtime class. - UnionResponseSchema.derive: renderType handles OrType so union-typed fail messages no longer render as <none>; silence the unused-foldLeft-result warning in the duplicate-member check. - Tests: assert kind's "type": "string" and the root "$schema" key; add decode-failure cases for missing result/kind; add an openai SchemaSupportSpec case pinning additionalProperties:false on a union variant while the root keeps its own required list. - Docs: note Scala 2.13 instances use deriveCodec/Schema.derived where the union examples show Scala 3 derives syntax. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Structured-output grammars (OpenAI strict mode, Claude) constrain generation to the schema's property order. With kind last, a model leading with the discriminator was locked out of every variant except the empty one - verified live: all non-empty variants classified as GeneralQuery. With kind first, all three providers classify correctly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A definition literally named 'properties' or 'type' was mistaken for a schema keyword of the $defs container, corrupting it. Also pins that the kind discriminator stays the first variant property through normalization. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- strip the synthetic kind discriminator before variant decoders run, so unknown-field-rejecting decoders work - compile-time error for distinct union members sharing a simple name (previously compiled, then failed at runtime with no escape hatch) - targeted error for reference-rooted (recursive) variant schemas instead of the misleading 'must be object schemas' message - reject encoders that emit their own kind field instead of clobbering it - version-consistent Variant default names for local classes (2.13's Strict$1 suffix stripped) - extract renderType into MacroSupport (shared by AgentTools and UnionResponseSchema) and the tapir rendering convention into ResponseSchema.renderTapirSchema Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Scala Native's getSimpleName returns just the numeric counter for method-local classes (Local$1 -> "1"), so ResponseSchemaOneOfSpec's local-class fixtures got kind "1" on the coreNative3 CI row and the strip-synthetic-kind test failed with an unknown-kind error. getName is identical on JVM and Native; the last non-numeric $-segment is the declared class name (verified on Scala Native 0.5.12 via scala-cli). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
110a6a7 to
cd51922
Compare
| .maxIterations(10) // Max reasoning steps | ||
| .systemPrompt("Custom prompt") // Optional instructions | ||
| .tools(tool1, tool2) // Your tools | ||
| .deriveResponseSchema[T] // Optional typed result (see runAs[T] below) |
There was a problem hiding this comment.
generated-docs should not be modified in PRs, only during releases
|
|
||
| val classifier = OpenAIAgent | ||
| .synchronous(openai, "gpt-4o-mini") | ||
| .responseSchema(UnionResponseSchema.derive[Refund | Complaint | GeneralQuery]("Classify the user's intent")) |
There was a problem hiding this comment.
why can't the same .deriveResponseSchema[Refund | Complaint | GeneralQuery] be used here, as in the first example above? Why do we need a specialised UnionResponseSchema in the first place?
There was a problem hiding this comment.
deriveResponseSchema[T] requires given sttp.tapir.Schema[T] and io.circe.Codec[T]. Neither tapir nor circe can derive these for union types — unions have no Mirror - so that call simply doesn't compile for a union (with an unhelpful "no given instance" error).
There was a problem hiding this comment.
Ok ... so then maybe we could have:
.responseSchema(ResponseSchema.derive[T]) // normal case
.responseSchema(ResponseSchema.deriveUnion[T | U]) // union case
this would be more regular. Plus we need clear docs on when to use .responseSchema vs .deriveResponseSchema and how these compare
| final case class Complaint(topic: String) derives Codec.AsObject, Schema | ||
| final case class GeneralQuery() derives Codec.AsObject, Schema | ||
|
|
||
| val intentSchema: ResponseSchema[Refund | Complaint | GeneralQuery] = |
There was a problem hiding this comment.
is ResponseSchema introduced earlier? Doesn't seem so - it might need a section on its own
Adds a Response schemas section to json-schemas.md before the union section uses the type, and documents why unions need a dedicated UnionResponseSchema.derive: no given Schema/Codec instances exist for union types, and the discriminated wire shape couples schema and codec. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cd51922 to
b1eb7c1
Compare
|
Automated review. Core logic verified correct (oneOf assembly, $defs merge, kind-stripping decoder, runtime dispatch, SchemaSupport $defs fix). Findings: Coverage / docs
Hardening Cleanup |
ResponseSchema.derivedUnion[A | B] now sits next to ResponseSchema.derived, via a version-specific companion parent trait (empty on Scala 2.13), and the standalone UnionResponseSchema object is gone. Docs gain explicit guidance on .responseSchema vs .deriveResponseSchema. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- oneOf now builds the schema as typed apispec values: ListMap properties make the discriminator-first invariant structural instead of depending on a Json round trip preserving key order, the $defs merge is deterministic and readable, and the internal-error decode branch is gone - the schema description is embedded in the root document so it reaches every provider, not only OpenAI (documented, incl. the derived caveat) - variant decode failures are re-anchored under the .result path - the macro rejects non-case-class members and same-generic-erasure members at compile time with dedicated errors - the strict-mode folder treats all name-to-schema containers (patternProperties, dependentSchemas, ...) like $defs - AgentTool.fromFunctionF uses the shared renderTapirSchema helper Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Union-type response schemas: structured intent classification
Adds discriminated-union response schemas, so a classifier agent returns one of several typed intents and the caller dispatches with an exhaustive
match(unhandled intents caught by the compiler):