Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/Provider/Mistral/MistralClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php declare(strict_types=1);

namespace Frosh\AI\Provider\Mistral;

use Frosh\AI\Contract\LlmClientInterface;
use Frosh\AI\Exception\ImageGenerationNotSupportedException;
use Frosh\AI\Provider\OpenAi\OpenAiChatClient;
use Frosh\AI\Request\ChatRequest;
use Frosh\AI\Request\ImageRequest;
use Frosh\AI\Response\ChatResponse;
use Frosh\AI\Response\ImageResponse;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
* Mistral Chat Completions client.
*
* Mistral exposes an OpenAI-compatible API, so chat is delegated to
* {@see OpenAiChatClient} pointed at the Mistral base URL. Mistral text
* models do not generate images.
*
* @see https://docs.mistral.ai/api/
*/
final class MistralClient implements LlmClientInterface
{
private readonly OpenAiChatClient $chatClient;

public function __construct(
HttpClientInterface $httpClient,
string $apiKey,
string $baseUrl = MistralProvider::DEFAULT_BASE_URL,
string $defaultModel = MistralProvider::DEFAULT_MODEL,
private readonly string $providerId = MistralProvider::ID,
) {
$this->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.',
);
}
}
128 changes: 128 additions & 0 deletions src/Provider/Mistral/MistralProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php declare(strict_types=1);

namespace Frosh\AI\Provider\Mistral;

use Frosh\AI\Contract\AiProviderInterface;
use Frosh\AI\Contract\LlmClientInterface;
use Frosh\AI\Enum\Capability;
use Frosh\AI\Exception\ProviderNotConfiguredException;
use Frosh\AI\Model\ProviderDescriptor;
use Shopware\Core\System\SystemConfig\SystemConfigService;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
* Mistral (OpenAI-compatible Chat Completions) — EU-hosted chat, vision, tool
* calling and structured output. Mistral text models do not generate images.
*
* @see https://docs.mistral.ai/api/
*/
final class MistralProvider implements AiProviderInterface
{
public const ID = 'mistral';

private const CONFIG_PREFIX = 'FroshAI.config.';

public const DEFAULT_BASE_URL = 'https://api.mistral.ai/v1';

public const DEFAULT_MODEL = 'mistral-large-latest';

public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly SystemConfigService $systemConfigService,
) {
}

public function getId(): string
{
return self::ID;
}

public function getName(): string
{
return 'Mistral';
}

public function isConfigured(?string $salesChannelId = null): bool
{
return $this->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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
{% block frosh_ai_provider_mistral %}
<div class="frosh-ai-provider-mistral">
<mt-card
position-identifier="frosh-ai-provider-mistral-config"
:title="$tc('frosh-ai-settings.mistral.cardTitle')"
:subtitle="$tc('frosh-ai-settings.mistral.cardSubtitle')"
>
<p class="frosh-ai-provider-mistral__hint frosh-ai-provider-mistral__field">
{{ $tc('frosh-ai-settings.mistral.help') }}
</p>

<mt-password-field
class="frosh-ai-provider-mistral__field"
v-model="mistralApiKey"
autocomplete="new-password"
:label="$tc('frosh-ai-settings.mistral.apiKey')"
:placeholder="mistralApiKeySet
? $tc('frosh-ai-settings.mistral.apiKeySet')
: $tc('frosh-ai-settings.mistral.apiKeyPlaceholder')"
:disabled="!canEdit"
/>

<mt-text-field
class="frosh-ai-provider-mistral__field"
v-model="mistralBaseUrl"
:label="$tc('frosh-ai-settings.mistral.baseUrl')"
:help-text="$tc('frosh-ai-settings.mistral.baseUrlHelp')"
:disabled="!canEdit"
/>

<sw-single-select
class="frosh-ai-provider-mistral__field"
v-model:value="mistralDefaultModel"
:options="textModelOptions"
:label="$tc('frosh-ai-settings.mistral.defaultModel')"
:helpText="$tc('frosh-ai-settings.mistral.defaultModelHelp')"
:is-loading="isRefreshingModels"
:disabled="!canEdit"
show-clearable-button
/>

<div class="frosh-ai-provider-mistral__actions">
<mt-button
variant="secondary"
size="default"
:is-loading="isRefreshingModels"
@click="loadModels"
>
{{ $tc('frosh-ai-settings.mistral.refreshModels') }}
</mt-button>
<mt-button
variant="secondary"
size="default"
:is-loading="isTesting"
:disabled="!canEdit && !mistralApiKeySet"
@click="onTest"
>
{{ $tc('frosh-ai-settings.actions.testConnection') }}
</mt-button>
<mt-button
variant="primary"
size="default"
:is-loading="isSaving"
:disabled="!canEdit"
@click="onSave"
>
{{ $tc('frosh-ai-settings.actions.save') }}
</mt-button>
</div>
</mt-card>
</div>
{% endblock %}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading