From d51efa057bf59919960c18494a745cb4f32bc949 Mon Sep 17 00:00:00 2001 From: Simon Kuehn Date: Sun, 17 May 2026 16:03:16 +0000 Subject: [PATCH] feat: add MistralClient as switchable alternative to OllamaClient Implements OllamaClientInterface against the Mistral Cloud API (/v1/chat/completions). Switch by toggling the alias in services.yaml and pointing AI_TEXT_MODEL / AI_VISION_MODEL env vars at the MISTRAL_* or OLLAMA_* counterparts. Co-Authored-By: Claude Sonnet 4.6 --- .env | 31 ++++++- config/services.yaml | 112 ++++++++++++++++++++++++ src/Infrastructure/AI/MistralClient.php | 79 +++++++++++++++++ 3 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 src/Infrastructure/AI/MistralClient.php diff --git a/.env b/.env index 3745581..b546067 100644 --- a/.env +++ b/.env @@ -12,4 +12,33 @@ REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379 MESSENGER_TRANSPORT_DSN=redis://:${REDIS_PASSWORD}@redis:6379/messages MAILER_DSN=smtp://localhost -OLLAMA_URL=http://localhost:11434 +OLLAMA_BASE_URL=http://172.18.0.1:11434 +OLLAMA_VISION_MODEL=llava +OLLAMA_TEXT_MODEL=llama3.2 + +MISTRAL_BASE_URL=https://api.mistral.ai +MISTRAL_API_KEY= +# Vision requires a Pixtral model, e.g. pixtral-12b-2409 +MISTRAL_VISION_MODEL=pixtral-12b-2409 +MISTRAL_TEXT_MODEL=mistral-large-latest + +# Active backend — point these at OLLAMA_* or MISTRAL_* vars +AI_TEXT_MODEL=${OLLAMA_TEXT_MODEL} +AI_VISION_MODEL=${OLLAMA_VISION_MODEL} +SERP_API_KEY= + +EBAY_CLIENT_ID= +EBAY_CLIENT_SECRET= +EBAY_MARKETPLACE_ID=EBAY_DE +EBAY_API_BASE_URL=https://api.ebay.com +EBAY_OAUTH_BASE_URL=https://api.ebay.com +EBAY_VERIFICATION_TOKEN= +EBAY_ENDPOINT_URL=https://your-domain.com/webhooks/ebay + +FRAPPE_ERP_BASE_URL=https://erp.example.com +FRAPPE_ERP_API_KEY=changeme +FRAPPE_ERP_API_SECRET=changeme +FRAPPE_GENERIC_ITEM_CODE=REFURB-HW + +SUPPLIER_EMAIL=lieferant@example.com +SENDER_EMAIL=noreply@superseller3000.de diff --git a/config/services.yaml b/config/services.yaml index 1568a8b..0b3e786 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -46,3 +46,115 @@ services: App\Application\Storage\StorageManagerInterface: alias: App\Infrastructure\Storage\LocalStorageManager + + App\Domain\Auth\Repository\UserRepositoryInterface: + alias: App\Infrastructure\Persistence\Repository\DoctrineUserRepository + + App\Domain\Auth\Repository\ApiKeyRepositoryInterface: + alias: App\Infrastructure\Persistence\Repository\DoctrineApiKeyRepository + + # Switch between Ollama and Mistral by changing the alias target below + App\Infrastructure\AI\OllamaClientInterface: + alias: App\Infrastructure\AI\OllamaClient + # alias: App\Infrastructure\AI\MistralClient + + App\Infrastructure\AI\OllamaClient: + arguments: + $ollamaBaseUrl: '%env(OLLAMA_BASE_URL)%' + + App\Infrastructure\AI\MistralClient: + arguments: + $mistralApiKey: '%env(MISTRAL_API_KEY)%' + $mistralBaseUrl: '%env(MISTRAL_BASE_URL)%' + + App\Infrastructure\Search\WebSearchInterface: + alias: App\Infrastructure\Search\SerpApiWebSearch + + App\Infrastructure\Search\SerpApiWebSearch: + arguments: + $serpApiKey: '%env(SERP_API_KEY)%' + + App\Infrastructure\AI\Agent\OllamaVisionAgent: + arguments: + $model: '%env(AI_VISION_MODEL)%' + + App\Infrastructure\AI\Agent\SpecsResearchAgent: + arguments: + $model: '%env(AI_TEXT_MODEL)%' + + App\Infrastructure\AI\Agent\JsonCodingAgent: + arguments: + $model: '%env(AI_TEXT_MODEL)%' + + App\Infrastructure\AI\Agent\EbayTextAgent: + arguments: + $model: '%env(AI_TEXT_MODEL)%' + + App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface: + alias: App\Infrastructure\Persistence\Repository\DoctrineAIPipelineJobRepository + + App\Infrastructure\Console\BackupCommand: + arguments: + $backupDir: '%kernel.project_dir%/var/backups' + $databaseUrl: '%env(DATABASE_URL)%' + + App\Application\Channel\ChannelAdapterRegistry: + arguments: + $adapters: !tagged_iterator app.channel_adapter + + App\Infrastructure\Channel\Ebay\EbayAdapter: + tags: ['app.channel_adapter'] + + App\Infrastructure\Channel\Ebay\EbayOAuthClient: + arguments: + $clientId: '%env(EBAY_CLIENT_ID)%' + $clientSecret: '%env(EBAY_CLIENT_SECRET)%' + $oauthBaseUrl: '%env(EBAY_OAUTH_BASE_URL)%' + $cache: '@cache.app' + + App\Infrastructure\Channel\Ebay\EbayInventoryApiClient: + arguments: + $apiBaseUrl: '%env(EBAY_API_BASE_URL)%' + $marketplaceId: '%env(EBAY_MARKETPLACE_ID)%' + + App\Infrastructure\Channel\Ebay\EbayTaxonomyService: + arguments: + $apiBaseUrl: '%env(EBAY_API_BASE_URL)%' + $marketplaceId: '%env(EBAY_MARKETPLACE_ID)%' + $cache: '@cache.app' + + App\Infrastructure\Channel\Ebay\EbayWebhookVerifier: + arguments: + $verificationToken: '%env(EBAY_VERIFICATION_TOKEN)%' + $endpointUrl: '%env(EBAY_ENDPOINT_URL)%' + + App\Domain\Order\Repository\InvoiceRepositoryInterface: + alias: App\Infrastructure\Persistence\Repository\DoctrineInvoiceRepository + + App\Infrastructure\Channel\Frappe\FrappeHttpClient: + arguments: + $baseUrl: '%env(FRAPPE_ERP_BASE_URL)%' + $apiKey: '%env(FRAPPE_ERP_API_KEY)%' + $apiSecret: '%env(FRAPPE_ERP_API_SECRET)%' + + App\Infrastructure\Channel\Frappe\FrappeErpAdapter: + arguments: + $genericItemCode: '%env(FRAPPE_GENERIC_ITEM_CODE)%' + + App\Application\Order\ErpAdapterInterface: + alias: App\Infrastructure\Channel\Frappe\FrappeErpAdapter + + App\Application\Order\CustomerResolverInterface: + alias: App\Infrastructure\Order\CustomerResolver + + App\Application\Order\InvoiceMailerInterface: + alias: App\Infrastructure\Mail\SymfonyInvoiceMailer + + App\Infrastructure\Mail\SymfonyInvoiceMailer: + arguments: + $supplierEmail: '%env(SUPPLIER_EMAIL)%' + $senderEmail: '%env(SENDER_EMAIL)%' + + App\Infrastructure\Channel\Ebay\EbayFulfillmentApiClient: + arguments: + $apiBaseUrl: '%env(EBAY_API_BASE_URL)%' diff --git a/src/Infrastructure/AI/MistralClient.php b/src/Infrastructure/AI/MistralClient.php new file mode 100644 index 0000000..ca0701c --- /dev/null +++ b/src/Infrastructure/AI/MistralClient.php @@ -0,0 +1,79 @@ +httpClient->request('POST', $this->mistralBaseUrl.'/v1/chat/completions', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->mistralApiKey, + ], + 'json' => [ + 'model' => $model, + 'messages' => [ + ['role' => 'user', 'content' => $prompt], + ], + ], + 'timeout' => 120, + ]); + + /** @var array{choices: array{0: array{message: array{content: string}}}} $data */ + $data = $response->toArray(); + + return $data['choices'][0]['message']['content']; + } + + public function generateWithImage(string $model, string $prompt, string $imagePath): string + { + $imageData = base64_encode((string) file_get_contents($imagePath)); + $mimeType = $this->guessMimeType($imagePath); + + $response = $this->httpClient->request('POST', $this->mistralBaseUrl.'/v1/chat/completions', [ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->mistralApiKey, + ], + 'json' => [ + 'model' => $model, + 'messages' => [ + [ + 'role' => 'user', + 'content' => [ + ['type' => 'text', 'text' => $prompt], + ['type' => 'image_url', 'image_url' => ['url' => 'data:'.$mimeType.';base64,'.$imageData]], + ], + ], + ], + ], + 'timeout' => 180, + ]); + + /** @var array{choices: array{0: array{message: array{content: string}}}} $data */ + $data = $response->toArray(); + + return $data['choices'][0]['message']['content']; + } + + private function guessMimeType(string $path): string + { + return match (strtolower(pathinfo($path, PATHINFO_EXTENSION))) { + 'jpg', 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + default => 'image/jpeg', + }; + } +}