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 <noreply@anthropic.com>
This commit is contained in:
Simon Kuehn 2026-05-17 16:03:16 +00:00
parent 6bf001b0c0
commit d51efa057b
3 changed files with 221 additions and 1 deletions

31
.env
View file

@ -12,4 +12,33 @@ REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
MESSENGER_TRANSPORT_DSN=redis://:${REDIS_PASSWORD}@redis:6379/messages MESSENGER_TRANSPORT_DSN=redis://:${REDIS_PASSWORD}@redis:6379/messages
MAILER_DSN=smtp://localhost 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

View file

@ -46,3 +46,115 @@ services:
App\Application\Storage\StorageManagerInterface: App\Application\Storage\StorageManagerInterface:
alias: App\Infrastructure\Storage\LocalStorageManager 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)%'

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\AI;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class MistralClient implements OllamaClientInterface
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly string $mistralApiKey,
private readonly string $mistralBaseUrl,
) {
}
public function generate(string $model, string $prompt): string
{
$response = $this->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',
};
}
}