Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/bundles/ai-bundle.rst
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,35 @@ You can specify a custom HTTP client service for any platform:
api_key: '%env(OPENAI_API_KEY)%'
http_client: 'app.custom_http_client'

Structured Output Validation
----------------------------

When ``symfony/validator`` is installed, the bundle registers the Platform component's ``ValidatorSubscriber``, which
validates the object populated from a ``response_format`` invocation and throws a
:class:`Symfony\\AI\\Platform\\Exception\\ValidationException` on violations. By default, the object is validated in the
``Default`` validation group. To validate it in specific groups instead, configure them under ``structured_output``:

.. code-block:: yaml

ai:
structured_output:
validation_groups: ['ai']

A single call can still override the configured groups by passing the ``validation_groups`` option to the platform or
agent. This lets you apply a dedicated set of constraints to model output, separate from the constraints used for user
input::

use Symfony\Component\Validator\Constraints as Assert;

final class Invoice
{
#[Assert\NotBlank(groups: ['ai'])]
public string $customer = '';

#[Assert\Positive] // "Default" group only, skipped for model output
public int $total = 0;
}

System Prompt Configuration
---------------------------

Expand Down
18 changes: 18 additions & 0 deletions docs/components/platform.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1432,6 +1432,24 @@ To enable validation, register the ``ValidatorSubscriber`` with your platform's
The ``ValidatorSubscriber`` will automatically validate any :class:`Symfony\\AI\\Platform\\Result\\ObjectResult` produced
by the ``PlatformSubscriber``. To use this feature, make sure `symfony/validator` is installed in your project.

By default, the object is validated in the ``Default`` validation group. To validate it in specific `validation groups`_
instead, for example to apply a dedicated set of constraints to model output, pass them to the subscriber::

$dispatcher->addSubscriber(new ValidatorSubscriber(groups: ['ai']));

The ``groups`` argument accepts the same values as ``ValidatorInterface::validate()``: a group name, a list of group
names, or a ``GroupSequence``. A single invocation can override them with the ``validation_groups`` option, which is
consumed by the subscriber and never forwarded to the provider::

$result = $platform->invoke('gpt-4o', $messages, [
'response_format' => MathReasoning::class,
'validation_groups' => ['ai', 'strict'],
]);

The groups also apply to the final object of a streamed structured output.

.. _`validation groups`: https://symfony.com/doc/current/validation/groups.html

Streaming Partial Objects
~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
5 changes: 5 additions & 0 deletions src/ai-bundle/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
CHANGELOG
=========

0.14
----

* Add the `structured_output.validation_groups` option to validate structured output in specific validation groups instead of the `Default` group

0.13
----

Expand Down
10 changes: 10 additions & 0 deletions src/ai-bundle/config/options.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,16 @@
->end()
->end()
->end()
->arrayNode('structured_output')
->info('Structured output configuration')
->addDefaultsIfNotSet()
->children()
->arrayNode('validation_groups')
->info('Validation groups the structured output object is validated in; defaults to the "Default" group')
->scalarPrototype()->end()
->end()
->end()
->end()
->arrayNode('agent')
->useAttributeAsKey('name')
->arrayPrototype()
Expand Down
9 changes: 9 additions & 0 deletions src/ai-bundle/src/AiBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,15 @@ public function loadExtension(array $config, ContainerConfigurator $container, C
$builder->removeDefinition('ai.platform.structured_output.validator_subscriber');
}

if ([] !== $config['structured_output']['validation_groups']) {
if (!$builder->hasDefinition('ai.platform.structured_output.validator_subscriber')) {
throw new RuntimeException('Configuring "ai.structured_output.validation_groups" requires the "symfony/validator" package. Try running "composer require symfony/validator".');
}

$builder->getDefinition('ai.platform.structured_output.validator_subscriber')
->setArgument(1, $config['structured_output']['validation_groups']);
}

if (false === $builder->getParameter('kernel.debug')) {
$builder->removeDefinition('ai.data_collector');
$builder->removeDefinition('ai.traceable_toolbox');
Expand Down
32 changes: 32 additions & 0 deletions src/ai-bundle/tests/DependencyInjection/AiBundleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,35 @@ public function testTemplateRendererListenerReceivesNormalizer()
$this->assertSame('serializer', (string) $arguments[1]);
}

public function testValidatorSubscriberReceivesConfiguredValidationGroups()
{
$container = $this->buildContainer($this->getFullConfig());
$definition = $container->getDefinition('ai.platform.structured_output.validator_subscriber');

$this->assertTrue($definition->hasTag('kernel.event_subscriber'));

$arguments = $definition->getArguments();
$this->assertCount(2, $arguments);
$this->assertSame('validator', (string) $arguments[0]);
$this->assertSame(['ai'], $arguments[1]);
}

public function testValidatorSubscriberValidatesDefaultGroupWithoutConfiguration()
{
$container = $this->buildContainer([
'ai' => [
'platform' => [
'openai' => [
'api_key' => 'sk-test-key',
],
],
],
]);
$definition = $container->getDefinition('ai.platform.structured_output.validator_subscriber');

$this->assertCount(1, $definition->getArguments());
}

public function testStoreCommandsArentDefinedWithoutStore()
{
$container = $this->buildContainer([
Expand Down Expand Up @@ -9108,6 +9137,9 @@ private function getFullConfig(): array
{
return [
'ai' => [
'structured_output' => [
'validation_groups' => ['ai'],
],
'platform' => [
'amazeeai' => [
'api_key' => 'amazeeai_key_full',
Expand Down
1 change: 1 addition & 0 deletions src/platform/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ CHANGELOG
0.14
----

* Add a `groups` argument to `StructuredOutput\Validator\ValidatorSubscriber` and a `validation_groups` invocation option to validate the structured output in specific validation groups (or a `GroupSequence`) instead of the `Default` group; the option overrides the subscriber's groups for that call, and both also apply to the final object of a streamed structured output
* Add a `server_tools` option to the Anthropic `ModelClient`, mapping `web_search` and `code_execution` to their versioned Anthropic tool spec, mirroring the Gemini and Vertex AI bridges' name-to-params map shape; unmapped tool names throw instead of being silently forwarded, and the raw `tools` option remains the escape hatch for anything not mapped. The Anthropic `ResultConverter` now merges the `server_tool_use`/`web_search_tool_result` pair of a web search into a single `Result\WebSearchResult` carrying query, id and status, instead of dropping the blocks (or throwing when a response carries only web-search blocks)
* Add `Result\Stream\Delta\WebSearchComplete`, emitted once per provider-hosted web search, so a streamed turn carries its searches into `Result\Stream\AssistantMessageStreamListener` and replays them like a buffered one
* [BC BREAK] Add `TokenUsage\TokenUsageInterface::getModel()`, reporting the model a provider says consumed the tokens, so a run mixing models (a chat model and an embeddings one, say) can be priced per call; `TokenUsageAggregation::getModel()` answers only when every usage it sums up agrees on a model, and `null` otherwise. `Test\Recording\ResultSerializer` records and replays it alongside the token counts, and a cassette recorded before the field existed still replays
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Constraints\GroupSequence;
use Symfony\Component\Validator\Validator\ValidatorInterface;

/**
Expand All @@ -33,8 +34,9 @@
*
* On stream completion the listener also produces the final `ObjectResult`,
* which `DeferredResult::asObject()` exposes after draining the stream.
* If a `ValidatorInterface` is injected, the final object is validated
* before being made available — partial snapshots are never validated.
* If a `ValidatorInterface` is injected, the final object is validated in the
* configured validation groups before being made available — partial snapshots
* are never validated.
*
* @author Johannes Wachter <johannes@sulu.io>
*/
Expand All @@ -46,6 +48,9 @@ final class PartialObjectStreamListener extends AbstractStreamListener
private ?ValidationException $validationException = null;
private ?ValidatorInterface $validator = null;

/** @var string|GroupSequence|array<string|GroupSequence>|null */
private string|GroupSequence|array|null $validationGroups = null;

private readonly SerializerInterface&DenormalizerInterface $serializer;

/**
Expand All @@ -59,9 +64,13 @@ public function __construct(
$this->serializer = $serializer;
}

public function setValidator(?ValidatorInterface $validator): void
/**
* @param string|GroupSequence|array<string|GroupSequence>|null $groups The validation groups to validate the final object in, or null for the validator's default group
*/
public function setValidator(?ValidatorInterface $validator, string|GroupSequence|array|null $groups = null): void
{
$this->validator = $validator;
$this->validationGroups = $groups;
}

public function getFinalObjectResult(): ?ObjectResult
Expand Down Expand Up @@ -130,7 +139,7 @@ public function onComplete(CompleteEvent $event): void
}

if (null !== $this->validator) {
$violations = $this->validator->validate($structure);
$violations = $this->validator->validate($structure, null, $this->validationGroups);

if (0 !== \count($violations)) {
$this->validationException = new ValidationException($violations);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use Symfony\AI\Platform\ResultConverterInterface;
use Symfony\AI\Platform\StructuredOutput\Streaming\PartialObjectStreamListener;
use Symfony\AI\Platform\TokenUsage\TokenUsageExtractorInterface;
use Symfony\Component\Validator\Constraints\GroupSequence;
use Symfony\Component\Validator\Validator\ValidatorInterface;

/**
Expand All @@ -29,9 +30,13 @@
*/
final class ValidatorResultConverter implements ResultConverterInterface
{
/**
* @param string|GroupSequence|array<string|GroupSequence>|null $groups The validation groups to validate the structured output in, or null for the validator's default group
*/
public function __construct(
private readonly ResultConverterInterface $innerConverter,
private readonly ValidatorInterface $validator,
private readonly string|GroupSequence|array|null $groups = null,
) {
}

Expand All @@ -47,7 +52,7 @@ public function convert(RawResultInterface $result, array $options = []): Result
if ($innerResult instanceof StreamResult) {
foreach ($innerResult->getListeners() as $listener) {
if ($listener instanceof PartialObjectStreamListener) {
$listener->setValidator($this->validator);
$listener->setValidator($this->validator, $this->groups);
}
}

Expand All @@ -59,7 +64,7 @@ public function convert(RawResultInterface $result, array $options = []): Result
}

$structure = $innerResult->getContent();
$violations = $this->validator->validate($structure);
$violations = $this->validator->validate($structure, null, $this->groups);

if (0 !== \count($violations)) {
throw new ValidationException($violations);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@

namespace Symfony\AI\Platform\StructuredOutput\Validator;

use Symfony\AI\Platform\Event\InvocationEvent;
use Symfony\AI\Platform\Event\ResultEvent;
use Symfony\AI\Platform\Exception\InvalidArgumentException;
use Symfony\AI\Platform\Result\DeferredResult;
use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Validator\Constraints\GroupSequence;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\Validator\ValidatorInterface;

Expand All @@ -23,21 +26,53 @@
*/
final class ValidatorSubscriber implements EventSubscriberInterface
{
public const VALIDATION_GROUPS = 'validation_groups';

private readonly ValidatorInterface $validator;

/** @var string|GroupSequence|array<string|GroupSequence>|null */
private string|GroupSequence|array|null $invocationGroups = null;

/**
* @param string|GroupSequence|array<string|GroupSequence>|null $groups The validation groups to validate the structured output in unless the "validation_groups" option is passed, or null for the validator's default group

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not very happy with this union type.. WDYT?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fine by me - it's honest: it's overloading. we take a bullet for DX

*/
public function __construct(
?ValidatorInterface $validator = null,
private readonly string|GroupSequence|array|null $groups = null,
) {
$this->validator = $validator ?? Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
}

public static function getSubscribedEvents(): array
{
return [
InvocationEvent::class => 'processInput',
ResultEvent::class => ['processResult', -10],
];
}

public function processInput(InvocationEvent $event): void
{
$options = $event->getOptions();
$this->invocationGroups = null;

if (!\array_key_exists(self::VALIDATION_GROUPS, $options)) {
return;
}

$groups = $options[self::VALIDATION_GROUPS];

if (null !== $groups && !\is_string($groups) && !\is_array($groups) && !$groups instanceof GroupSequence) {
throw new InvalidArgumentException('The "validation_groups" option must be a string, an array or a GroupSequence.');
}

$this->invocationGroups = $groups;

// Consume the option, so it is not forwarded to the provider
unset($options[self::VALIDATION_GROUPS]);

@marco-jouwweb marco-jouwweb Sep 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chr-hertel question about this mechanic in general: I wonder if unsetting to not forward them is not going to bite in long term. I could imagine being able to properly distinguish between options & provider parameters would be cleaner. What do you think about introducing typed option keys, or dividing the array in 2 keys (e.g. a separate "provider" key in the array which contains all options that should be forwarded to the provider) at a later stage?

Just a nitpick but interested in your opinion:) I can imagine this does bring additional complexity because each provider has its own options, but I think it should be doable at one point.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I def see where you're coming from, and that's always a bit painful with those array-wildcards or metadata bags, that buy us flexibility in abstractions. I think for now I'd prefer to be lazy on that and we can re-evaluate when it bites us - but "good instinct" like Claude would write :D

$event->setOptions($options);
}

public function processResult(ResultEvent $event): void
{
$options = $event->getOptions();
Expand All @@ -50,6 +85,7 @@ public function processResult(ResultEvent $event): void
$converter = new ValidatorResultConverter(
$deferred->getResultConverter(),
$this->validator,
$this->invocationGroups ?? $this->groups,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need that $invocationGroups property - we have $event->getOptions() here as well

@marco-jouwweb marco-jouwweb Sep 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

processInput() unsets validation_groups so it is not sent to the provider. Provider::invoke() then builds the ResultEvent from those stripped options, so processResult() never sees the key on $event->getOptions().

$invocationGroups keeps the per-call groups between the two events. $this->groups is only the subscriber default.

(This is the same split PlatformSubscriber makes for response_format: the invocation listener rewrites the options into something the provider understands, and keeps the original meaning in instance state for the result listener.)

);

$event->setDeferredResult(new DeferredResult($converter, $deferred->getRawResult(), $options));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\AI\Platform\Tests\Fixtures\StructuredOutput;

use Symfony\Component\Validator\Constraints as Assert;

final class UserWithGroupedConstraints
{
#[Assert\Positive]
public int $id = 0;
#[Assert\NotBlank(groups: ['strict'])]
public string $name = '';
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@
use Symfony\AI\Platform\StructuredOutput\Streaming\PartialObjectStreamListener;
use Symfony\AI\Platform\Tests\Fixtures\StructuredOutput\City;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\Mapping\ClassMetadata;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\Validator\ValidatorInterface;

final class PartialObjectStreamListenerTest extends TestCase
{
Expand Down Expand Up @@ -170,6 +172,23 @@ public function loadClassMetadata(ClassMetadata $metadata): bool
$listener->getFinalObjectResult();
}

public function testValidatorReceivesConfiguredGroups()
{
$validator = $this->createMock(ValidatorInterface::class);
$validator->expects($this->once())
->method('validate')
->with($this->isInstanceOf(City::class), null, ['strict'])
->willReturn(new ConstraintViolationList());

$listener = new PartialObjectStreamListener(new Serializer(), City::class);
$listener->setValidator($validator, ['strict']);

$stream = $this->buildStream(['{"name":"Berlin"}'], [$listener]);
iterator_to_array($stream->getContent(), false);

$this->assertNotNull($listener->getFinalObjectResult());
}

/**
* @param string[] $textChunks
* @param list<\Symfony\AI\Platform\Result\Stream\ListenerInterface> $listeners
Expand Down
Loading
Loading