From b1b9d3787c396607d52ec83fbdbd2920d467b275 Mon Sep 17 00:00:00 2001 From: roboshyim Date: Tue, 21 Jul 2026 17:29:27 +0000 Subject: [PATCH] feat: native Mistral provider (OpenAI-compatible, EU-hosted) - MistralProvider: config keys mistralApiKey/mistralBaseUrl/mistralDefaultModel, capabilities TextGeneration/Vision/ToolCalling/StructuredOutput/Reasoning/Temperature (no image generation) - MistralClient: delegates chat to OpenAiChatClient against https://api.mistral.ai/v1, throws ImageGenerationNotSupportedException for generateImage - services.xml registration, config.xml card (en + de-DE) - admin component frosh-ai-provider-mistral + module import + detail-page allowlist - en-GB/de-DE snippets, MistralProviderTest (7 tests) --- src/Provider/Mistral/MistralClient.php | 56 ++++++ src/Provider/Mistral/MistralProvider.php | 128 ++++++++++++ .../frosh-ai-provider-mistral.html.twig | 72 +++++++ .../frosh-ai-provider-mistral.scss | 19 ++ .../frosh-ai-provider-mistral/index.js | 189 ++++++++++++++++++ .../src/module/frosh-ai-settings/index.js | 1 + .../page/frosh-ai-settings-provider/index.js | 1 + .../frosh-ai-settings/snippet/de-DE.json | 17 ++ .../frosh-ai-settings/snippet/en-GB.json | 17 ++ src/Resources/config/config.xml | 33 +++ src/Resources/config/services.xml | 7 + tests/Unit/Provider/MistralProviderTest.php | 94 +++++++++ 12 files changed, 634 insertions(+) create mode 100644 src/Provider/Mistral/MistralClient.php create mode 100644 src/Provider/Mistral/MistralProvider.php create mode 100644 src/Resources/app/administration/src/module/frosh-ai-settings/component/frosh-ai-provider-mistral/frosh-ai-provider-mistral.html.twig create mode 100644 src/Resources/app/administration/src/module/frosh-ai-settings/component/frosh-ai-provider-mistral/frosh-ai-provider-mistral.scss create mode 100644 src/Resources/app/administration/src/module/frosh-ai-settings/component/frosh-ai-provider-mistral/index.js create mode 100644 tests/Unit/Provider/MistralProviderTest.php 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, + ); + } +}