diff --git a/Classes/Middleware/RequestLoggingMiddleware.php b/Classes/Middleware/RequestLoggingMiddleware.php index 5b27d68..67df8fc 100644 --- a/Classes/Middleware/RequestLoggingMiddleware.php +++ b/Classes/Middleware/RequestLoggingMiddleware.php @@ -71,7 +71,7 @@ private function logRequest( RequestContext $context, ): void { // Determine effective privacy level - $privacyLevel = $this->resolvePrivacyLevel($configuration); + $privacyLevel = $this->resolvePrivacyLevel($configuration, $request); if ($privacyLevel === PrivacyLevel::None) { return; } @@ -165,23 +165,28 @@ private function logRequest( } /** - * Resolve the effective privacy level from the provider config and user TSconfig. - * The stricter of the two wins. + * Resolve the effective privacy level from provider config, user TSconfig, + * and any per-request override carried on the request. The strictest of + * the three wins — an override can only escalate, never relax. */ - private function resolvePrivacyLevel(ProviderConfiguration $configuration): PrivacyLevel + private function resolvePrivacyLevel(ProviderConfiguration $configuration, AiRequestInterface $request): PrivacyLevel { - $configLevel = PrivacyLevel::fromString($configuration->privacyLevel); + $level = PrivacyLevel::fromString($configuration->privacyLevel); $user = $this->getBackendUser(); - if ($user === null || !method_exists($user, 'getTSConfig')) { - return $configLevel; + if ($user !== null && method_exists($user, 'getTSConfig')) { + $userLevel = PrivacyLevel::fromString( + (string)($user->getTSConfig()['aim.']['privacyLevel'] ?? 'standard') + ); + $level = $level->strictest($userLevel); } - $userLevel = PrivacyLevel::fromString( - (string)($user->getTSConfig()['aim.']['privacyLevel'] ?? 'standard') - ); + $requestOverride = $request->getPrivacyLevelOverride(); + if ($requestOverride !== null) { + $level = $level->strictest($requestOverride); + } - return $configLevel->strictest($userLevel); + return $level; } private function extractMetadata(AiRequestInterface $request): array diff --git a/Classes/Request/AiRequestInterface.php b/Classes/Request/AiRequestInterface.php index 8db8fc3..2fe6d65 100644 --- a/Classes/Request/AiRequestInterface.php +++ b/Classes/Request/AiRequestInterface.php @@ -13,6 +13,7 @@ namespace B13\Aim\Request; use B13\Aim\Domain\Model\ProviderConfiguration; +use B13\Aim\Governance\PrivacyLevel; /** * Contract for all AI request objects passed through the middleware pipeline. @@ -30,4 +31,31 @@ public function getConfiguration(): ProviderConfiguration; * Used during fallback to swap the API key/model without reflection. */ public function withConfiguration(ProviderConfiguration $configuration): static; + + /** + * Return a copy of this request with additional metadata merged in. + * + * Example: + * $request = $request->withMetadata(['my_extension.context' => $value]); + * return $next->handle($request, $provider, $configuration); + */ + public function withMetadata(array $additional): static; + + /** + * Return a copy of this request with a per-request privacy level override. + * + * Folds into the existing strictness ladder (provider config → user + * TSconfig → per-request), strictest wins. The override can only + * escalate — it cannot relax a stricter level set upstream. + * + * Example (suppress logging for a health-check request): + * $request = $request->withPrivacyLevel(PrivacyLevel::None); + */ + public function withPrivacyLevel(PrivacyLevel $level): static; + + /** + * The privacy-level override carried on the request, or null when none + * has been set. Returned for the logging middleware's strictness ladder. + */ + public function getPrivacyLevelOverride(): ?PrivacyLevel; } diff --git a/Classes/Request/ConversationRequest.php b/Classes/Request/ConversationRequest.php index d290d23..e511710 100644 --- a/Classes/Request/ConversationRequest.php +++ b/Classes/Request/ConversationRequest.php @@ -13,6 +13,7 @@ namespace B13\Aim\Request; use B13\Aim\Domain\Model\ProviderConfiguration; +use B13\Aim\Governance\PrivacyLevel; use B13\Aim\Request\Message\AbstractMessage; final class ConversationRequest implements AiRequestInterface @@ -30,6 +31,7 @@ public function __construct( public readonly string $user = '', public readonly array $metadata = [], public readonly bool $stream = false, + public readonly ?PrivacyLevel $privacyLevelOverride = null, ) {} public function getConfiguration(): ProviderConfiguration @@ -44,4 +46,25 @@ public function withConfiguration(ProviderConfiguration $configuration): static ['configuration' => $configuration], )); } + + public function withMetadata(array $additional): static + { + return new static(...array_merge( + get_object_vars($this), + ['metadata' => [...$this->metadata, ...$additional]], + )); + } + + public function withPrivacyLevel(PrivacyLevel $level): static + { + return new static(...array_merge( + get_object_vars($this), + ['privacyLevelOverride' => $level], + )); + } + + public function getPrivacyLevelOverride(): ?PrivacyLevel + { + return $this->privacyLevelOverride; + } } diff --git a/Classes/Request/EmbeddingRequest.php b/Classes/Request/EmbeddingRequest.php index 4b5b6a9..9baa864 100644 --- a/Classes/Request/EmbeddingRequest.php +++ b/Classes/Request/EmbeddingRequest.php @@ -13,6 +13,7 @@ namespace B13\Aim\Request; use B13\Aim\Domain\Model\ProviderConfiguration; +use B13\Aim\Governance\PrivacyLevel; final class EmbeddingRequest implements AiRequestInterface { @@ -26,6 +27,7 @@ public function __construct( public readonly int $dimensions = 0, public readonly string $user = '', public readonly array $metadata = [], + public readonly ?PrivacyLevel $privacyLevelOverride = null, ) {} public function getConfiguration(): ProviderConfiguration @@ -40,4 +42,25 @@ public function withConfiguration(ProviderConfiguration $configuration): static ['configuration' => $configuration], )); } + + public function withMetadata(array $additional): static + { + return new static(...array_merge( + get_object_vars($this), + ['metadata' => [...$this->metadata, ...$additional]], + )); + } + + public function withPrivacyLevel(PrivacyLevel $level): static + { + return new static(...array_merge( + get_object_vars($this), + ['privacyLevelOverride' => $level], + )); + } + + public function getPrivacyLevelOverride(): ?PrivacyLevel + { + return $this->privacyLevelOverride; + } } diff --git a/Classes/Request/TextGenerationRequest.php b/Classes/Request/TextGenerationRequest.php index f4a050a..33e02bd 100644 --- a/Classes/Request/TextGenerationRequest.php +++ b/Classes/Request/TextGenerationRequest.php @@ -13,6 +13,7 @@ namespace B13\Aim\Request; use B13\Aim\Domain\Model\ProviderConfiguration; +use B13\Aim\Governance\PrivacyLevel; final class TextGenerationRequest implements AiRequestInterface { @@ -25,6 +26,7 @@ public function __construct( public readonly float $temperature = 0.2, public readonly string $user = '', public readonly array $metadata = [], + public readonly ?PrivacyLevel $privacyLevelOverride = null, ) {} public function getConfiguration(): ProviderConfiguration @@ -39,4 +41,25 @@ public function withConfiguration(ProviderConfiguration $configuration): static ['configuration' => $configuration], )); } + + public function withMetadata(array $additional): static + { + return new static(...array_merge( + get_object_vars($this), + ['metadata' => [...$this->metadata, ...$additional]], + )); + } + + public function withPrivacyLevel(PrivacyLevel $level): static + { + return new static(...array_merge( + get_object_vars($this), + ['privacyLevelOverride' => $level], + )); + } + + public function getPrivacyLevelOverride(): ?PrivacyLevel + { + return $this->privacyLevelOverride; + } } diff --git a/Classes/Request/ToolCallingRequest.php b/Classes/Request/ToolCallingRequest.php index 6f61d40..fdf6340 100644 --- a/Classes/Request/ToolCallingRequest.php +++ b/Classes/Request/ToolCallingRequest.php @@ -13,6 +13,7 @@ namespace B13\Aim\Request; use B13\Aim\Domain\Model\ProviderConfiguration; +use B13\Aim\Governance\PrivacyLevel; use B13\Aim\Request\Message\AbstractMessage; /** @@ -40,6 +41,7 @@ public function __construct( public readonly float $temperature = 0.7, public readonly string $user = '', public readonly array $metadata = [], + public readonly ?PrivacyLevel $privacyLevelOverride = null, ) {} public function getConfiguration(): ProviderConfiguration @@ -54,4 +56,25 @@ public function withConfiguration(ProviderConfiguration $configuration): static ['configuration' => $configuration], )); } + + public function withMetadata(array $additional): static + { + return new static(...array_merge( + get_object_vars($this), + ['metadata' => [...$this->metadata, ...$additional]], + )); + } + + public function withPrivacyLevel(PrivacyLevel $level): static + { + return new static(...array_merge( + get_object_vars($this), + ['privacyLevelOverride' => $level], + )); + } + + public function getPrivacyLevelOverride(): ?PrivacyLevel + { + return $this->privacyLevelOverride; + } } diff --git a/Classes/Request/TranslationRequest.php b/Classes/Request/TranslationRequest.php index 9480cf4..a644062 100644 --- a/Classes/Request/TranslationRequest.php +++ b/Classes/Request/TranslationRequest.php @@ -13,6 +13,7 @@ namespace B13\Aim\Request; use B13\Aim\Domain\Model\ProviderConfiguration; +use B13\Aim\Governance\PrivacyLevel; final class TranslationRequest implements AiRequestInterface { @@ -26,6 +27,7 @@ public function __construct( public readonly float $temperature = 0.2, public readonly string $user = '', public readonly array $metadata = [], + public readonly ?PrivacyLevel $privacyLevelOverride = null, ) {} public function getConfiguration(): ProviderConfiguration @@ -40,4 +42,25 @@ public function withConfiguration(ProviderConfiguration $configuration): static ['configuration' => $configuration], )); } + + public function withMetadata(array $additional): static + { + return new static(...array_merge( + get_object_vars($this), + ['metadata' => [...$this->metadata, ...$additional]], + )); + } + + public function withPrivacyLevel(PrivacyLevel $level): static + { + return new static(...array_merge( + get_object_vars($this), + ['privacyLevelOverride' => $level], + )); + } + + public function getPrivacyLevelOverride(): ?PrivacyLevel + { + return $this->privacyLevelOverride; + } } diff --git a/Classes/Request/VisionRequest.php b/Classes/Request/VisionRequest.php index b7a340f..441a1a9 100644 --- a/Classes/Request/VisionRequest.php +++ b/Classes/Request/VisionRequest.php @@ -13,6 +13,7 @@ namespace B13\Aim\Request; use B13\Aim\Domain\Model\ProviderConfiguration; +use B13\Aim\Governance\PrivacyLevel; final class VisionRequest implements AiRequestInterface { @@ -26,6 +27,7 @@ public function __construct( public readonly float $temperature = 0.2, public readonly string $user = '', public readonly array $metadata = [], + public readonly ?PrivacyLevel $privacyLevelOverride = null, ) {} public function getConfiguration(): ProviderConfiguration @@ -40,4 +42,25 @@ public function withConfiguration(ProviderConfiguration $configuration): static ['configuration' => $configuration], )); } + + public function withMetadata(array $additional): static + { + return new static(...array_merge( + get_object_vars($this), + ['metadata' => [...$this->metadata, ...$additional]], + )); + } + + public function withPrivacyLevel(PrivacyLevel $level): static + { + return new static(...array_merge( + get_object_vars($this), + ['privacyLevelOverride' => $level], + )); + } + + public function getPrivacyLevelOverride(): ?PrivacyLevel + { + return $this->privacyLevelOverride; + } } diff --git a/README.md b/README.md index 52d2a2e..0c906b9 100644 --- a/README.md +++ b/README.md @@ -457,6 +457,60 @@ class MyMiddleware implements AiMiddlewareInterface } ``` +### Enriching the request log + +Every request DTO carries a `metadata` array that lands in the `metadata` JSON column of `tx_aim_request_log`. To attach extension-specific context, enrich it from your custom middleware via `$request->withMetadata([...])` and forward the new instance. The original request stays immutable; downstream middlewares see the merged metadata: + +```php +#[AsAiMiddleware(priority: 80)] +final class MyExtensionContextMiddleware implements AiMiddlewareInterface +{ + public function process( + AiRequestInterface $request, + AiProviderInterface $provider, + ProviderConfiguration $configuration, + AiMiddlewareHandler $next, + ): TextResponse { + $request = $request->withMetadata([ + 'my_ext.additional' => 'info', + ]); + return $next->handle($request, $provider, $configuration); + } +} +``` + +### Detailed / parallel logging + +For richer or separate logging, register a middleware at a lower priority than `RequestLoggingMiddleware` (use a priority below `-700`). It sees the response, the resolved `$configuration`, and any metadata enriched by earlier middlewares, and is free to write wherever it likes without touching `tx_aim_request_log`: + +```php +#[AsAiMiddleware(priority: -750)] +final class MyExtensionDetailedLogger implements AiMiddlewareInterface +{ + public function __construct(private readonly MyExtensionLogRepository $repository) {} + + public function process( + AiRequestInterface $request, + AiProviderInterface $provider, + ProviderConfiguration $configuration, + AiMiddlewareHandler $next, + ): TextResponse { + $response = $next->handle($request, $provider, $configuration); + $this->repository->record([ + 'provider' => $configuration->providerIdentifier, + 'model' => $response->usage->modelUsed, + 'metadata' => $request->metadata, + 'tokens' => $response->usage->getTotalTokens(), + 'cost' => $response->usage->cost, + // ...any custom shape you need + ]); + return $response; + } +} +``` + +The middleware pipeline is intentionally the only logging extension point: it gives you the request, response, configuration, and middleware context in one place, plus full control over where the data goes. + ### Built-in Middleware | Middleware | Priority | Purpose | diff --git a/Resources/Private/Partials/RequestLog/Filters.html b/Resources/Private/Partials/RequestLog/Filters.html new file mode 100644 index 0000000..ce316d4 --- /dev/null +++ b/Resources/Private/Partials/RequestLog/Filters.html @@ -0,0 +1,58 @@ + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + diff --git a/Resources/Private/Partials/RequestLog/Row.html b/Resources/Private/Partials/RequestLog/Row.html new file mode 100644 index 0000000..5098435 --- /dev/null +++ b/Resources/Private/Partials/RequestLog/Row.html @@ -0,0 +1,60 @@ + + + + {entry.crdate -> f:format.date(format: 'Y-m-d H:i:s')} + + + + + + + {entry.extension_key} + + - + + + + + {userMap.{entry.user_id}} + #{entry.user_id} + + + {entry.request_type} + {entry.provider_identifier} + + {entry.model_used -> f:or(alternative: entry.model_requested)} + +
requested: {entry.model_requested} +
+ + + {entry.total_tokens -> f:format.number(decimals: 0, thousandsSeparator: ',')} +
{entry.prompt_tokens} / {entry.completion_tokens} + +
cached: {entry.cached_tokens} +
+ +
reasoning: {entry.reasoning_tokens} +
+ + {entry.cost -> f:format.number(decimals: 6)} + {entry.duration_ms -> f:format.number(decimals: 0, thousandsSeparator: ',')} ms + + + + + + + + + + + + + + + + + + + diff --git a/Resources/Private/Partials/RequestLog/Stats.html b/Resources/Private/Partials/RequestLog/Stats.html new file mode 100644 index 0000000..b9f6a9a --- /dev/null +++ b/Resources/Private/Partials/RequestLog/Stats.html @@ -0,0 +1,68 @@ + + +
+
+
+ + + +
+ +
+

{statistics.total_requests -> f:format.number(decimals: 0, thousandsSeparator: ',')}

+
+
+
+
+
+ + + +
+ +
+

{statistics.total_cost -> f:format.number(decimals: 4)}

+
+
+
+
+
+ + + +
+ +
+

{statistics.total_tokens -> f:format.number(decimals: 0, thousandsSeparator: ',')}

+
+
+
+
+
+ + + +
+ +
+

{statistics.success_rate}%

+
+
+
+
+
+ + + +
+ +
+

{statistics.avg_duration_ms -> f:format.number(decimals: 0, thousandsSeparator: ',')} ms

+
+
+
+
+ + diff --git a/Resources/Private/Partials/RequestLog/Table.html b/Resources/Private/Partials/RequestLog/Table.html new file mode 100644 index 0000000..4b4b44e --- /dev/null +++ b/Resources/Private/Partials/RequestLog/Table.html @@ -0,0 +1,27 @@ + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + diff --git a/Resources/Private/Templates/Aim/RequestLog.html b/Resources/Private/Templates/Aim/RequestLog.html index e3aa2ec..e082d8d 100644 --- a/Resources/Private/Templates/Aim/RequestLog.html +++ b/Resources/Private/Templates/Aim/RequestLog.html @@ -1,5 +1,4 @@ @@ -13,89 +12,12 @@ - - + + -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{entry.crdate -> f:format.date(format: 'Y-m-d H:i:s')} - - - - - - {entry.extension_key} - - - - - - - {userMap.{entry.user_id}} - #{entry.user_id} - - {entry.request_type}{entry.provider_identifier} - {entry.model_used -> f:or(alternative: entry.model_requested)} - -
requested: {entry.model_requested} -
-
- {entry.total_tokens -> f:format.number(decimals: 0, thousandsSeparator: ',')} -
{entry.prompt_tokens} / {entry.completion_tokens} - -
cached: {entry.cached_tokens} -
- -
reasoning: {entry.reasoning_tokens} -
-
{entry.cost -> f:format.number(decimals: 6)}{entry.duration_ms -> f:format.number(decimals: 0, thousandsSeparator: ',')} ms - - - - - - - - - - - - - - -
-
+
@@ -127,126 +49,4 @@ - -
-
-
- - - -
- -
-

{statistics.total_requests -> f:format.number(decimals: 0, thousandsSeparator: ',')}

-
-
-
-
-
- - - -
- -
-

{statistics.total_cost -> f:format.number(decimals: 4)}

-
-
-
-
-
- - - -
- -
-

{statistics.total_tokens -> f:format.number(decimals: 0, thousandsSeparator: ',')}

-
-
-
-
-
- - - -
- -
-

{statistics.success_rate}%

-
-
-
-
-
- - - -
- -
-

{statistics.avg_duration_ms -> f:format.number(decimals: 0, thousandsSeparator: ',')} ms

-
-
-
-
-
- - -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- diff --git a/Tests/Functional/Governance/PrivacyLoggingTest.php b/Tests/Functional/Governance/PrivacyLoggingTest.php index 89e5ae2..0aabc6b 100644 --- a/Tests/Functional/Governance/PrivacyLoggingTest.php +++ b/Tests/Functional/Governance/PrivacyLoggingTest.php @@ -14,6 +14,7 @@ use B13\Aim\Domain\Model\ProviderConfiguration; use B13\Aim\Domain\Repository\RequestLogRepository; +use B13\Aim\Governance\PrivacyLevel; use B13\Aim\Middleware\AiMiddlewareHandler; use B13\Aim\Middleware\RequestLoggingMiddleware; use B13\Aim\Provider\AiProviderInterface; @@ -188,6 +189,88 @@ public function userTsconfigCanEscalatePrivacy(): void self::assertSame('', $rows[0]['response_content']); } + #[Test] + public function requestOverrideCanEscalatePrivacyToNone(): void + { + // Config says standard, request says none → none wins, nothing logged. + $config = $this->createConfig('standard'); + $request = (new TextGenerationRequest( + configuration: $config, + prompt: 'Health check', + ))->withPrivacyLevel(PrivacyLevel::None); + + $this->createLoggingMiddleware()->process( + $request, + $this->createMock(AiProviderInterface::class), + $config, + $this->respondWith('Pong'), + ); + + $count = $this->getConnectionPool() + ->getConnectionForTable('tx_aim_request_log') + ->count('*', 'tx_aim_request_log', []); + + self::assertSame(0, $count); + } + + #[Test] + public function requestOverrideCannotRelaxConfigPrivacy(): void + { + // Config says none, request says standard → none still wins (stricter), + // nothing logged. The request can only escalate, never relax. + $config = $this->createConfig('none'); + $request = (new TextGenerationRequest( + configuration: $config, + prompt: 'Top secret', + ))->withPrivacyLevel(PrivacyLevel::Standard); + + $this->createLoggingMiddleware()->process( + $request, + $this->createMock(AiProviderInterface::class), + $config, + $this->respondWith('Classified'), + ); + + $count = $this->getConnectionPool() + ->getConnectionForTable('tx_aim_request_log') + ->count('*', 'tx_aim_request_log', []); + + self::assertSame(0, $count); + } + + #[Test] + public function requestOverrideCannotRelaxUserTsconfig(): void + { + // User TSconfig says reduced, request says standard → reduced still wins. + $user = $this->createMock(\TYPO3\CMS\Core\Authentication\BackendUserAuthentication::class); + $user->method('getTSConfig')->willReturn([ + 'aim.' => ['privacyLevel' => 'reduced'], + ]); + $GLOBALS['BE_USER'] = $user; + + $config = $this->createConfig('standard'); + $request = (new TextGenerationRequest( + configuration: $config, + prompt: 'Should still be redacted', + ))->withPrivacyLevel(PrivacyLevel::Standard); + + $this->createLoggingMiddleware()->process( + $request, + $this->createMock(AiProviderInterface::class), + $config, + $this->respondWith('Also redacted'), + ); + + $rows = $this->getConnectionPool() + ->getConnectionForTable('tx_aim_request_log') + ->select(['*'], 'tx_aim_request_log') + ->fetchAllAssociative(); + + self::assertCount(1, $rows); + self::assertSame('', $rows[0]['request_prompt']); + self::assertSame('', $rows[0]['response_content']); + } + #[Test] public function userTsconfigCannotDowngradePrivacy(): void { diff --git a/Tests/Unit/Request/WithMetadataTest.php b/Tests/Unit/Request/WithMetadataTest.php new file mode 100644 index 0000000..f9d35ac --- /dev/null +++ b/Tests/Unit/Request/WithMetadataTest.php @@ -0,0 +1,140 @@ + 1, 'ai_provider' => 'test']); + + yield 'TextGenerationRequest' => [ + new TextGenerationRequest( + configuration: $configuration, + prompt: 'Hello', + metadata: ['existing' => 'value', 'shared' => 'old'], + ), + ]; + yield 'VisionRequest' => [ + new VisionRequest( + configuration: $configuration, + imageData: 'b64', + mimeType: 'image/jpeg', + prompt: 'Describe', + metadata: ['existing' => 'value', 'shared' => 'old'], + ), + ]; + yield 'ConversationRequest' => [ + new ConversationRequest( + configuration: $configuration, + messages: [new UserMessage('hi')], + metadata: ['existing' => 'value', 'shared' => 'old'], + ), + ]; + yield 'TranslationRequest' => [ + new TranslationRequest( + configuration: $configuration, + text: 'Hello', + sourceLanguage: 'en', + targetLanguage: 'de', + metadata: ['existing' => 'value', 'shared' => 'old'], + ), + ]; + yield 'EmbeddingRequest' => [ + new EmbeddingRequest( + configuration: $configuration, + input: ['text'], + metadata: ['existing' => 'value', 'shared' => 'old'], + ), + ]; + yield 'ToolCallingRequest' => [ + new ToolCallingRequest( + configuration: $configuration, + messages: [new UserMessage('hi')], + tools: [new ToolDefinition(name: 't', description: 'd', parameters: [])], + metadata: ['existing' => 'value', 'shared' => 'old'], + ), + ]; + } + + #[Test] + #[DataProvider('requestProvider')] + public function returnsNewInstanceLeavingOriginalUntouched(AiRequestInterface $request): void + { + $enriched = $request->withMetadata(['added' => 1]); + + self::assertNotSame($request, $enriched); + self::assertSame(['existing' => 'value', 'shared' => 'old'], $this->metadataOf($request)); + } + + #[Test] + #[DataProvider('requestProvider')] + public function mergesAdditionalKeysIntoExistingMetadata(AiRequestInterface $request): void + { + $enriched = $request->withMetadata(['added' => 1, 'shared' => 'new']); + + self::assertSame( + ['existing' => 'value', 'shared' => 'new', 'added' => 1], + $this->metadataOf($enriched), + ); + } + + #[Test] + #[DataProvider('requestProvider')] + public function preservesAllOtherFields(AiRequestInterface $request): void + { + $enriched = $request->withMetadata(['added' => 1]); + + $original = get_object_vars($request); + $after = get_object_vars($enriched); + unset($original['metadata'], $after['metadata']); + self::assertSame($original, $after); + } + + #[Test] + #[DataProvider('requestProvider')] + public function chainedCallsAccumulateAcrossLaterWins(AiRequestInterface $request): void + { + $enriched = $request + ->withMetadata(['first' => 1, 'shared' => 'mid']) + ->withMetadata(['second' => 2, 'shared' => 'last']); + + self::assertSame( + ['existing' => 'value', 'shared' => 'last', 'first' => 1, 'second' => 2], + $this->metadataOf($enriched), + ); + } + + private function metadataOf(AiRequestInterface $request): array + { + return get_object_vars($request)['metadata']; + } +}