diff --git a/src/Provider/Mistral/MistralClient.php b/src/Provider/Mistral/MistralClient.php
new file mode 100644
index 0000000..6d9c77f
--- /dev/null
+++ b/src/Provider/Mistral/MistralClient.php
@@ -0,0 +1,56 @@
+chatClient = new OpenAiChatClient(
+ httpClient: $httpClient,
+ apiKey: $apiKey,
+ baseUrl: $baseUrl,
+ organization: null,
+ defaultModel: $defaultModel,
+ providerId: $providerId,
+ );
+ }
+
+ public function chat(ChatRequest $request): ChatResponse
+ {
+ return $this->chatClient->chat($request);
+ }
+
+ public function generateImage(ImageRequest $request): ImageResponse
+ {
+ throw new ImageGenerationNotSupportedException(
+ $this->providerId,
+ 'Mistral text models do not generate images. Use OpenAI or Gemini for image features.',
+ );
+ }
+}
diff --git a/src/Provider/Mistral/MistralProvider.php b/src/Provider/Mistral/MistralProvider.php
new file mode 100644
index 0000000..27c2cf2
--- /dev/null
+++ b/src/Provider/Mistral/MistralProvider.php
@@ -0,0 +1,128 @@
+apiKey($salesChannelId) !== null;
+ }
+
+ public function getClient(): LlmClientInterface
+ {
+ $apiKey = $this->apiKey();
+ if ($apiKey === null) {
+ throw new ProviderNotConfiguredException(self::ID);
+ }
+
+ return new MistralClient(
+ httpClient: $this->httpClient,
+ apiKey: $apiKey,
+ baseUrl: $this->baseUrl(),
+ defaultModel: $this->defaultModel(),
+ providerId: self::ID,
+ );
+ }
+
+ public function getCapabilities(): array
+ {
+ return [
+ Capability::TextGeneration,
+ Capability::Vision,
+ Capability::ToolCalling,
+ Capability::StructuredOutput,
+ Capability::Reasoning,
+ Capability::Temperature,
+ ];
+ }
+
+ public function getDescriptor(): ProviderDescriptor
+ {
+ return new ProviderDescriptor(
+ id: self::ID,
+ name: 'Mistral',
+ description: 'Mistral (EU-hosted, OpenAI-compatible): chat, vision, tool calling and structured output (no image generation).',
+ icon: 'regular-products',
+ authMethods: ['api_key'],
+ modelsDevId: 'mistral',
+ docsUrl: 'https://docs.mistral.ai/api/',
+ capabilityLabels: array_map(
+ static fn (Capability $c): string => $c->value,
+ $this->getCapabilities(),
+ ),
+ );
+ }
+
+ public function getAdminStatus(?string $salesChannelId = null): array
+ {
+ $configured = $this->isConfigured($salesChannelId);
+
+ return [
+ 'configured' => $configured,
+ 'hasApiKey' => $configured,
+ 'summary' => $configured ? 'API key' : 'Not configured',
+ 'baseUrl' => $this->baseUrl(),
+ 'defaultModel' => $this->defaultModel(),
+ ];
+ }
+
+ private function apiKey(?string $salesChannelId = null): ?string
+ {
+ $value = $this->systemConfigService->getString(self::CONFIG_PREFIX . 'mistralApiKey', $salesChannelId);
+
+ return $value !== '' ? $value : null;
+ }
+
+ private function baseUrl(): string
+ {
+ $value = $this->systemConfigService->getString(self::CONFIG_PREFIX . 'mistralBaseUrl');
+
+ return $value !== '' ? rtrim($value, '/') : self::DEFAULT_BASE_URL;
+ }
+
+ private function defaultModel(): string
+ {
+ $value = $this->systemConfigService->getString(self::CONFIG_PREFIX . 'mistralDefaultModel');
+
+ return $value !== '' ? $value : self::DEFAULT_MODEL;
+ }
+}
diff --git a/src/Resources/app/administration/src/module/frosh-ai-settings/component/frosh-ai-provider-mistral/frosh-ai-provider-mistral.html.twig b/src/Resources/app/administration/src/module/frosh-ai-settings/component/frosh-ai-provider-mistral/frosh-ai-provider-mistral.html.twig
new file mode 100644
index 0000000..05e8c12
--- /dev/null
+++ b/src/Resources/app/administration/src/module/frosh-ai-settings/component/frosh-ai-provider-mistral/frosh-ai-provider-mistral.html.twig
@@ -0,0 +1,72 @@
+{% block frosh_ai_provider_mistral %}
+
+
+
+ {{ $tc('frosh-ai-settings.mistral.help') }}
+
+
+
+
+
+
+
+
+
+
+ {{ $tc('frosh-ai-settings.mistral.refreshModels') }}
+
+
+ {{ $tc('frosh-ai-settings.actions.testConnection') }}
+
+
+ {{ $tc('frosh-ai-settings.actions.save') }}
+
+
+
+
+{% endblock %}
diff --git a/src/Resources/app/administration/src/module/frosh-ai-settings/component/frosh-ai-provider-mistral/frosh-ai-provider-mistral.scss b/src/Resources/app/administration/src/module/frosh-ai-settings/component/frosh-ai-provider-mistral/frosh-ai-provider-mistral.scss
new file mode 100644
index 0000000..b85c67b
--- /dev/null
+++ b/src/Resources/app/administration/src/module/frosh-ai-settings/component/frosh-ai-provider-mistral/frosh-ai-provider-mistral.scss
@@ -0,0 +1,19 @@
+.frosh-ai-provider-mistral {
+ &__field {
+ margin-bottom: 12px;
+ }
+
+ &__hint {
+ margin: 0 0 12px;
+ font-size: 13px;
+ line-height: 1.45;
+ color: var(--color-text-secondary-default, #758CA3);
+ }
+
+ &__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 8px;
+ }
+}
diff --git a/src/Resources/app/administration/src/module/frosh-ai-settings/component/frosh-ai-provider-mistral/index.js b/src/Resources/app/administration/src/module/frosh-ai-settings/component/frosh-ai-provider-mistral/index.js
new file mode 100644
index 0000000..4131e14
--- /dev/null
+++ b/src/Resources/app/administration/src/module/frosh-ai-settings/component/frosh-ai-provider-mistral/index.js
@@ -0,0 +1,189 @@
+import template from './frosh-ai-provider-mistral.html.twig';
+import './frosh-ai-provider-mistral.scss';
+
+const { Mixin } = Shopware;
+
+const DOMAIN = 'FroshAI.config';
+
+/**
+ * Provider panel for Mistral (API key).
+ */
+Shopware.Component.register('frosh-ai-provider-mistral', {
+ template,
+
+ inject: [
+ 'froshAiApiService',
+ 'systemConfigApiService',
+ ],
+
+ mixins: [
+ Mixin.getByName('notification'),
+ ],
+
+ props: {
+ provider: {
+ type: Object,
+ required: true,
+ },
+ status: {
+ type: Object,
+ default: () => ({}),
+ },
+ canEdit: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ data() {
+ return {
+ isSaving: false,
+ isTesting: false,
+ isRefreshingModels: false,
+ mistralApiKey: '',
+ mistralApiKeySet: false,
+ mistralBaseUrl: 'https://api.mistral.ai/v1',
+ mistralDefaultModel: 'mistral-large-latest',
+ models: [],
+ };
+ },
+
+ computed: {
+ textModelOptions() {
+ return this.buildModelOptions(this.models, this.mistralDefaultModel);
+ },
+ },
+
+ watch: {
+ status: {
+ immediate: true,
+ deep: true,
+ handler(value) {
+ this.applyStatus(value || {});
+ },
+ },
+ },
+
+ created() {
+ this.loadConfig();
+ this.loadModels();
+ },
+
+ methods: {
+ buildModelOptions(models, current) {
+ const options = (models || []).map((model) => ({
+ value: model.id,
+ label: model.name ? `${model.name} (${model.id})` : model.id,
+ }));
+
+ if (current && !options.some((option) => option.value === current)) {
+ options.unshift({ value: current, label: current });
+ }
+
+ return options;
+ },
+
+ applyStatus(status) {
+ this.mistralApiKeySet = !!status.hasApiKey || !!status.configured;
+ this.mistralBaseUrl = status.baseUrl
+ || 'https://api.mistral.ai/v1';
+ if (status.defaultModel) {
+ this.mistralDefaultModel = status.defaultModel;
+ }
+ },
+
+ async loadConfig() {
+ try {
+ const values = await this.systemConfigApiService.getValues(DOMAIN);
+ if (values[`${DOMAIN}.mistralBaseUrl`]) {
+ this.mistralBaseUrl = values[`${DOMAIN}.mistralBaseUrl`];
+ }
+ if (values[`${DOMAIN}.mistralDefaultModel`]) {
+ this.mistralDefaultModel = values[`${DOMAIN}.mistralDefaultModel`];
+ }
+ // Password fields are not returned; status.hasApiKey indicates presence.
+ this.mistralApiKeySet = !!values[`${DOMAIN}.mistralApiKey`]
+ || this.mistralApiKeySet
+ || !!this.status?.hasApiKey;
+ } catch (error) {
+ // keep defaults
+ }
+ },
+
+ async loadModels() {
+ this.isRefreshingModels = true;
+ try {
+ const textResponse = await this.froshAiApiService.models(
+ 'mistral',
+ 'text_generation',
+ 'catalog',
+ );
+ this.models = textResponse.models || textResponse.items || [];
+ } catch (error) {
+ this.models = [];
+ } finally {
+ this.isRefreshingModels = false;
+ }
+ },
+
+ async onSave() {
+ if (!this.canEdit) {
+ return;
+ }
+
+ this.isSaving = true;
+ try {
+ const payload = {
+ [`${DOMAIN}.mistralBaseUrl`]: this.mistralBaseUrl,
+ [`${DOMAIN}.mistralDefaultModel`]: this.mistralDefaultModel,
+ };
+ if (this.mistralApiKey) {
+ payload[`${DOMAIN}.mistralApiKey`] = this.mistralApiKey;
+ }
+
+ await this.systemConfigApiService.saveValues(payload);
+ this.mistralApiKey = '';
+ if (payload[`${DOMAIN}.mistralApiKey`]) {
+ this.mistralApiKeySet = true;
+ }
+
+ this.createNotificationSuccess({
+ message: this.$tc('frosh-ai-settings.mistral.saveSuccess'),
+ });
+ this.$emit('saved');
+ } catch (error) {
+ this.createNotificationError({
+ message: this.$tc('frosh-ai-settings.mistral.saveError'),
+ });
+ } finally {
+ this.isSaving = false;
+ }
+ },
+
+ async onTest() {
+ this.isTesting = true;
+ try {
+ const result = await this.froshAiApiService.testConnection(
+ 'mistral',
+ this.mistralDefaultModel || null,
+ );
+ if (result.success === false) {
+ throw new Error(result.error || 'Test failed');
+ }
+ this.createNotificationSuccess({
+ message: this.$tc('frosh-ai-settings.mistral.testSuccess', 0, {
+ model: result.model || this.mistralDefaultModel || '—',
+ }),
+ });
+ } catch (error) {
+ this.createNotificationError({
+ message: error?.response?.data?.error
+ || error?.message
+ || this.$tc('frosh-ai-settings.mistral.testError'),
+ });
+ } finally {
+ this.isTesting = false;
+ }
+ },
+ },
+});
diff --git a/src/Resources/app/administration/src/module/frosh-ai-settings/index.js b/src/Resources/app/administration/src/module/frosh-ai-settings/index.js
index 3b0f3f1..85c277d 100644
--- a/src/Resources/app/administration/src/module/frosh-ai-settings/index.js
+++ b/src/Resources/app/administration/src/module/frosh-ai-settings/index.js
@@ -10,6 +10,7 @@ import './component/frosh-ai-provider-openai';
import './component/frosh-ai-provider-openai-compatible';
import './component/frosh-ai-provider-gemini';
import './component/frosh-ai-provider-anthropic';
+import './component/frosh-ai-provider-mistral';
import deDE from './snippet/de-DE.json';
import enGB from './snippet/en-GB.json';
diff --git a/src/Resources/app/administration/src/module/frosh-ai-settings/page/frosh-ai-settings-provider/index.js b/src/Resources/app/administration/src/module/frosh-ai-settings/page/frosh-ai-settings-provider/index.js
index 038b54a..2aac78c 100644
--- a/src/Resources/app/administration/src/module/frosh-ai-settings/page/frosh-ai-settings-provider/index.js
+++ b/src/Resources/app/administration/src/module/frosh-ai-settings/page/frosh-ai-settings-provider/index.js
@@ -65,6 +65,7 @@ Shopware.Component.register('frosh-ai-settings-provider', {
'frosh-ai-provider-openai-compatible',
'frosh-ai-provider-gemini',
'frosh-ai-provider-anthropic',
+ 'frosh-ai-provider-mistral',
].includes(this.componentName);
},
diff --git a/src/Resources/app/administration/src/module/frosh-ai-settings/snippet/de-DE.json b/src/Resources/app/administration/src/module/frosh-ai-settings/snippet/de-DE.json
index 0a9783b..17258ce 100644
--- a/src/Resources/app/administration/src/module/frosh-ai-settings/snippet/de-DE.json
+++ b/src/Resources/app/administration/src/module/frosh-ai-settings/snippet/de-DE.json
@@ -266,6 +266,23 @@
"testSuccess": "Verbindung OK ({model}).",
"testError": "Anthropic-Verbindungstest fehlgeschlagen."
},
+ "mistral": {
+ "cardTitle": "Mistral",
+ "cardSubtitle": "Mistral-API-Schlüssel — OpenAI-kompatibler Chat, Vision, Tools und strukturierte Ausgabe (keine Bildgenerierung). EU-gehostet.",
+ "help": "Schlüssel unter console.mistral.ai/api-keys erstellen. Mistral-Modell (z. B. mistral-large-latest) für Chat nutzen. Mistral generiert keine Bilder — dafür OpenAI oder Gemini verwenden. Shop-Standards unter Standard-Modelle setzen, wenn Mistral primär sein soll.",
+ "apiKey": "API-Schlüssel",
+ "apiKeyPlaceholder": "…",
+ "apiKeySet": "•••••••• (leer lassen, um den aktuellen Schlüssel zu behalten)",
+ "baseUrl": "Base-URL",
+ "baseUrlHelp": "Standard: https://api.mistral.ai/v1",
+ "defaultModel": "Standard-Textmodell",
+ "defaultModelHelp": "Anbieter-Fallback für Chat, wenn kein Modell angegeben ist.",
+ "refreshModels": "Katalogmodelle aktualisieren",
+ "saveSuccess": "Mistral-Einstellungen gespeichert.",
+ "saveError": "Mistral-Einstellungen konnten nicht gespeichert werden.",
+ "testSuccess": "Verbindung OK ({model}).",
+ "testError": "Mistral-Verbindungstest fehlgeschlagen."
+ },
"chatgpt": {
"cardTitle": "ChatGPT-Abo",
"cardSubtitle": "Mit ChatGPT anmelden (OpenAI Device Code / Codex) — nutzt das Plus-/Pro-/Business-Kontingent.",
diff --git a/src/Resources/app/administration/src/module/frosh-ai-settings/snippet/en-GB.json b/src/Resources/app/administration/src/module/frosh-ai-settings/snippet/en-GB.json
index fc5152f..cbd7173 100644
--- a/src/Resources/app/administration/src/module/frosh-ai-settings/snippet/en-GB.json
+++ b/src/Resources/app/administration/src/module/frosh-ai-settings/snippet/en-GB.json
@@ -266,6 +266,23 @@
"testSuccess": "Connection OK ({model}).",
"testError": "Anthropic connection test failed."
},
+ "mistral": {
+ "cardTitle": "Mistral",
+ "cardSubtitle": "Mistral API key — OpenAI-compatible chat, vision, tools and structured output (no image generation). EU-hosted.",
+ "help": "Create a key at console.mistral.ai/api-keys. Use a Mistral model (e.g. mistral-large-latest) for chat. Mistral does not generate images — use OpenAI or Gemini for image features. Set shop defaults under Default models if you want Mistral as the primary provider.",
+ "apiKey": "API key",
+ "apiKeyPlaceholder": "…",
+ "apiKeySet": "•••••••• (leave empty to keep current key)",
+ "baseUrl": "Base URL",
+ "baseUrlHelp": "Default: https://api.mistral.ai/v1",
+ "defaultModel": "Default text model",
+ "defaultModelHelp": "Provider fallback for chat when no model is specified.",
+ "refreshModels": "Refresh catalog models",
+ "saveSuccess": "Mistral settings saved.",
+ "saveError": "Could not save Mistral settings.",
+ "testSuccess": "Connection OK ({model}).",
+ "testError": "Mistral connection test failed."
+ },
"chatgpt": {
"cardTitle": "ChatGPT subscription",
"cardSubtitle": "Sign in with ChatGPT using OpenAI device code (Codex) — uses your Plus / Pro / Business plan quota.",
diff --git a/src/Resources/config/config.xml b/src/Resources/config/config.xml
index bb4a813..f3e095f 100644
--- a/src/Resources/config/config.xml
+++ b/src/Resources/config/config.xml
@@ -207,6 +207,39 @@
+
+ Mistral
+ Mistral
+
+
+ mistralApiKey
+
+
+ Mistral Console API key (https://console.mistral.ai/api-keys). EU-hosted.
+ Mistral-Console-API-Schlüssel (https://console.mistral.ai/api-keys). EU-gehostet.
+
+
+
+ mistralBaseUrl
+
+
+ Defaults to https://api.mistral.ai/v1
+ Standard: https://api.mistral.ai/v1
+ https://api.mistral.ai/v1
+ https://api.mistral.ai/v1
+
+
+
+ mistralDefaultModel
+
+
+ Used when a request targets Mistral without a model. Prefer shop Default models → text when set.
+ Wenn ein Request Mistral ohne Modell nutzt. Bevorzugt Shop-Standard-Modelle → Text.
+ mistral-large-latest
+ mistral-large-latest
+
+
+
OpenAI
OpenAI
diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml
index dd29bed..ded31f4 100644
--- a/src/Resources/config/services.xml
+++ b/src/Resources/config/services.xml
@@ -55,6 +55,13 @@
+
+
+
+
+
+
+
diff --git a/tests/Unit/Provider/MistralProviderTest.php b/tests/Unit/Provider/MistralProviderTest.php
new file mode 100644
index 0000000..ece3c46
--- /dev/null
+++ b/tests/Unit/Provider/MistralProviderTest.php
@@ -0,0 +1,94 @@
+createProvider([]);
+
+ static::assertSame('mistral', $provider->getId());
+ static::assertSame('Mistral', $provider->getName());
+
+ $descriptor = $provider->getDescriptor();
+ static::assertSame('mistral', $descriptor->id);
+ static::assertSame('mistral', $descriptor->modelsDevId);
+ static::assertSame(['api_key'], $descriptor->authMethods);
+ }
+
+ public function testCapabilitiesExcludeImageGeneration(): void
+ {
+ $provider = $this->createProvider([]);
+ $capabilities = $provider->getCapabilities();
+
+ static::assertContains(Capability::TextGeneration, $capabilities);
+ static::assertContains(Capability::StructuredOutput, $capabilities);
+ static::assertContains(Capability::ToolCalling, $capabilities);
+ static::assertNotContains(Capability::ImageGeneration, $capabilities);
+ static::assertNotContains(Capability::Embeddings, $capabilities);
+ }
+
+ public function testIsConfiguredFalseWithoutApiKey(): void
+ {
+ $provider = $this->createProvider([]);
+ static::assertFalse($provider->isConfigured());
+ }
+
+ public function testIsConfiguredTrueWithApiKey(): void
+ {
+ $provider = $this->createProvider(['FroshAI.config.mistralApiKey' => 'test-key']);
+ static::assertTrue($provider->isConfigured());
+ }
+
+ public function testGetClientThrowsWhenNotConfigured(): void
+ {
+ $provider = $this->createProvider([]);
+
+ $this->expectException(ProviderNotConfiguredException::class);
+ $provider->getClient();
+ }
+
+ public function testGetClientReturnsLlmClientWhenConfigured(): void
+ {
+ $provider = $this->createProvider(['FroshAI.config.mistralApiKey' => 'test-key']);
+
+ static::assertInstanceOf(LlmClientInterface::class, $provider->getClient());
+ }
+
+ public function testAdminStatusReflectsConfiguration(): void
+ {
+ $unconfigured = $this->createProvider([]);
+ static::assertFalse($unconfigured->getAdminStatus()['configured']);
+
+ $configured = $this->createProvider(['FroshAI.config.mistralApiKey' => 'test-key']);
+ $status = $configured->getAdminStatus();
+ static::assertTrue($status['configured']);
+ static::assertSame(MistralProvider::DEFAULT_BASE_URL, $status['baseUrl']);
+ static::assertSame(MistralProvider::DEFAULT_MODEL, $status['defaultModel']);
+ }
+
+ /**
+ * @param array $config
+ */
+ private function createProvider(array $config): MistralProvider
+ {
+ $systemConfig = $this->createMock(SystemConfigService::class);
+ $systemConfig->method('getString')->willReturnCallback(
+ static fn (string $key): string => $config[$key] ?? '',
+ );
+
+ return new MistralProvider(
+ $this->createMock(HttpClientInterface::class),
+ $systemConfig,
+ );
+ }
+}