From 2631ff4bee1ab5e780859e898303a57fec6672f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 25 Apr 2026 23:29:22 +0200 Subject: [PATCH] Introduced subsystem to configure AI providers and allow services to select them dynamiclly --- config/packages/ai_lm_studio_platform.yaml | 3 +- config/packages/ai_open_router_platform.yaml | 2 +- src/Services/AI/AIPlatformRegistry.php | 95 +++++++++++++++++++ .../AI/AIPlatformSettingsInterface.php | 33 +++++++ src/Services/AI/AIPlatforms.php | 57 +++++++++++ .../Providers/AIInfoExtractor.php | 9 +- src/Settings/AISettings/AISettings.php | 43 +++++++++ src/Settings/AISettings/LMStudioSettings.php | 51 ++++++++++ .../AISettings/OpenRouterSettings.php | 50 ++++++++++ src/Settings/AppSettings.php | 4 + 10 files changed, 342 insertions(+), 5 deletions(-) create mode 100644 src/Services/AI/AIPlatformRegistry.php create mode 100644 src/Services/AI/AIPlatformSettingsInterface.php create mode 100644 src/Services/AI/AIPlatforms.php create mode 100644 src/Settings/AISettings/AISettings.php create mode 100644 src/Settings/AISettings/LMStudioSettings.php create mode 100644 src/Settings/AISettings/OpenRouterSettings.php diff --git a/config/packages/ai_lm_studio_platform.yaml b/config/packages/ai_lm_studio_platform.yaml index 0e6e4ac0..0e4287e0 100644 --- a/config/packages/ai_lm_studio_platform.yaml +++ b/config/packages/ai_lm_studio_platform.yaml @@ -1,3 +1,4 @@ ai: platform: - lmstudio: null + lmstudio: + host_url: '%env(string:settings:ai_lmstudio:hostURL)%' diff --git a/config/packages/ai_open_router_platform.yaml b/config/packages/ai_open_router_platform.yaml index bef68793..d34de592 100644 --- a/config/packages/ai_open_router_platform.yaml +++ b/config/packages/ai_open_router_platform.yaml @@ -1,4 +1,4 @@ ai: platform: openrouter: - api_key: '%env(OPENROUTER_API_KEY)%' + api_key: '%env(string:settings:ai_openrouter:apiKey)%' diff --git a/src/Services/AI/AIPlatformRegistry.php b/src/Services/AI/AIPlatformRegistry.php new file mode 100644 index 00000000..bf1d355c --- /dev/null +++ b/src/Services/AI/AIPlatformRegistry.php @@ -0,0 +1,95 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\AI; + +use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface; +use Symfony\AI\Platform\PlatformInterface; +use Symfony\Component\DependencyInjection\Attribute\AutowireIterator; + +final readonly class AIPlatformRegistry +{ + /** + * All registered platforms, indexed by their service tag name (e.g. "openrouter", "lmstudio") + * @var array $allPlatforms + */ + private array $allPlatforms; + + /** + * All registered platforms, indexed by their AIPlatforms enum value (e.g. AIPlatforms::OPENROUTER->value) + * @var array $enabledPlatforms + */ + private array $enabledPlatforms; + + public function __construct( + SettingsManagerInterface $settingsManager, + + #[AutowireIterator(tag: 'ai.platform', indexAttribute: 'name')] + iterable $platforms, + ) { + $this->allPlatforms = iterator_to_array($platforms); + + //Check which platforms are active based on the settings and store them in $activePlatforms + $tmp = []; + foreach (AIPlatforms::cases() as $platform) { + if (isset($this->allPlatforms[$platform->toServiceTagName()])) { + //Check if the platform is active by calling its isActive() on the settings class + $settings = $settingsManager->get($platform->toSettingsClass()); + if (!$settings->isAIPlatformEnabled()) { + continue; + } + + $tmp[$platform->value] = $this->allPlatforms[$platform->toServiceTagName()]; + } + } + $this->enabledPlatforms = $tmp; + } + + public function getPlatform(AIPlatforms $platform): PlatformInterface + { + if (!isset($this->enabledPlatforms[$platform->value])) { + throw new \InvalidArgumentException(sprintf('AI platform "%s" is not active or does not exist.', $platform->name)); + } + + return $this->enabledPlatforms[$platform->value]; + } + + /** + * Check if the given platform is active (i.e. it is registered and its settings are properly configured) + * @param AIPlatforms $platform + * @return bool + */ + public function isEnabled(AIPlatforms $platform): bool + { + return isset($this->enabledPlatforms[$platform->value]); + } + + /** + * Returns an array of all active platforms, indexed by their AIPlatforms enum value (e.g. AIPlatforms::OPENROUTER->value) + * @return PlatformInterface[] + */ + public function getEnabledPlatforms(): array + { + return $this->enabledPlatforms; + } +} diff --git a/src/Services/AI/AIPlatformSettingsInterface.php b/src/Services/AI/AIPlatformSettingsInterface.php new file mode 100644 index 00000000..c400db46 --- /dev/null +++ b/src/Services/AI/AIPlatformSettingsInterface.php @@ -0,0 +1,33 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\AI; + +interface AIPlatformSettingsInterface +{ + /** + * Returns true, if the AI platform is enabled in the settings and can be used, false otherwise. + * @return bool + */ + public function isAIPlatformEnabled(): bool; +} diff --git a/src/Services/AI/AIPlatforms.php b/src/Services/AI/AIPlatforms.php new file mode 100644 index 00000000..9a1480e4 --- /dev/null +++ b/src/Services/AI/AIPlatforms.php @@ -0,0 +1,57 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\AI; + +use App\Settings\AISettings\LMStudioSettings; +use App\Settings\AISettings\OpenRouterSettings; + +enum AIPlatforms: string +{ + case OPENROUTER = 'openrouter'; + case LMSTUDIO = 'lmstudio'; + + /** + * Returns the name attribute of the service tag for this platform, which is used to register the platform in the AIPlatformRegistry + * @return string + */ + public function toServiceTagName(): string + { + return $this->value; + } + + /** + * Returns the class name of the settings class for this platform, which implements AIPlatformSettingsInterface + * @return string + * @phpstan-return class-string + */ + public function toSettingsClass(): string + { + return match ($this) { + self::LMSTUDIO => LMStudioSettings::class, + self::OPENROUTER => OpenRouterSettings::class, + + default => throw new \InvalidArgumentException(sprintf('No settings class defined for AI platform "%s".', $this->name)), + }; + } +} diff --git a/src/Services/InfoProviderSystem/Providers/AIInfoExtractor.php b/src/Services/InfoProviderSystem/Providers/AIInfoExtractor.php index 0bf32159..7472c87f 100644 --- a/src/Services/InfoProviderSystem/Providers/AIInfoExtractor.php +++ b/src/Services/InfoProviderSystem/Providers/AIInfoExtractor.php @@ -25,6 +25,8 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\Providers; use App\Exceptions\ProviderIDNotSupportedException; +use App\Services\AI\AIPlatformRegistry; +use App\Services\AI\AIPlatforms; use App\Services\InfoProviderSystem\DTOJsonSchemaConverter; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Settings\InfoProviderSystem\AIExtractorSettings; @@ -45,8 +47,7 @@ final class AIInfoExtractor implements InfoProviderInterface public function __construct( HttpClientInterface $httpClient, private readonly AIExtractorSettings $settings, - #[Autowire(service: "ai.traceable_platform.openrouter")] - private readonly PlatformInterface $aiPlatform, + private readonly AIPlatformRegistry $AIPlatformRegistry, private readonly DTOJsonSchemaConverter $jsonSchemaConverter, ) { $this->httpClient = $httpClient->withOptions([ @@ -171,8 +172,10 @@ final class AIInfoExtractor implements InfoProviderInterface ); try { + $aiPlatform = $this->AIPlatformRegistry->getPlatform(AIPlatforms::OPENROUTER); + //'openai/gpt-5-mini' - $result = $this->aiPlatform->invoke('openrouter/auto', $input, [ + $result = $aiPlatform->invoke('openrouter/auto', $input, [ 'response_format' => 'json_schema', 'json_schema' => $this->jsonSchemaConverter->getJSONSchema(), ]); diff --git a/src/Settings/AISettings/AISettings.php b/src/Settings/AISettings/AISettings.php new file mode 100644 index 00000000..0e99d6fc --- /dev/null +++ b/src/Settings/AISettings/AISettings.php @@ -0,0 +1,43 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\AISettings; + +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Settings\EmbeddedSettings; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(label: new TM("settings.ai"), description: "settings.ai.help")] +#[SettingsIcon("fa-brain")] +class AISettings +{ + use SettingsTrait; + + #[EmbeddedSettings] + public ?OpenRouterSettings $openRouter = null; + + #[EmbeddedSettings] + public ?LMStudioSettings $lmstudio = null; +} diff --git a/src/Settings/AISettings/LMStudioSettings.php b/src/Settings/AISettings/LMStudioSettings.php new file mode 100644 index 00000000..ea6b9681 --- /dev/null +++ b/src/Settings/AISettings/LMStudioSettings.php @@ -0,0 +1,51 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\AISettings; + +use App\Form\Type\APIKeyType; +use App\Services\AI\AIPlatformSettingsInterface; +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Form\Extension\Core\Type\UrlType; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(name: 'ai_lmstudio', label: new TM("settings.ai.openrouter"), description: "settings.ai.lmstudio.help")] +#[SettingsIcon("fa-brain")] +class LMStudioSettings implements AIPlatformSettingsInterface +{ + use SettingsTrait; + + #[SettingsParameter(label: new TM("settings.ai.lmstudio.hosturl"), + formType: UrlType::class, + envVar: "AI_LMSTUDIO_HOSTURL", envVarMode: EnvVarMode::OVERWRITE)] + public ?string $hostURL = null; + + public function isAIPlatformEnabled(): bool + { + return $this->hostURL !== null && $this->hostURL !== ""; + } +} diff --git a/src/Settings/AISettings/OpenRouterSettings.php b/src/Settings/AISettings/OpenRouterSettings.php new file mode 100644 index 00000000..7b96c1d9 --- /dev/null +++ b/src/Settings/AISettings/OpenRouterSettings.php @@ -0,0 +1,50 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\AISettings; + +use App\Form\Type\APIKeyType; +use App\Services\AI\AIPlatformSettingsInterface; +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(name: 'ai_openrouter', label: new TM("settings.ai.openrouter"), description: "settings.ai.openrouter.help")] +#[SettingsIcon("fa-brain")] +class OpenRouterSettings implements AIPlatformSettingsInterface +{ + use SettingsTrait; + + #[SettingsParameter(label: new TM("settings.ips.element14.apiKey"), + formType: APIKeyType::class, + formOptions: ["help_html" => true], envVar: "AI_OPENROUTER_KEY", envVarMode: EnvVarMode::OVERWRITE)] + public ?string $apiKey = null; + + public function isAIPlatformEnabled(): bool + { + return $this->apiKey !== null && $this->apiKey !== ""; + } +} diff --git a/src/Settings/AppSettings.php b/src/Settings/AppSettings.php index 14d9395e..085f7ffe 100644 --- a/src/Settings/AppSettings.php +++ b/src/Settings/AppSettings.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Settings; +use App\Settings\AISettings\AISettings; use App\Settings\BehaviorSettings\BehaviorSettings; use App\Settings\InfoProviderSystem\InfoProviderSettings; use App\Settings\MiscSettings\MiscSettings; @@ -50,6 +51,9 @@ class AppSettings #[EmbeddedSettings] public ?SynonymSettings $synonyms = null; + #[EmbeddedSettings] + public ?AISettings $ai = null; + #[EmbeddedSettings()] public ?MiscSettings $miscSettings = null;