Problem
Using OpenAI structured outputs (text.format / json_schema with strict: true) for topics whose Pydantic response models use discriminated unions (e.g. EmailInsightResponse.blocks: paragraph | list | cta) fails at the API with 400 invalid_json_schema:
Invalid schema for response_format 'EmailInsightResponse': In context=('properties', 'blocks', 'items'), 'oneOf' is not permitted.
Pydantic v2 emits oneOf for those unions. OpenAI’s structured-output schema subset rejects oneOf in items (and has other constraints). The LLM call never runs (0 tokens), so prompt-only workarounds are not the right long-term fix when we want enforced structured output.
Bedrock already ignores response_schema (prompt + serializer only). OpenAI enforces the schema. Vertex Gemini passes response_schema into GenerateContentConfig — rules may differ from OpenAI’s.
Goals
- Keep structured output enabled whenever the provider supports it for a given schema — do not globally disable schema for
EmailInsightResponse.
- Prefer a single canonical result shape (existing
EmailInsightResponse / API contract): adjust schema generation or transformation, not the business payload, unless unavoidable.
- If OpenAI and Gemini (and future providers) require incompatible schema dialects, centralize adaptation in the layer that builds the schema for each provider (not copy-paste per topic).
Research / references
- OpenAI: Structured outputs — documented JSON Schema subset and limitations (e.g. constraints on
oneOf / nesting).
- Gemini / Vertex: JSON response schema / controlled generation and Vertex
response_schema behavior — confirm whether oneOf / anyOf / allOf are allowed and how they map from Pydantic model_json_schema().
Proposed direction
- Inventory which constructs we emit today from
_prepare_schema_for_structured_output / _simplify_schema_for_prompt in coaching/src/application/ai_engine/unified_ai_engine.py (root: Pydantic model_json_schema(by_alias=True)).
- Define per-provider transforms (e.g.
SchemaForOpenAI, SchemaForGemini) that convert a canonical internal schema into a provider-legal schema while preserving the same logical output (validate returned JSON with the original Pydantic model after the call).
- Example approaches to evaluate:
oneOf → anyOf where OpenAI allows; flattening discriminated unions into explicit object variants; or a wrapper object with mutually exclusive optional fields (only if validation story stays sound).
- Wire
OpenAILLMProvider.generate(..., response_schema=...) and GoogleVertexLLMProvider.generate(..., response_schema=...) to receive already-adapted schemas from the engine (or a small llm_schema module), not the raw OpenAI-prep copy for both.
- Regression tests: unit tests for schema transform on
EmailInsightResponse; optional integration test against OpenAI with strict schema for that topic (mocked or gated).
- Observability: log provider + schema name + transform version on failure to speed up future schema issues.
Acceptance criteria
Related context
- Production logs (dev): OpenAI 400 on
EmailInsightResponse / blocks.items / oneOf with GPT_5_2_PRO / gpt-5.2-pro.
- Prior failure mode on Bedrock was serializer mismatch (LLM ignored prompt schema); this issue is provider schema legality before generation.
Non-goals (for this issue)
- Dropping structured output globally for unions.
- Changing the public email-insight API contract unless we explicitly version it and coordinate consumers.
Problem
Using OpenAI structured outputs (
text.format/json_schemawithstrict: true) for topics whose Pydantic response models use discriminated unions (e.g.EmailInsightResponse.blocks:paragraph|list|cta) fails at the API with 400invalid_json_schema:Pydantic v2 emits
oneOffor those unions. OpenAI’s structured-output schema subset rejectsoneOfinitems(and has other constraints). The LLM call never runs (0tokens), so prompt-only workarounds are not the right long-term fix when we want enforced structured output.Bedrock already ignores
response_schema(prompt + serializer only). OpenAI enforces the schema. Vertex Gemini passesresponse_schemaintoGenerateContentConfig— rules may differ from OpenAI’s.Goals
EmailInsightResponse.EmailInsightResponse/ API contract): adjust schema generation or transformation, not the business payload, unless unavoidable.Research / references
oneOf/ nesting).response_schemabehavior — confirm whetheroneOf/anyOf/allOfare allowed and how they map from Pydanticmodel_json_schema().Proposed direction
_prepare_schema_for_structured_output/_simplify_schema_for_promptincoaching/src/application/ai_engine/unified_ai_engine.py(root: Pydanticmodel_json_schema(by_alias=True)).SchemaForOpenAI,SchemaForGemini) that convert a canonical internal schema into a provider-legal schema while preserving the same logical output (validate returned JSON with the original Pydantic model after the call).oneOf→anyOfwhere OpenAI allows; flattening discriminated unions into explicit object variants; or a wrapper object with mutually exclusive optional fields (only if validation story stays sound).OpenAILLMProvider.generate(..., response_schema=...)andGoogleVertexLLMProvider.generate(..., response_schema=...)to receive already-adapted schemas from the engine (or a smallllm_schemamodule), not the raw OpenAI-prep copy for both.EmailInsightResponse; optional integration test against OpenAI with strict schema for that topic (mocked or gated).Acceptance criteria
goal_created_email_insight(or any topic usingEmailInsightResponse) can run on OpenAI with structured output without 400 schema errors.EmailInsightResponseafter the call.Related context
EmailInsightResponse/blocks.items/oneOfwithGPT_5_2_PRO/gpt-5.2-pro.Non-goals (for this issue)