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 @@ + + +{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
+| {entry.crdate -> f:format.date(format: 'Y-m-d H:i:s')} | -
- |
-
- |
- {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 | -
- |
-
{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
-