From 0dbe637241d1c53be7a926106cb76e09c9445cfc Mon Sep 17 00:00:00 2001 From: Marco van Angeren Date: Mon, 14 Sep 2026 13:37:01 +0200 Subject: [PATCH] [Platform][AI Bundle] Make the structured output validation groups configurable `ValidatorSubscriber` always validated the populated structured output object in the `Default` validation group, which collides with domain objects that already carry `Default` constraints for other purposes. Add an optional `groups` argument to `ValidatorSubscriber`, passed through `ValidatorResultConverter` to the buffered validation call and to `PartialObjectStreamListener` for the final object of a streamed result. A `validation_groups` invocation option overrides them for a single call; the subscriber consumes it so it is never forwarded to the provider. The AI Bundle exposes the default as `ai.structured_output.validation_groups`. --- docs/bundles/ai-bundle.rst | 29 ++++++ docs/components/platform.rst | 18 ++++ src/ai-bundle/CHANGELOG.md | 5 + src/ai-bundle/config/options.php | 10 ++ src/ai-bundle/src/AiBundle.php | 9 ++ .../DependencyInjection/AiBundleTest.php | 32 +++++++ src/platform/CHANGELOG.md | 1 + .../Streaming/PartialObjectStreamListener.php | 17 +++- .../Validator/ValidatorResultConverter.php | 9 +- .../Validator/ValidatorSubscriber.php | 36 +++++++ .../UserWithGroupedConstraints.php | 22 +++++ .../PartialObjectStreamListenerTest.php | 19 ++++ .../ValidatorResultConverterTest.php | 50 ++++++++++ .../Validator/ValidatorSubscriberTest.php | 95 ++++++++++++++++++- 14 files changed, 341 insertions(+), 11 deletions(-) create mode 100644 src/platform/tests/Fixtures/StructuredOutput/UserWithGroupedConstraints.php diff --git a/docs/bundles/ai-bundle.rst b/docs/bundles/ai-bundle.rst index 3553480586..ed29b79518 100644 --- a/docs/bundles/ai-bundle.rst +++ b/docs/bundles/ai-bundle.rst @@ -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 --------------------------- diff --git a/docs/components/platform.rst b/docs/components/platform.rst index bd815fb8a9..eb69dd39b8 100644 --- a/docs/components/platform.rst +++ b/docs/components/platform.rst @@ -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 ~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/ai-bundle/CHANGELOG.md b/src/ai-bundle/CHANGELOG.md index fb7f4e4e51..7b003e57ee 100644 --- a/src/ai-bundle/CHANGELOG.md +++ b/src/ai-bundle/CHANGELOG.md @@ -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 ---- diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index acec7eba27..6e86643fff 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -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() diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index c91f06379c..9cca72323f 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -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'); diff --git a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php index 719523a9c2..e59fccc86d 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -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([ @@ -9108,6 +9137,9 @@ private function getFullConfig(): array { return [ 'ai' => [ + 'structured_output' => [ + 'validation_groups' => ['ai'], + ], 'platform' => [ 'amazeeai' => [ 'api_key' => 'amazeeai_key_full', diff --git a/src/platform/CHANGELOG.md b/src/platform/CHANGELOG.md index 57aa82c234..4d7342804c 100644 --- a/src/platform/CHANGELOG.md +++ b/src/platform/CHANGELOG.md @@ -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 diff --git a/src/platform/src/StructuredOutput/Streaming/PartialObjectStreamListener.php b/src/platform/src/StructuredOutput/Streaming/PartialObjectStreamListener.php index 160b14cfdc..7f30047740 100644 --- a/src/platform/src/StructuredOutput/Streaming/PartialObjectStreamListener.php +++ b/src/platform/src/StructuredOutput/Streaming/PartialObjectStreamListener.php @@ -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; /** @@ -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 */ @@ -46,6 +48,9 @@ final class PartialObjectStreamListener extends AbstractStreamListener private ?ValidationException $validationException = null; private ?ValidatorInterface $validator = null; + /** @var string|GroupSequence|array|null */ + private string|GroupSequence|array|null $validationGroups = null; + private readonly SerializerInterface&DenormalizerInterface $serializer; /** @@ -59,9 +64,13 @@ public function __construct( $this->serializer = $serializer; } - public function setValidator(?ValidatorInterface $validator): void + /** + * @param string|GroupSequence|array|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 @@ -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); diff --git a/src/platform/src/StructuredOutput/Validator/ValidatorResultConverter.php b/src/platform/src/StructuredOutput/Validator/ValidatorResultConverter.php index e6118f8d62..3360e34772 100644 --- a/src/platform/src/StructuredOutput/Validator/ValidatorResultConverter.php +++ b/src/platform/src/StructuredOutput/Validator/ValidatorResultConverter.php @@ -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; /** @@ -29,9 +30,13 @@ */ final class ValidatorResultConverter implements ResultConverterInterface { + /** + * @param string|GroupSequence|array|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, ) { } @@ -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); } } @@ -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); diff --git a/src/platform/src/StructuredOutput/Validator/ValidatorSubscriber.php b/src/platform/src/StructuredOutput/Validator/ValidatorSubscriber.php index c541ce5a7d..ea8c34268b 100644 --- a/src/platform/src/StructuredOutput/Validator/ValidatorSubscriber.php +++ b/src/platform/src/StructuredOutput/Validator/ValidatorSubscriber.php @@ -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; @@ -23,10 +26,19 @@ */ final class ValidatorSubscriber implements EventSubscriberInterface { + public const VALIDATION_GROUPS = 'validation_groups'; + private readonly ValidatorInterface $validator; + /** @var string|GroupSequence|array|null */ + private string|GroupSequence|array|null $invocationGroups = null; + + /** + * @param string|GroupSequence|array|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 + */ public function __construct( ?ValidatorInterface $validator = null, + private readonly string|GroupSequence|array|null $groups = null, ) { $this->validator = $validator ?? Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator(); } @@ -34,10 +46,33 @@ public function __construct( 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]); + $event->setOptions($options); + } + public function processResult(ResultEvent $event): void { $options = $event->getOptions(); @@ -50,6 +85,7 @@ public function processResult(ResultEvent $event): void $converter = new ValidatorResultConverter( $deferred->getResultConverter(), $this->validator, + $this->invocationGroups ?? $this->groups, ); $event->setDeferredResult(new DeferredResult($converter, $deferred->getRawResult(), $options)); diff --git a/src/platform/tests/Fixtures/StructuredOutput/UserWithGroupedConstraints.php b/src/platform/tests/Fixtures/StructuredOutput/UserWithGroupedConstraints.php new file mode 100644 index 0000000000..6f78007db8 --- /dev/null +++ b/src/platform/tests/Fixtures/StructuredOutput/UserWithGroupedConstraints.php @@ -0,0 +1,22 @@ + + * + * 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 = ''; +} diff --git a/src/platform/tests/StructuredOutput/Streaming/PartialObjectStreamListenerTest.php b/src/platform/tests/StructuredOutput/Streaming/PartialObjectStreamListenerTest.php index 7e3dae338c..5bdac08832 100644 --- a/src/platform/tests/StructuredOutput/Streaming/PartialObjectStreamListenerTest.php +++ b/src/platform/tests/StructuredOutput/Streaming/PartialObjectStreamListenerTest.php @@ -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 { @@ -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 diff --git a/src/platform/tests/StructuredOutput/Validator/ValidatorResultConverterTest.php b/src/platform/tests/StructuredOutput/Validator/ValidatorResultConverterTest.php index 061e27f2b8..aee9ea4f65 100644 --- a/src/platform/tests/StructuredOutput/Validator/ValidatorResultConverterTest.php +++ b/src/platform/tests/StructuredOutput/Validator/ValidatorResultConverterTest.php @@ -19,7 +19,11 @@ use Symfony\AI\Platform\ResultConverterInterface; use Symfony\AI\Platform\StructuredOutput\Validator\ValidatorResultConverter; use Symfony\AI\Platform\Tests\Fixtures\StructuredOutput\UserWithConstraints; +use Symfony\AI\Platform\Tests\Fixtures\StructuredOutput\UserWithGroupedConstraints; +use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Component\Validator\ConstraintViolationListInterface; use Symfony\Component\Validator\Validation; +use Symfony\Component\Validator\Validator\ValidatorInterface; final class ValidatorResultConverterTest extends TestCase { @@ -67,6 +71,52 @@ public function testConvertThrowsOnValidationError() $converter->convert($rawResult, []); } + public function testConvertPassesGroupsToValidator() + { + $user = new UserWithGroupedConstraints(); + + $validator = $this->createMock(ValidatorInterface::class); + $validator->expects($this->once()) + ->method('validate') + ->with($this->identicalTo($user), null, ['strict']) + ->willReturn(new ConstraintViolationList()); + + $innerConverter = $this->createStub(ResultConverterInterface::class); + $innerConverter->method('convert')->willReturn(new ObjectResult($user)); + + $converter = new ValidatorResultConverter($innerConverter, $validator, ['strict']); + + $result = $converter->convert($this->createStub(RawResultInterface::class)); + $this->assertInstanceOf(ObjectResult::class, $result); + $this->assertSame($user, $result->getContent()); + } + + public function testConvertValidatesOnlyConfiguredGroups() + { + $validator = Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->getValidator(); + + $user = new UserWithGroupedConstraints(); + $user->id = 0; // Violates Positive in the "Default" group + $user->name = ''; // Violates NotBlank in the "strict" group + + $innerConverter = $this->createStub(ResultConverterInterface::class); + $innerConverter->method('convert')->willReturn(new ObjectResult($user)); + + $converter = new ValidatorResultConverter($innerConverter, $validator, ['strict']); + + try { + $converter->convert($this->createStub(RawResultInterface::class)); + $this->fail('Expected a ValidationException to be thrown.'); + } catch (ValidationException $e) { + $violations = $e->getViolations(); + $this->assertInstanceOf(ConstraintViolationListInterface::class, $violations); + $this->assertCount(1, $violations); + $this->assertSame('name', $violations->get(0)->getPropertyPath()); + } + } + public function testSupportsDelegatesToInnerConverter() { $model = new Model('gpt-4o'); diff --git a/src/platform/tests/StructuredOutput/Validator/ValidatorSubscriberTest.php b/src/platform/tests/StructuredOutput/Validator/ValidatorSubscriberTest.php index eec2dc847f..559d063a26 100644 --- a/src/platform/tests/StructuredOutput/Validator/ValidatorSubscriberTest.php +++ b/src/platform/tests/StructuredOutput/Validator/ValidatorSubscriberTest.php @@ -12,7 +12,9 @@ namespace Symfony\AI\Platform\Tests\StructuredOutput\Validator; use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Event\InvocationEvent; use Symfony\AI\Platform\Event\ResultEvent; +use Symfony\AI\Platform\Exception\InvalidArgumentException; use Symfony\AI\Platform\Exception\ValidationException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\PlainConverter; @@ -28,12 +30,18 @@ use Symfony\AI\Platform\StructuredOutput\Validator\ValidatorResultConverter; use Symfony\AI\Platform\StructuredOutput\Validator\ValidatorSubscriber; use Symfony\AI\Platform\Tests\Fixtures\StructuredOutput\UserWithConstraints; +use Symfony\AI\Platform\Tests\Fixtures\StructuredOutput\UserWithGroupedConstraints; +use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Component\Validator\ConstraintViolationListInterface; +use Symfony\Component\Validator\Validation; +use Symfony\Component\Validator\Validator\ValidatorInterface; final class ValidatorSubscriberTest extends TestCase { public function testGetSubscribedEvents() { $this->assertSame([ + InvocationEvent::class => 'processInput', ResultEvent::class => ['processResult', -10], ], ValidatorSubscriber::getSubscribedEvents()); } @@ -131,10 +139,87 @@ public function testStreamingFinalObjectIsValidated() $deferred->asObject(); } + public function testValidationGroupsOptionOverridesConfiguredGroups() + { + $object = new UserWithGroupedConstraints(); + + $validator = $this->createMock(ValidatorInterface::class); + $validator->expects($this->once()) + ->method('validate') + ->with($this->identicalTo($object), null, ['strict']) + ->willReturn(new ConstraintViolationList()); + + $subscriber = new ValidatorSubscriber($validator, ['configured']); + + $model = new Model('gpt-4'); + $invocationEvent = new InvocationEvent($model, [], [ + PlatformSubscriber::RESPONSE_FORMAT => UserWithGroupedConstraints::class, + ValidatorSubscriber::VALIDATION_GROUPS => ['strict'], + ]); + + $subscriber->processInput($invocationEvent); + + // The option is consumed, so it is not forwarded to the provider + $options = $invocationEvent->getOptions(); + $this->assertSame([PlatformSubscriber::RESPONSE_FORMAT => UserWithGroupedConstraints::class], $options); + + $converter = $this->createStub(ResultConverterInterface::class); + $converter->method('supports')->willReturn(true); + $converter->method('convert')->willReturn(new ObjectResult($object)); + + $resultEvent = new ResultEvent($model, new DeferredResult($converter, new InMemoryRawResult(), $options), $options); + + $subscriber->processResult($resultEvent); + + $this->assertSame($object, $resultEvent->getDeferredResult()->asObject()); + } + + public function testInvalidValidationGroupsOptionThrows() + { + $subscriber = new ValidatorSubscriber(); + $event = new InvocationEvent(new Model('gpt-4'), [], [ValidatorSubscriber::VALIDATION_GROUPS => 42]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The "validation_groups" option must be a string, an array or a GroupSequence.'); + + $subscriber->processInput($event); + } + + public function testStreamingFinalObjectIsValidatedInConfiguredGroups() + { + $stream = $this->buildStreamWithListener([ + '{"id":0,"name":""}', // violates Positive in the "Default" group and NotBlank in the "strict" group + ], UserWithGroupedConstraints::class); + + $options = [PlatformSubscriber::RESPONSE_FORMAT => UserWithGroupedConstraints::class]; + $event = new ResultEvent( + new Model('gpt-4'), + new DeferredResult(new PlainConverter($stream), new InMemoryRawResult(), $options), + $options, + ); + + (new ValidatorSubscriber(groups: ['strict']))->processResult($event); + + $deferred = $event->getDeferredResult(); + // Trigger the converter chain, which injects the validator and its groups into the stream listener + $deferred->getResult(); + + try { + $deferred->asObject(); + $this->fail('Expected a ValidationException to be thrown.'); + } catch (ValidationException $e) { + $violations = $e->getViolations(); + $this->assertInstanceOf(ConstraintViolationListInterface::class, $violations); + $this->assertCount(1, $violations); + $this->assertSame('name', $violations->get(0)->getPropertyPath()); + } + } + /** - * @param string[] $chunks + * @param string[] $chunks + * @param class-string $outputType */ - private function buildStreamWithListener(array $chunks): StreamResult + private function buildStreamWithListener(array $chunks, string $outputType = UserWithConstraints::class): StreamResult { $generator = (static function () use ($chunks): \Generator { foreach ($chunks as $chunk) { @@ -144,13 +229,13 @@ private function buildStreamWithListener(array $chunks): StreamResult return new StreamResult( $generator, - [new PartialObjectStreamListener(new Serializer(), UserWithConstraints::class)], + [new PartialObjectStreamListener(new Serializer(), $outputType)], ); } - private function createValidator(): \Symfony\Component\Validator\Validator\ValidatorInterface + private function createValidator(): ValidatorInterface { - return \Symfony\Component\Validator\Validation::createValidatorBuilder() + return Validation::createValidatorBuilder() ->enableAttributeMapping() ->getValidator(); }