From 4dbdc4f25bca46ef7bae101e2cf02940123a6dcf Mon Sep 17 00:00:00 2001 From: Rowan Date: Mon, 10 Aug 2026 22:15:42 +0200 Subject: [PATCH 1/2] added mistral ai service --- .../settings/template/RequestTemplate.vue | 6 +- Lingarr.Client/src/ts/setting.ts | 7 + Lingarr.Core/Configuration/SettingKeys.cs | 7 + Lingarr.Docs/developers/plugins.md | 2 +- Lingarr.Docs/getting-started/configuration.md | 2 +- .../translation-services/ai-services.md | 11 +- .../Migrations/M0017_SeedMistralSettings.cs | 42 ++ .../Services/StartupServiceTests.cs | 2 + .../ApplicationBuilderExtensions.cs | 1 + .../Extensions/ServiceCollectionExtensions.cs | 1 + .../Translation/ITranslationServiceFactory.cs | 2 +- .../Listener/SettingChangedListener.cs | 2 +- .../RequestTemplates/MistralTemplate.cs | 27 + .../Manifests/MistralPluginManifest.cs | 40 ++ Lingarr.Server/Services/StartupService.cs | 3 + .../Services/Translation/MistralService.cs | 498 ++++++++++++++++++ .../Translation/RequestTemplateService.cs | 4 +- .../Translation/TranslationFactory.cs | 8 + Readme.MD | 1 + Settings.MD | 12 +- samples/CloudflarePlugin/README.md | 2 +- 21 files changed, 670 insertions(+), 10 deletions(-) create mode 100644 Lingarr.Migrations/Migrations/M0017_SeedMistralSettings.cs create mode 100644 Lingarr.Server/Models/RequestTemplates/MistralTemplate.cs create mode 100644 Lingarr.Server/Services/Plugins/Manifests/MistralPluginManifest.cs create mode 100644 Lingarr.Server/Services/Translation/MistralService.cs diff --git a/Lingarr.Client/src/components/features/settings/template/RequestTemplate.vue b/Lingarr.Client/src/components/features/settings/template/RequestTemplate.vue index dbdf3f18..123d6c76 100644 --- a/Lingarr.Client/src/components/features/settings/template/RequestTemplate.vue +++ b/Lingarr.Client/src/components/features/settings/template/RequestTemplate.vue @@ -129,7 +129,8 @@ const templateMap: Record = { [SERVICE_TYPE.ANTHROPIC]: SETTINGS.ANTHROPIC_REQUEST_TEMPLATE, [SERVICE_TYPE.LOCALAI]: SETTINGS.LOCAL_AI_CHAT_REQUEST_TEMPLATE, [SERVICE_TYPE.GEMINI]: SETTINGS.GEMINI_REQUEST_TEMPLATE, - [SERVICE_TYPE.DEEPSEEK]: SETTINGS.DEEPSEEK_REQUEST_TEMPLATE + [SERVICE_TYPE.DEEPSEEK]: SETTINGS.DEEPSEEK_REQUEST_TEMPLATE, + [SERVICE_TYPE.MISTRAL]: SETTINGS.MISTRAL_REQUEST_TEMPLATE } const localAiEndpoint = computed( @@ -206,7 +207,8 @@ const presetLabelMap: Record = { gemini_request_template: 'Gemini Content', local_ai_generate_request_template: 'Ollama Generate', local_ai_chat_request_template: 'LocalAI Chat', - deepseek_request_template: 'DeepSeek Chat' + deepseek_request_template: 'DeepSeek Chat', + mistral_request_template: 'Mistral Chat' } onMounted(async () => { diff --git a/Lingarr.Client/src/ts/setting.ts b/Lingarr.Client/src/ts/setting.ts index 59fcfe61..d94f0cba 100644 --- a/Lingarr.Client/src/ts/setting.ts +++ b/Lingarr.Client/src/ts/setting.ts @@ -22,6 +22,7 @@ export const SETTINGS = { LOCAL_AI_MODEL: 'local_ai_model', GEMINI_MODEL: 'gemini_model', DEEPSEEK_MODEL: 'deepseek_model', + MISTRAL_MODEL: 'mistral_model', AI_PROMPT: 'ai_prompt', AI_USER_PROMPT: 'ai_user_prompt', PROOFREAD_PROMPT: 'proofread_prompt', @@ -62,6 +63,7 @@ export const SETTINGS = { LOCAL_AI_GENERATE_REQUEST_TEMPLATE: 'local_ai_generate_request_template', DEEPSEEK_REQUEST_TEMPLATE: 'deepseek_request_template', GEMINI_REQUEST_TEMPLATE: 'gemini_request_template', + MISTRAL_REQUEST_TEMPLATE: 'mistral_request_template', LANGUAGE_CODE_FORMAT: 'language_code_format', RADARR_DEFAULT_INCLUDE: 'radarr_default_include', SONARR_DEFAULT_INCLUDE: 'sonarr_default_include' @@ -88,6 +90,7 @@ export interface ISettings { local_ai_model: string gemini_model: string deepseek_model: string + mistral_model: string ai_prompt: string ai_user_prompt: string proofread_prompt: string @@ -128,6 +131,7 @@ export interface ISettings { local_ai_generate_request_template: string deepseek_request_template: string gemini_request_template: string + mistral_request_template: string language_code_format: string radarr_default_include: string sonarr_default_include: string @@ -142,6 +146,7 @@ export const ENCRYPTED_SETTINGS = { ANTHROPIC_API_KEY: 'anthropic_api_key', GEMINI_API_KEY: 'gemini_api_key', DEEPSEEK_API_KEY: 'deepseek_api_key', + MISTRAL_API_KEY: 'mistral_api_key', DEEPL_API_KEY: 'deepl_api_key', LIBRETRANSLATE_API_KEY: 'libretranslate_api_key', LOCAL_AI_API_KEY: 'local_ai_api_key', @@ -155,6 +160,7 @@ export interface IEncryptedSettings { anthropic_api_key: string gemini_api_key: string deepseek_api_key: string + mistral_api_key: string deepl_api_key: string libretranslate_api_key: string local_ai_api_key: string @@ -168,6 +174,7 @@ export const SERVICE_TYPE = { DEEPL: 'deepl', GEMINI: 'gemini', DEEPSEEK: 'deepseek', + MISTRAL: 'mistral', GOOGLE: 'google', BING: 'bing', MICROSOFT: 'microsoft', diff --git a/Lingarr.Core/Configuration/SettingKeys.cs b/Lingarr.Core/Configuration/SettingKeys.cs index d9c08839..7731d212 100644 --- a/Lingarr.Core/Configuration/SettingKeys.cs +++ b/Lingarr.Core/Configuration/SettingKeys.cs @@ -62,6 +62,13 @@ public static class DeepSeek public const string RequestTemplate = "deepseek_request_template"; } + public static class Mistral + { + public const string Model = "mistral_model"; + public const string ApiKey = "mistral_api_key"; + public const string RequestTemplate = "mistral_request_template"; + } + public static class LibreTranslate { public const string Url = "libretranslate_url"; diff --git a/Lingarr.Docs/developers/plugins.md b/Lingarr.Docs/developers/plugins.md index 81702d5e..1577a73d 100644 --- a/Lingarr.Docs/developers/plugins.md +++ b/Lingarr.Docs/developers/plugins.md @@ -60,7 +60,7 @@ Lingarr scans a folder specified by the `PLUGINS_PATH` environment variable at s Do not use these plugin identifiers (they are used by built-in providers): -`anthropic`, `openai`, `gemini`, `deepseek`, `localai`, `deepl`, `libretranslate`, `google`, `bing`, `microsoft`, `yandex` +`anthropic`, `openai`, `gemini`, `deepseek`, `mistral`, `localai`, `deepl`, `libretranslate`, `google`, `bing`, `microsoft`, `yandex` ## Settings diff --git a/Lingarr.Docs/getting-started/configuration.md b/Lingarr.Docs/getting-started/configuration.md index 61a0266b..0fdf6a93 100644 --- a/Lingarr.Docs/getting-started/configuration.md +++ b/Lingarr.Docs/getting-started/configuration.md @@ -91,7 +91,7 @@ The `SOURCE_LANGUAGES` and `TARGET_LANGUAGES` variables should be provided as a The supported values are: -- **[AI services](/translation-services/ai-services)**: [`openai`](/translation-services/ai-services#openai), [`anthropic`](/translation-services/ai-services#anthropic), [`gemini`](/translation-services/ai-services#gemini), [`deepseek`](/translation-services/ai-services#deepseek) and [`localai`](/translation-services/ai-services#localai) +- **[AI services](/translation-services/ai-services)**: [`openai`](/translation-services/ai-services#openai), [`anthropic`](/translation-services/ai-services#anthropic), [`gemini`](/translation-services/ai-services#gemini), [`deepseek`](/translation-services/ai-services#deepseek), [`mistral`](/translation-services/ai-services#mistral) and [`localai`](/translation-services/ai-services#localai) - **[Machine translation](/translation-services/machine-translation)**: [`libretranslate`](/translation-services/machine-translation#libretranslate), [`deepl`](/translation-services/machine-translation#deepl), [`google`](/translation-services/machine-translation#google-bing-microsoft-and-yandex), [`bing`](/translation-services/machine-translation#google-bing-microsoft-and-yandex), [`microsoft`](/translation-services/machine-translation#google-bing-microsoft-and-yandex) and [`yandex`](/translation-services/machine-translation#google-bing-microsoft-and-yandex) Each service has its own configuration variables, such as API keys and model selection, documented on its settings page. diff --git a/Lingarr.Docs/translation-services/ai-services.md b/Lingarr.Docs/translation-services/ai-services.md index 4604381f..3e44e836 100644 --- a/Lingarr.Docs/translation-services/ai-services.md +++ b/Lingarr.Docs/translation-services/ai-services.md @@ -52,7 +52,7 @@ Batch translation does not use the user prompt. The batch is sent as the user me Proofreading re-examines a completed translation. For each subtitle line it sends the source line and the existing translation together to the AI service and asks for a corrected translation, without translating from scratch. -Only services that implement proofreading offer it: OpenAI, Anthropic, Gemini, DeepSeek and LocalAI. LibreTranslate, DeepL, Google, Bing, Microsoft and Yandex do not support proofreading. +Only services that implement proofreading offer it: OpenAI, Anthropic, Gemini, DeepSeek, Mistral and LocalAI. LibreTranslate, DeepL, Google, Bing, Microsoft and Yandex do not support proofreading. You can run it two ways: @@ -112,6 +112,15 @@ Both accept the placeholders already listed above, plus two more: | `AI_PROMPT` | The system prompt template. | | `AI_USER_PROMPT` | The user message template. | +### Mistral + +| **Environment Variable** | **Description** | +|--------------------------|-----------------------------------------------------------------------------| +| `MISTRAL_MODEL` | The model to use for Mistral translations. Example: `mistral-large-latest`. | +| `MISTRAL_API_KEY` | The API key for authenticating with Mistral. | +| `AI_PROMPT` | The system prompt template. | +| `AI_USER_PROMPT` | The user message template. | + ### LocalAI LocalAI works with Ollama or any other OpenAI-compatible model or router. diff --git a/Lingarr.Migrations/Migrations/M0017_SeedMistralSettings.cs b/Lingarr.Migrations/Migrations/M0017_SeedMistralSettings.cs new file mode 100644 index 00000000..20c092e3 --- /dev/null +++ b/Lingarr.Migrations/Migrations/M0017_SeedMistralSettings.cs @@ -0,0 +1,42 @@ +using FluentMigrator; + +namespace Lingarr.Migrations.Migrations; + +[Migration(17)] +public class M0017_SeedMistralSettings : Migration +{ + public override void Up() + { + Insert.IntoTable("settings").Row(new + { + key = "mistral_model", + value = "" + }); + Insert.IntoTable("settings").Row(new + { + key = "mistral_api_key", + value = "" + }); + Insert.IntoTable("settings").Row(new + { + key = "mistral_request_template", + value = "" + }); + } + + public override void Down() + { + Delete.FromTable("settings").Row(new + { + key = "mistral_model" + }); + Delete.FromTable("settings").Row(new + { + key = "mistral_api_key" + }); + Delete.FromTable("settings").Row(new + { + key = "mistral_request_template" + }); + } +} diff --git a/Lingarr.Server.Tests/Services/StartupServiceTests.cs b/Lingarr.Server.Tests/Services/StartupServiceTests.cs index 99558e1b..91f8df5d 100644 --- a/Lingarr.Server.Tests/Services/StartupServiceTests.cs +++ b/Lingarr.Server.Tests/Services/StartupServiceTests.cs @@ -41,6 +41,8 @@ private static readonly (string EnvVar, string SettingKey)[] EnvMap = ("GEMINI_API_KEY", SettingKeys.Translation.Gemini.ApiKey), ("DEEPSEEK_MODEL", SettingKeys.Translation.DeepSeek.Model), ("DEEPSEEK_API_KEY", SettingKeys.Translation.DeepSeek.ApiKey), + ("MISTRAL_MODEL", SettingKeys.Translation.Mistral.Model), + ("MISTRAL_API_KEY", SettingKeys.Translation.Mistral.ApiKey), ("DEEPL_API_KEY", SettingKeys.Translation.DeepL.DeeplApiKey), ("AUTH_ENABLED", SettingKeys.Authentication.AuthEnabled), ("TELEMETRY_ENABLED", SettingKeys.Telemetry.TelemetryEnabled) diff --git a/Lingarr.Server/Extensions/ApplicationBuilderExtensions.cs b/Lingarr.Server/Extensions/ApplicationBuilderExtensions.cs index dc166b8b..b1e456ac 100644 --- a/Lingarr.Server/Extensions/ApplicationBuilderExtensions.cs +++ b/Lingarr.Server/Extensions/ApplicationBuilderExtensions.cs @@ -79,6 +79,7 @@ private static async Task MigrateApiKeyEncryption(this WebApplication app) SettingKeys.Translation.Anthropic.ApiKey, SettingKeys.Translation.Gemini.ApiKey, SettingKeys.Translation.DeepSeek.ApiKey, + SettingKeys.Translation.Mistral.ApiKey, SettingKeys.Translation.DeepL.DeeplApiKey, SettingKeys.Translation.LibreTranslate.ApiKey, SettingKeys.Translation.LocalAi.ApiKey, diff --git a/Lingarr.Server/Extensions/ServiceCollectionExtensions.cs b/Lingarr.Server/Extensions/ServiceCollectionExtensions.cs index 6e2e9956..d914374f 100644 --- a/Lingarr.Server/Extensions/ServiceCollectionExtensions.cs +++ b/Lingarr.Server/Extensions/ServiceCollectionExtensions.cs @@ -186,6 +186,7 @@ private static void ConfigureServices(this WebApplicationBuilder builder) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/Lingarr.Server/Interfaces/Services/Translation/ITranslationServiceFactory.cs b/Lingarr.Server/Interfaces/Services/Translation/ITranslationServiceFactory.cs index 8b3d8318..f97037c1 100644 --- a/Lingarr.Server/Interfaces/Services/Translation/ITranslationServiceFactory.cs +++ b/Lingarr.Server/Interfaces/Services/Translation/ITranslationServiceFactory.cs @@ -9,7 +9,7 @@ public interface ITranslationServiceFactory /// /// Creates and returns an instance of a translation service based on the specified service type. /// - /// The provider name (case-insensitive), e.g. "libretranslate", "openai", "anthropic", "deepl", "gemini", "deepseek", "localai", "google", "bing", "microsoft", "yandex". + /// The provider name (case-insensitive), e.g. "libretranslate", "openai", "anthropic", "deepl", "gemini", "deepseek", "mistral", "localai", "google", "bing", "microsoft", "yandex". /// An instance of corresponding to the specified service type. /// Thrown when an unsupported service type is specified. ITranslationService CreateTranslationService(string serviceType); diff --git a/Lingarr.Server/Listener/SettingChangedListener.cs b/Lingarr.Server/Listener/SettingChangedListener.cs index 935fd0c2..264dada2 100644 --- a/Lingarr.Server/Listener/SettingChangedListener.cs +++ b/Lingarr.Server/Listener/SettingChangedListener.cs @@ -18,7 +18,7 @@ public class SettingChangedListener private readonly ILogger _logger; private static readonly HashSet BatchServiceTypes = new(StringComparer.OrdinalIgnoreCase) { - "openai", "anthropic", "localai", "gemini" + "openai", "anthropic", "localai", "gemini", "mistral" }; public SettingChangedListener(IServiceProvider serviceProvider, diff --git a/Lingarr.Server/Models/RequestTemplates/MistralTemplate.cs b/Lingarr.Server/Models/RequestTemplates/MistralTemplate.cs new file mode 100644 index 00000000..c8bde55a --- /dev/null +++ b/Lingarr.Server/Models/RequestTemplates/MistralTemplate.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace Lingarr.Server.Models.RequestTemplates; + +public class MistralTemplate +{ + [JsonPropertyName("model")] + public string Model { get; set; } = "{model}"; + + [JsonPropertyName("messages")] + public List Messages { get; set; } = + [ + new() + { + Role = "system", + Content = "{systemPrompt}" + }, + new() + { + Role = "user", + Content = "{userMessage}" + } + ]; + + [JsonPropertyName("stream")] + public bool Stream { get; set; } = false; +} diff --git a/Lingarr.Server/Services/Plugins/Manifests/MistralPluginManifest.cs b/Lingarr.Server/Services/Plugins/Manifests/MistralPluginManifest.cs new file mode 100644 index 00000000..e8a5ad01 --- /dev/null +++ b/Lingarr.Server/Services/Plugins/Manifests/MistralPluginManifest.cs @@ -0,0 +1,40 @@ +using Lingarr.Contracts.Interfaces.Plugins; +using Lingarr.Contracts.Plugins; +using Lingarr.Core.Configuration; + +namespace Lingarr.Server.Services.Plugins.Manifests; + +public sealed class MistralPluginManifest : IPluginManifest +{ + public string Provider => "mistral"; + + public string DisplayName => "Mistral"; + + public string? Description => + "Mistral AI's OpenAI-compatible chat completion models. AI translation can be costly, only use it when you know what you are doing and keep automation disabled."; + + public bool HasRequestTemplate => true; + + public IReadOnlyList Settings { get; } = + [ + new() + { + Key = SettingKeys.Translation.Mistral.ApiKey, + Label = "API key", + Type = PluginSettingType.Secret, + Required = true, + Description = "Mistral API key. Stored encrypted.", + MinLength = 1, + ValidationErrorMessage = "Value must not be empty" + }, + new() + { + Key = SettingKeys.Translation.Mistral.Model, + Label = "AI Model", + Type = PluginSettingType.RemoteDropdown, + Required = true, + OptionsEndpoint = "/api/plugin/mistral/models", + Description = "Select a model from your Mistral catalogue." + } + ]; +} diff --git a/Lingarr.Server/Services/StartupService.cs b/Lingarr.Server/Services/StartupService.cs index ec2bfb7b..01953a51 100644 --- a/Lingarr.Server/Services/StartupService.cs +++ b/Lingarr.Server/Services/StartupService.cs @@ -253,6 +253,9 @@ private async Task ApplySettingsFromEnvironment(LingarrDbContext dbContext) { "DEEPSEEK_MODEL", SettingKeys.Translation.DeepSeek.Model }, { "DEEPSEEK_API_KEY", SettingKeys.Translation.DeepSeek.ApiKey }, + { "MISTRAL_MODEL", SettingKeys.Translation.Mistral.Model }, + { "MISTRAL_API_KEY", SettingKeys.Translation.Mistral.ApiKey }, + { "DEEPL_API_KEY", SettingKeys.Translation.DeepL.DeeplApiKey }, { "AUTH_ENABLED", SettingKeys.Authentication.AuthEnabled }, diff --git a/Lingarr.Server/Services/Translation/MistralService.cs b/Lingarr.Server/Services/Translation/MistralService.cs new file mode 100644 index 00000000..27b12d0a --- /dev/null +++ b/Lingarr.Server/Services/Translation/MistralService.cs @@ -0,0 +1,498 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Lingarr.Contracts.Exceptions; +using Lingarr.Contracts.Models; +using Lingarr.Contracts.Models.Batch; +using Lingarr.Contracts.Translation; +using Lingarr.Core.Configuration; +using Lingarr.Server.Interfaces.Services; +using Lingarr.Server.Models; +using Lingarr.Server.Services.Translation.Base; + +namespace Lingarr.Server.Services.Translation; + +public class MistralService : BaseLanguageService, ITranslationService, IBatchTranslationService, IProofreadService +{ + private readonly string? _endpoint = "https://api.mistral.ai/v1"; + private string? _model; + private string? _apiKey; + private string? _requestTemplate; + private readonly HttpClient _httpClient; + private readonly IRequestTemplateService _requestTemplateService; + private bool _initialized; + private readonly SemaphoreSlim _initLock = new(1, 1); + + /// + public override string? ModelName => _model; + + // retry settings + private int _maxRetries; + private TimeSpan _retryDelay; + private int _retryDelayMultiplier; + + public MistralService( + ISettingService settings, + HttpClient httpClient, + ILogger logger, + LanguageCodeService languageCodeService, + IRequestTemplateService requestTemplateService) + : base(settings, logger, languageCodeService) + { + _httpClient = httpClient; + _requestTemplateService = requestTemplateService; + } + + /// + /// Initializes the translation service with necessary configurations and credentials. + /// This method is thread-safe and ensures one-time initialization of service dependencies. + /// + /// The source language code for translation + /// The target language code for translation + /// A task that represents the asynchronous initialization operation + /// Thrown when required configuration settings are missing or invalid + private async Task InitializeAsync(string sourceLanguage, string targetLanguage) + { + if (_initialized) return; + + try + { + await _initLock.WaitAsync(); + if (_initialized) return; + + var settings = await _settings.GetSettings([ + SettingKeys.Translation.Mistral.Model, + SettingKeys.Translation.Mistral.RequestTemplate, + SettingKeys.Translation.AiPrompt, + SettingKeys.Translation.AiUserPrompt, + SettingKeys.Translation.ProofreadPrompt, + SettingKeys.Translation.ProofreadUserPrompt, + SettingKeys.Translation.RequestTimeout, + SettingKeys.Translation.MaxRetries, + SettingKeys.Translation.RetryDelay, + SettingKeys.Translation.RetryDelayMultiplier, + SettingKeys.Translation.LanguageCodeFormat + ]); + + _model = settings[SettingKeys.Translation.Mistral.Model]; + _apiKey = await _settings.GetEncryptedSetting(SettingKeys.Translation.Mistral.ApiKey); + _requestTemplate = !string.IsNullOrEmpty(settings[SettingKeys.Translation.Mistral.RequestTemplate]) + ? settings[SettingKeys.Translation.Mistral.RequestTemplate] + : _requestTemplateService.GetDefaultTemplate(SettingKeys.Translation.Mistral.RequestTemplate); + + if (string.IsNullOrEmpty(_model) || string.IsNullOrEmpty(_apiKey)) + { + throw new InvalidOperationException("Mistral API key or model is not configured."); + } + + SetLanguageReplacements(sourceLanguage, targetLanguage, settings[SettingKeys.Translation.LanguageCodeFormat]); + _prompt = settings[SettingKeys.Translation.AiPrompt]; + _userPrompt = settings[SettingKeys.Translation.AiUserPrompt]; + _proofreadPrompt = settings.GetValueOrDefault(SettingKeys.Translation.ProofreadPrompt); + _proofreadUserPrompt = settings.GetValueOrDefault(SettingKeys.Translation.ProofreadUserPrompt); + + var requestTimeout = int.TryParse(settings[SettingKeys.Translation.RequestTimeout], + out var timeOut) + ? timeOut + : 5; + _httpClient.Timeout = TimeSpan.FromMinutes(requestTimeout); + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + _httpClient.DefaultRequestHeaders.Add("Accept", "application/json"); + + _maxRetries = int.TryParse(settings[SettingKeys.Translation.MaxRetries], out var maxRetries) + ? maxRetries + : 5; + var retryDelaySeconds = int.TryParse(settings[SettingKeys.Translation.RetryDelay], out var delaySeconds) + ? delaySeconds + : 1; + _retryDelay = TimeSpan.FromSeconds(retryDelaySeconds); + _retryDelayMultiplier = int.TryParse(settings[SettingKeys.Translation.RetryDelayMultiplier], out var multiplier) + ? multiplier + : 2; + + _initialized = true; + } + finally + { + _initLock.Release(); + } + } + + /// + public override async Task TranslateAsync( + string text, + string sourceLanguage, + string targetLanguage, + List? contextLinesBefore, + List? contextLinesAfter, + CancellationToken cancellationToken) + { + await InitializeAsync(sourceLanguage, targetLanguage); + + using var retry = new CancellationTokenSource(); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, retry.Token); + + var delay = _retryDelay; + for (var attempt = 1; attempt <= _maxRetries; attempt++) + { + try + { + var replacements = GetReplacements(_model!, text, contextLinesBefore, contextLinesAfter); + return await CompleteWithMistralApi(replacements, linked.Token); + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.ServiceUnavailable) + { + if (attempt == _maxRetries) + { + _logger.LogError(ex, "Max retries exhausted ({StatusCode}) for text: {Text}", ex.StatusCode, text); + throw new TranslationException($"Retry limit reached after {ex.StatusCode}.", ex); + } + + await Task.Delay(delay, linked.Token).ConfigureAwait(false); + delay = TimeSpan.FromTicks(delay.Ticks * _retryDelayMultiplier); + + _logger.LogWarning( + "{ServiceName} received {StatusCode}. Retrying in {Delay}... (Attempt {Attempt}/{MaxRetries})", + "Mistral", ex.StatusCode, delay, attempt, _maxRetries); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred during Mistral translation"); + throw new TranslationException("Failed to translate using Mistral", ex); + } + } + + throw new TranslationException("Translation failed after maximum retry attempts."); + } + + /// + public async Task ProofreadAsync( + string sourceText, + string translatedText, + string sourceLanguage, + string targetLanguage, + CancellationToken cancellationToken) + { + await InitializeAsync(sourceLanguage, targetLanguage); + + using var retry = new CancellationTokenSource(); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, retry.Token); + + var delay = _retryDelay; + for (var attempt = 1; attempt <= _maxRetries; attempt++) + { + try + { + var replacements = GetProofreadReplacements(_model!, sourceText, translatedText); + return await CompleteWithMistralApi(replacements, linked.Token); + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.ServiceUnavailable) + { + if (attempt == _maxRetries) + { + _logger.LogError(ex, "Max retries exhausted ({StatusCode}) for text: {Text}", ex.StatusCode, translatedText); + throw new TranslationException($"Retry limit reached after {ex.StatusCode}.", ex); + } + + await Task.Delay(delay, linked.Token).ConfigureAwait(false); + delay = TimeSpan.FromTicks(delay.Ticks * _retryDelayMultiplier); + + _logger.LogWarning( + "{ServiceName} received {StatusCode}. Retrying in {Delay}... (Attempt {Attempt}/{MaxRetries})", + "Mistral", ex.StatusCode, delay, attempt, _maxRetries); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred during Mistral proofread"); + throw new TranslationException("Failed to proofread using Mistral", ex); + } + } + + throw new TranslationException("Proofread failed after maximum retry attempts."); + } + + private async Task CompleteWithMistralApi( + Dictionary replacements, + CancellationToken cancellationToken) + { + var requestUrl = $"{_endpoint}/chat/completions"; + var bodyJson = _requestTemplateService.BuildRequestBody(_requestTemplate!, replacements); + var requestContent = new StringContent( + bodyJson, + Encoding.UTF8, + "application/json"); + + var response = await _httpClient.PostAsync(requestUrl, requestContent, cancellationToken); + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.ServiceUnavailable) + { + throw new HttpRequestException( + $"Mistral returned {response.StatusCode}", null, response.StatusCode); + } + + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogError( + "Mistral API request failed with status {StatusCode}: {ResponseContent}", + response.StatusCode, responseContent); + throw new TranslationException( + $"Mistral API request failed with status {response.StatusCode}: {responseContent}"); + } + + var completionResponse = + await response.Content.ReadFromJsonAsync(cancellationToken); + if (completionResponse?.Choices == null || completionResponse.Choices.Count == 0) + { + throw new TranslationException("No completion choices returned from Mistral"); + } + + return completionResponse.Choices[0].Message.Content; + } + + /// + /// Translates a batch of subtitles in a single API call using structured outputs + /// + /// List of subtitles with position and content + /// Source language code + /// Target language code + /// Cancellation token + /// Dictionary mapping position to translated content + public async Task> TranslateBatchAsync( + List subtitleBatch, + string sourceLanguage, + string targetLanguage, + CancellationToken cancellationToken) + { + await InitializeAsync(sourceLanguage, targetLanguage); + + using var retry = new CancellationTokenSource(); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, retry.Token); + + var delay = _retryDelay; + for (var attempt = 1; attempt <= _maxRetries; attempt++) + { + try + { + return await TranslateBatchWithMistralApi(subtitleBatch, linked.Token); + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.ServiceUnavailable) + { + if (attempt == _maxRetries) + { + _logger.LogError(ex, "Max retries exhausted ({StatusCode}) for batch translation", ex.StatusCode); + throw new TranslationException($"Retry limit reached after {ex.StatusCode}.", ex); + } + + await Task.Delay(delay, linked.Token).ConfigureAwait(false); + delay = TimeSpan.FromTicks(delay.Ticks * _retryDelayMultiplier); + + _logger.LogWarning( + "{ServiceName} received {StatusCode}. Retrying in {Delay}... (Attempt {Attempt}/{MaxRetries})", + "Mistral", ex.StatusCode, delay, attempt, _maxRetries); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error during batch translation attempt {Attempt}", attempt); + throw new TranslationException("Unexpected error occurred during batch translation.", ex); + } + } + + throw new TranslationException("Batch translation failed after maximum retry attempts."); + } + + private async Task> TranslateBatchWithMistralApi( + List subtitleBatch, + CancellationToken cancellationToken) + { + var requestUrl = $"{_endpoint}/chat/completions"; + // Mistral only honours a json_schema response format when the schema object also + // carries name and strict, unlike the OpenAI dialect where strict is optional. + var responseFormat = new + { + type = "json_schema", + json_schema = new + { + name = "batch_translation_response", + strict = true, + schema = new + { + type = "object", + properties = new + { + translations = new + { + type = "array", + items = new + { + type = "object", + properties = new + { + position = new + { + type = "integer" + }, + line = new + { + type = "string" + } + }, + required = new[] { "position", "line" }, + additionalProperties = false + } + } + }, + required = new[] { "translations" }, + additionalProperties = false + } + } + }; + + var replacements = GetBatchReplacements(_model!, JsonSerializer.Serialize(subtitleBatch)); + var bodyJson = _requestTemplateService.BuildRequestBody(_requestTemplate!, replacements); + bodyJson = _requestTemplateService.SetRequestFields(bodyJson, new Dictionary + { + ["response_format"] = responseFormat + }); + + var requestContent = new StringContent( + bodyJson, + Encoding.UTF8, + "application/json"); + + var response = await _httpClient.PostAsync(requestUrl, requestContent, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.ServiceUnavailable) + { + throw new HttpRequestException( + $"Batch translation using Mistral API failed with {response.StatusCode}.", + null, response.StatusCode); + } + + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogError( + "Mistral batch API request failed with status {StatusCode}: {ResponseContent}", + response.StatusCode, responseContent); + throw new TranslationException( + $"Mistral batch API request failed with status {response.StatusCode}: {responseContent}"); + } + + var completionResponse = await response.Content.ReadFromJsonAsync(cancellationToken); + if (completionResponse?.Choices == null || completionResponse.Choices.Count == 0) + { + throw new TranslationException("No completion choices returned from Mistral"); + } + + var translatedJson = completionResponse.Choices[0].Message.Content; + try + { + var responseWrapper = JsonSerializer.Deserialize(translatedJson); + if (!responseWrapper.TryGetProperty("translations", out var translationsElement)) + { + throw new TranslationException("Response does not contain 'translations' property"); + } + + var translatedItems = + JsonSerializer.Deserialize>(translationsElement.GetRawText()); + if (translatedItems == null) + { + throw new TranslationException("Failed to deserialize translated subtitles"); + } + + return MergeByPosition(translatedItems); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Failed to parse translated JSON: {Json}", translatedJson); + throw new TranslationException("Failed to parse translated subtitles", ex); + } + } + + /// + public override async Task GetModels() + { + var apiKey = await _settings.GetEncryptedSetting( + SettingKeys.Translation.Mistral.ApiKey + ); + + if (string.IsNullOrEmpty(apiKey)) + { + return new ModelsResponse + { + Message = "Mistral API key is not configured." + }; + } + + try + { + var client = new HttpClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + + var requestUrl = $"{_endpoint}/models"; + var response = await client.GetAsync(requestUrl); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Failed to fetch models. Status: {StatusCode}", response.StatusCode); + return new ModelsResponse + { + Message = $"Failed to fetch models. Status: {response.StatusCode}" + }; + } + + var modelsResponse = await response.Content.ReadFromJsonAsync(); + + if (modelsResponse?.Data == null) + { + return new ModelsResponse + { + Message = "No models data returned from Mistral API." + }; + } + + var labelValues = modelsResponse.Data + .Select(model => new LabelValue + { + Label = model.Id, + Value = model.Id + }) + .ToList(); + + return new ModelsResponse + { + Options = labelValues + }; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "HTTP error fetching models from Mistral API"); + return new ModelsResponse + { + Message = $"HTTP error fetching models from Mistral API: {ex.Message}" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching models from Mistral API"); + return new ModelsResponse + { + Message = $"Error fetching models from Mistral API: {ex.Message}" + }; + } + } +} diff --git a/Lingarr.Server/Services/Translation/RequestTemplateService.cs b/Lingarr.Server/Services/Translation/RequestTemplateService.cs index 5f92091f..0d3767b1 100644 --- a/Lingarr.Server/Services/Translation/RequestTemplateService.cs +++ b/Lingarr.Server/Services/Translation/RequestTemplateService.cs @@ -24,7 +24,9 @@ public class RequestTemplateService : IRequestTemplateService [SettingKeys.Translation.DeepSeek.RequestTemplate] = () => JsonSerializer.Serialize(new DeepSeekTemplate()), [SettingKeys.Translation.Gemini.RequestTemplate] = - () => JsonSerializer.Serialize(new GeminiTemplate()) + () => JsonSerializer.Serialize(new GeminiTemplate()), + [SettingKeys.Translation.Mistral.RequestTemplate] = + () => JsonSerializer.Serialize(new MistralTemplate()) }; /// diff --git a/Lingarr.Server/Services/Translation/TranslationFactory.cs b/Lingarr.Server/Services/Translation/TranslationFactory.cs index 2eceb4f0..9e8533ef 100644 --- a/Lingarr.Server/Services/Translation/TranslationFactory.cs +++ b/Lingarr.Server/Services/Translation/TranslationFactory.cs @@ -102,6 +102,14 @@ public ITranslationService CreateTranslationService(string serviceType) _serviceProvider.GetRequiredService() ), + "mistral" => new MistralService( + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService>(), + languageCodeService, + _serviceProvider.GetRequiredService() + ), + "gemini" => new GoogleGeminiService( _serviceProvider.GetRequiredService(), _serviceProvider.GetRequiredService(), diff --git a/Readme.MD b/Readme.MD index c060262b..608dffde 100644 --- a/Readme.MD +++ b/Readme.MD @@ -19,6 +19,7 @@ Lingarr now offers multiple services for automated translation: - **[Anthropic](https://www.anthropic.com/)** - **[OpenAI](https://openai.com/)** - **[DeepSeek](https://deepseek.com)** +- **[Mistral](https://mistral.ai)** - **[Gemini](https://gemini.google.com/)** - **[Google](https://translate.google.com/)** - **[Bing](https://www.bing.com/translator)** diff --git a/Settings.MD b/Settings.MD index a46a7ea4..baa48332 100644 --- a/Settings.MD +++ b/Settings.MD @@ -133,6 +133,15 @@ The `SOURCE_LANGUAGES` and `TARGET_LANGUAGES` variables should be provided as a | `AI_PROMPT` | The prompt template for AI-based translation services. | | `AI_USER_PROMPT` | The user message template. Supports `{lineToTranslate}`, `{contextBefore}` and `{contextAfter}`. | +#### **Mistral** + +| **Environment Variable** | **Description** | +|--------------------------|---------------------------------------------------------------------------------| +| `MISTRAL_MODEL` | The model to use for Mistral translations. Example: `mistral-large-latest`. | +| `MISTRAL_API_KEY` | The API key for authenticating with Mistral. | +| `AI_PROMPT` | The prompt template for AI-based translation services. | +| `AI_USER_PROMPT` | The user message template. Supports `{lineToTranslate}`, `{contextBefore}` and `{contextAfter}`. | + #### **LocalAI** | **Environment Variable** | **Description** | @@ -145,7 +154,7 @@ The `SOURCE_LANGUAGES` and `TARGET_LANGUAGES` variables should be provided as a #### **Proofreading** -Proofreading is only available for the AI services above (OpenAI, Anthropic, Gemini, DeepSeek, LocalAI), and only for a `service_type` entry that supports it. It uses two more settings, shared across all AI services the same way `AI_PROMPT` and `AI_USER_PROMPT` are. These are configured through **Settings > Services > Request Settings** in the web interface, or through their environment variables: +Proofreading is only available for the AI services above (OpenAI, Anthropic, Gemini, DeepSeek, Mistral, LocalAI), and only for a `service_type` entry that supports it. It uses two more settings, shared across all AI services the same way `AI_PROMPT` and `AI_USER_PROMPT` are. These are configured through **Settings > Services > Request Settings** in the web interface, or through their environment variables: | **Setting** | **Environment Variable** | **Default** | |-------------|--------------------------|-------------| @@ -182,6 +191,7 @@ The supported values are: | `anthropic` | Use Anthropic's models (e.g., Claude-2) for translations. | | `gemini` | Use Google's Gemini models for translations. | | `deepseek` | Use DeepSeek's models for translations. | +| `mistral` | Use Mistral AI's models for translations. | | `localai` | Use a locally hosted or OpenAI-compatible AI model for translations. | | `deepl` | Use the DeepL API for translations. | | `google` | Use Google Translate for translations. | diff --git a/samples/CloudflarePlugin/README.md b/samples/CloudflarePlugin/README.md index e3f9f18b..af9ec3f8 100644 --- a/samples/CloudflarePlugin/README.md +++ b/samples/CloudflarePlugin/README.md @@ -78,7 +78,7 @@ The plugin accepts standard language codes (`en`, `nl`, `ja`, etc.). If a langua Do not use these plugin identifiers (they are used by built-in providers): -`anthropic`, `openai`, `gemini`, `deepseek`, `localai`, `deepl`, `libretranslate`, `google`, `bing`, `microsoft`, `yandex`. +`anthropic`, `openai`, `gemini`, `deepseek`, `mistral`, `localai`, `deepl`, `libretranslate`, `google`, `bing`, `microsoft`, `yandex`. ## Security From ed861f7abec95a9307b382298a399fac2df7ae64 Mon Sep 17 00:00:00 2001 From: Rowan Date: Tue, 11 Aug 2026 12:36:10 +0200 Subject: [PATCH 2/2] added xAI and included response body in failed models retrieval --- .../features/settings/TranslationSettings.vue | 4 +- .../settings/template/RequestTemplate.vue | 6 +- Lingarr.Client/src/ts/setting.ts | 7 + Lingarr.Core/Configuration/SettingKeys.cs | 7 + Lingarr.Docs/developers/plugins.md | 2 +- Lingarr.Docs/getting-started/configuration.md | 2 +- .../translation-services/ai-services.md | 11 +- .../Migrations/M0018_SeedXAiSettings.cs | 42 ++ .../Services/StartupServiceTests.cs | 2 + .../ApplicationBuilderExtensions.cs | 1 + .../Extensions/ServiceCollectionExtensions.cs | 1 + .../Translation/ITranslationServiceFactory.cs | 2 +- .../Listener/SettingChangedListener.cs | 2 +- .../Models/RequestTemplates/XAiTemplate.cs | 27 + .../Plugins/Manifests/XAiPluginManifest.cs | 40 ++ Lingarr.Server/Services/StartupService.cs | 3 + .../Services/Translation/AnthropicService.cs | 8 +- .../Services/Translation/DeepSeekService.cs | 8 +- .../Translation/GoogleGeminiService.cs | 8 +- .../Services/Translation/MistralService.cs | 9 +- .../Services/Translation/OpenAiService.cs | 7 +- .../Translation/RequestTemplateService.cs | 4 +- .../Translation/TranslationFactory.cs | 8 + .../Services/Translation/XAiService.cs | 499 ++++++++++++++++++ Readme.MD | 1 + Settings.MD | 12 +- samples/CloudflarePlugin/README.md | 2 +- 27 files changed, 699 insertions(+), 26 deletions(-) create mode 100644 Lingarr.Migrations/Migrations/M0018_SeedXAiSettings.cs create mode 100644 Lingarr.Server/Models/RequestTemplates/XAiTemplate.cs create mode 100644 Lingarr.Server/Services/Plugins/Manifests/XAiPluginManifest.cs create mode 100644 Lingarr.Server/Services/Translation/XAiService.cs diff --git a/Lingarr.Client/src/components/features/settings/TranslationSettings.vue b/Lingarr.Client/src/components/features/settings/TranslationSettings.vue index 9e0845e4..b1a26b74 100644 --- a/Lingarr.Client/src/components/features/settings/TranslationSettings.vue +++ b/Lingarr.Client/src/components/features/settings/TranslationSettings.vue @@ -14,7 +14,8 @@ SERVICE_TYPE.GEMINI, SERVICE_TYPE.LOCALAI, SERVICE_TYPE.MISTRAL, - SERVICE_TYPE.OPENAI + SERVICE_TYPE.OPENAI, + SERVICE_TYPE.XAI ].includes( serviceType as | 'openai' @@ -23,6 +24,7 @@ | 'gemini' | 'deepseek' | 'mistral' + | 'xai' ) ">
diff --git a/Lingarr.Client/src/components/features/settings/template/RequestTemplate.vue b/Lingarr.Client/src/components/features/settings/template/RequestTemplate.vue index 123d6c76..d4b87d0f 100644 --- a/Lingarr.Client/src/components/features/settings/template/RequestTemplate.vue +++ b/Lingarr.Client/src/components/features/settings/template/RequestTemplate.vue @@ -130,7 +130,8 @@ const templateMap: Record = { [SERVICE_TYPE.LOCALAI]: SETTINGS.LOCAL_AI_CHAT_REQUEST_TEMPLATE, [SERVICE_TYPE.GEMINI]: SETTINGS.GEMINI_REQUEST_TEMPLATE, [SERVICE_TYPE.DEEPSEEK]: SETTINGS.DEEPSEEK_REQUEST_TEMPLATE, - [SERVICE_TYPE.MISTRAL]: SETTINGS.MISTRAL_REQUEST_TEMPLATE + [SERVICE_TYPE.MISTRAL]: SETTINGS.MISTRAL_REQUEST_TEMPLATE, + [SERVICE_TYPE.XAI]: SETTINGS.XAI_REQUEST_TEMPLATE } const localAiEndpoint = computed( @@ -208,7 +209,8 @@ const presetLabelMap: Record = { local_ai_generate_request_template: 'Ollama Generate', local_ai_chat_request_template: 'LocalAI Chat', deepseek_request_template: 'DeepSeek Chat', - mistral_request_template: 'Mistral Chat' + mistral_request_template: 'Mistral Chat', + xai_request_template: 'xAI Chat' } onMounted(async () => { diff --git a/Lingarr.Client/src/ts/setting.ts b/Lingarr.Client/src/ts/setting.ts index d94f0cba..069199cf 100644 --- a/Lingarr.Client/src/ts/setting.ts +++ b/Lingarr.Client/src/ts/setting.ts @@ -23,6 +23,7 @@ export const SETTINGS = { GEMINI_MODEL: 'gemini_model', DEEPSEEK_MODEL: 'deepseek_model', MISTRAL_MODEL: 'mistral_model', + XAI_MODEL: 'xai_model', AI_PROMPT: 'ai_prompt', AI_USER_PROMPT: 'ai_user_prompt', PROOFREAD_PROMPT: 'proofread_prompt', @@ -64,6 +65,7 @@ export const SETTINGS = { DEEPSEEK_REQUEST_TEMPLATE: 'deepseek_request_template', GEMINI_REQUEST_TEMPLATE: 'gemini_request_template', MISTRAL_REQUEST_TEMPLATE: 'mistral_request_template', + XAI_REQUEST_TEMPLATE: 'xai_request_template', LANGUAGE_CODE_FORMAT: 'language_code_format', RADARR_DEFAULT_INCLUDE: 'radarr_default_include', SONARR_DEFAULT_INCLUDE: 'sonarr_default_include' @@ -91,6 +93,7 @@ export interface ISettings { gemini_model: string deepseek_model: string mistral_model: string + xai_model: string ai_prompt: string ai_user_prompt: string proofread_prompt: string @@ -132,6 +135,7 @@ export interface ISettings { deepseek_request_template: string gemini_request_template: string mistral_request_template: string + xai_request_template: string language_code_format: string radarr_default_include: string sonarr_default_include: string @@ -147,6 +151,7 @@ export const ENCRYPTED_SETTINGS = { GEMINI_API_KEY: 'gemini_api_key', DEEPSEEK_API_KEY: 'deepseek_api_key', MISTRAL_API_KEY: 'mistral_api_key', + XAI_API_KEY: 'xai_api_key', DEEPL_API_KEY: 'deepl_api_key', LIBRETRANSLATE_API_KEY: 'libretranslate_api_key', LOCAL_AI_API_KEY: 'local_ai_api_key', @@ -161,6 +166,7 @@ export interface IEncryptedSettings { gemini_api_key: string deepseek_api_key: string mistral_api_key: string + xai_api_key: string deepl_api_key: string libretranslate_api_key: string local_ai_api_key: string @@ -175,6 +181,7 @@ export const SERVICE_TYPE = { GEMINI: 'gemini', DEEPSEEK: 'deepseek', MISTRAL: 'mistral', + XAI: 'xai', GOOGLE: 'google', BING: 'bing', MICROSOFT: 'microsoft', diff --git a/Lingarr.Core/Configuration/SettingKeys.cs b/Lingarr.Core/Configuration/SettingKeys.cs index 7731d212..93211647 100644 --- a/Lingarr.Core/Configuration/SettingKeys.cs +++ b/Lingarr.Core/Configuration/SettingKeys.cs @@ -69,6 +69,13 @@ public static class Mistral public const string RequestTemplate = "mistral_request_template"; } + public static class XAi + { + public const string Model = "xai_model"; + public const string ApiKey = "xai_api_key"; + public const string RequestTemplate = "xai_request_template"; + } + public static class LibreTranslate { public const string Url = "libretranslate_url"; diff --git a/Lingarr.Docs/developers/plugins.md b/Lingarr.Docs/developers/plugins.md index 1577a73d..62bd4001 100644 --- a/Lingarr.Docs/developers/plugins.md +++ b/Lingarr.Docs/developers/plugins.md @@ -60,7 +60,7 @@ Lingarr scans a folder specified by the `PLUGINS_PATH` environment variable at s Do not use these plugin identifiers (they are used by built-in providers): -`anthropic`, `openai`, `gemini`, `deepseek`, `mistral`, `localai`, `deepl`, `libretranslate`, `google`, `bing`, `microsoft`, `yandex` +`anthropic`, `openai`, `gemini`, `deepseek`, `mistral`, `xai`, `localai`, `deepl`, `libretranslate`, `google`, `bing`, `microsoft`, `yandex` ## Settings diff --git a/Lingarr.Docs/getting-started/configuration.md b/Lingarr.Docs/getting-started/configuration.md index 0fdf6a93..01e6d044 100644 --- a/Lingarr.Docs/getting-started/configuration.md +++ b/Lingarr.Docs/getting-started/configuration.md @@ -91,7 +91,7 @@ The `SOURCE_LANGUAGES` and `TARGET_LANGUAGES` variables should be provided as a The supported values are: -- **[AI services](/translation-services/ai-services)**: [`openai`](/translation-services/ai-services#openai), [`anthropic`](/translation-services/ai-services#anthropic), [`gemini`](/translation-services/ai-services#gemini), [`deepseek`](/translation-services/ai-services#deepseek), [`mistral`](/translation-services/ai-services#mistral) and [`localai`](/translation-services/ai-services#localai) +- **[AI services](/translation-services/ai-services)**: [`openai`](/translation-services/ai-services#openai), [`anthropic`](/translation-services/ai-services#anthropic), [`gemini`](/translation-services/ai-services#gemini), [`deepseek`](/translation-services/ai-services#deepseek), [`mistral`](/translation-services/ai-services#mistral), [`xai`](/translation-services/ai-services#xai) and [`localai`](/translation-services/ai-services#localai) - **[Machine translation](/translation-services/machine-translation)**: [`libretranslate`](/translation-services/machine-translation#libretranslate), [`deepl`](/translation-services/machine-translation#deepl), [`google`](/translation-services/machine-translation#google-bing-microsoft-and-yandex), [`bing`](/translation-services/machine-translation#google-bing-microsoft-and-yandex), [`microsoft`](/translation-services/machine-translation#google-bing-microsoft-and-yandex) and [`yandex`](/translation-services/machine-translation#google-bing-microsoft-and-yandex) Each service has its own configuration variables, such as API keys and model selection, documented on its settings page. diff --git a/Lingarr.Docs/translation-services/ai-services.md b/Lingarr.Docs/translation-services/ai-services.md index 3e44e836..b814a2bd 100644 --- a/Lingarr.Docs/translation-services/ai-services.md +++ b/Lingarr.Docs/translation-services/ai-services.md @@ -52,7 +52,7 @@ Batch translation does not use the user prompt. The batch is sent as the user me Proofreading re-examines a completed translation. For each subtitle line it sends the source line and the existing translation together to the AI service and asks for a corrected translation, without translating from scratch. -Only services that implement proofreading offer it: OpenAI, Anthropic, Gemini, DeepSeek, Mistral and LocalAI. LibreTranslate, DeepL, Google, Bing, Microsoft and Yandex do not support proofreading. +Only services that implement proofreading offer it: OpenAI, Anthropic, Gemini, DeepSeek, Mistral, xAI and LocalAI. LibreTranslate, DeepL, Google, Bing, Microsoft and Yandex do not support proofreading. You can run it two ways: @@ -121,6 +121,15 @@ Both accept the placeholders already listed above, plus two more: | `AI_PROMPT` | The system prompt template. | | `AI_USER_PROMPT` | The user message template. | +### xAI + +| **Environment Variable** | **Description** | +|--------------------------|-------------------------------------------------------------| +| `XAI_MODEL` | The model to use for xAI translations. Example: `grok-4.5`. | +| `XAI_API_KEY` | The API key for authenticating with xAI. | +| `AI_PROMPT` | The system prompt template. | +| `AI_USER_PROMPT` | The user message template. | + ### LocalAI LocalAI works with Ollama or any other OpenAI-compatible model or router. diff --git a/Lingarr.Migrations/Migrations/M0018_SeedXAiSettings.cs b/Lingarr.Migrations/Migrations/M0018_SeedXAiSettings.cs new file mode 100644 index 00000000..30308dfb --- /dev/null +++ b/Lingarr.Migrations/Migrations/M0018_SeedXAiSettings.cs @@ -0,0 +1,42 @@ +using FluentMigrator; + +namespace Lingarr.Migrations.Migrations; + +[Migration(18)] +public class M0018_SeedXAiSettings : Migration +{ + public override void Up() + { + Insert.IntoTable("settings").Row(new + { + key = "xai_model", + value = "" + }); + Insert.IntoTable("settings").Row(new + { + key = "xai_api_key", + value = "" + }); + Insert.IntoTable("settings").Row(new + { + key = "xai_request_template", + value = "" + }); + } + + public override void Down() + { + Delete.FromTable("settings").Row(new + { + key = "xai_model" + }); + Delete.FromTable("settings").Row(new + { + key = "xai_api_key" + }); + Delete.FromTable("settings").Row(new + { + key = "xai_request_template" + }); + } +} diff --git a/Lingarr.Server.Tests/Services/StartupServiceTests.cs b/Lingarr.Server.Tests/Services/StartupServiceTests.cs index 91f8df5d..60f8f01e 100644 --- a/Lingarr.Server.Tests/Services/StartupServiceTests.cs +++ b/Lingarr.Server.Tests/Services/StartupServiceTests.cs @@ -43,6 +43,8 @@ private static readonly (string EnvVar, string SettingKey)[] EnvMap = ("DEEPSEEK_API_KEY", SettingKeys.Translation.DeepSeek.ApiKey), ("MISTRAL_MODEL", SettingKeys.Translation.Mistral.Model), ("MISTRAL_API_KEY", SettingKeys.Translation.Mistral.ApiKey), + ("XAI_MODEL", SettingKeys.Translation.XAi.Model), + ("XAI_API_KEY", SettingKeys.Translation.XAi.ApiKey), ("DEEPL_API_KEY", SettingKeys.Translation.DeepL.DeeplApiKey), ("AUTH_ENABLED", SettingKeys.Authentication.AuthEnabled), ("TELEMETRY_ENABLED", SettingKeys.Telemetry.TelemetryEnabled) diff --git a/Lingarr.Server/Extensions/ApplicationBuilderExtensions.cs b/Lingarr.Server/Extensions/ApplicationBuilderExtensions.cs index b1e456ac..b436f1fc 100644 --- a/Lingarr.Server/Extensions/ApplicationBuilderExtensions.cs +++ b/Lingarr.Server/Extensions/ApplicationBuilderExtensions.cs @@ -80,6 +80,7 @@ private static async Task MigrateApiKeyEncryption(this WebApplication app) SettingKeys.Translation.Gemini.ApiKey, SettingKeys.Translation.DeepSeek.ApiKey, SettingKeys.Translation.Mistral.ApiKey, + SettingKeys.Translation.XAi.ApiKey, SettingKeys.Translation.DeepL.DeeplApiKey, SettingKeys.Translation.LibreTranslate.ApiKey, SettingKeys.Translation.LocalAi.ApiKey, diff --git a/Lingarr.Server/Extensions/ServiceCollectionExtensions.cs b/Lingarr.Server/Extensions/ServiceCollectionExtensions.cs index d914374f..4b69dd08 100644 --- a/Lingarr.Server/Extensions/ServiceCollectionExtensions.cs +++ b/Lingarr.Server/Extensions/ServiceCollectionExtensions.cs @@ -187,6 +187,7 @@ private static void ConfigureServices(this WebApplicationBuilder builder) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/Lingarr.Server/Interfaces/Services/Translation/ITranslationServiceFactory.cs b/Lingarr.Server/Interfaces/Services/Translation/ITranslationServiceFactory.cs index f97037c1..985cd021 100644 --- a/Lingarr.Server/Interfaces/Services/Translation/ITranslationServiceFactory.cs +++ b/Lingarr.Server/Interfaces/Services/Translation/ITranslationServiceFactory.cs @@ -9,7 +9,7 @@ public interface ITranslationServiceFactory /// /// Creates and returns an instance of a translation service based on the specified service type. /// - /// The provider name (case-insensitive), e.g. "libretranslate", "openai", "anthropic", "deepl", "gemini", "deepseek", "mistral", "localai", "google", "bing", "microsoft", "yandex". + /// The provider name (case-insensitive), e.g. "libretranslate", "openai", "anthropic", "deepl", "gemini", "deepseek", "mistral", "xai", "localai", "google", "bing", "microsoft", "yandex". /// An instance of corresponding to the specified service type. /// Thrown when an unsupported service type is specified. ITranslationService CreateTranslationService(string serviceType); diff --git a/Lingarr.Server/Listener/SettingChangedListener.cs b/Lingarr.Server/Listener/SettingChangedListener.cs index 264dada2..589574dc 100644 --- a/Lingarr.Server/Listener/SettingChangedListener.cs +++ b/Lingarr.Server/Listener/SettingChangedListener.cs @@ -18,7 +18,7 @@ public class SettingChangedListener private readonly ILogger _logger; private static readonly HashSet BatchServiceTypes = new(StringComparer.OrdinalIgnoreCase) { - "openai", "anthropic", "localai", "gemini", "mistral" + "openai", "anthropic", "localai", "gemini", "mistral", "xai" }; public SettingChangedListener(IServiceProvider serviceProvider, diff --git a/Lingarr.Server/Models/RequestTemplates/XAiTemplate.cs b/Lingarr.Server/Models/RequestTemplates/XAiTemplate.cs new file mode 100644 index 00000000..c6326807 --- /dev/null +++ b/Lingarr.Server/Models/RequestTemplates/XAiTemplate.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace Lingarr.Server.Models.RequestTemplates; + +public class XAiTemplate +{ + [JsonPropertyName("model")] + public string Model { get; set; } = "{model}"; + + [JsonPropertyName("messages")] + public List Messages { get; set; } = + [ + new() + { + Role = "system", + Content = "{systemPrompt}" + }, + new() + { + Role = "user", + Content = "{userMessage}" + } + ]; + + [JsonPropertyName("stream")] + public bool Stream { get; set; } = false; +} diff --git a/Lingarr.Server/Services/Plugins/Manifests/XAiPluginManifest.cs b/Lingarr.Server/Services/Plugins/Manifests/XAiPluginManifest.cs new file mode 100644 index 00000000..2ded0171 --- /dev/null +++ b/Lingarr.Server/Services/Plugins/Manifests/XAiPluginManifest.cs @@ -0,0 +1,40 @@ +using Lingarr.Contracts.Interfaces.Plugins; +using Lingarr.Contracts.Plugins; +using Lingarr.Core.Configuration; + +namespace Lingarr.Server.Services.Plugins.Manifests; + +public sealed class XAiPluginManifest : IPluginManifest +{ + public string Provider => "xai"; + + public string DisplayName => "xAI"; + + public string? Description => + "xAI's OpenAI-compatible chat completion models. AI translation can be costly, only use it when you know what you are doing and keep automation disabled."; + + public bool HasRequestTemplate => true; + + public IReadOnlyList Settings { get; } = + [ + new() + { + Key = SettingKeys.Translation.XAi.ApiKey, + Label = "API key", + Type = PluginSettingType.Secret, + Required = true, + Description = "xAI API key. Stored encrypted.", + MinLength = 1, + ValidationErrorMessage = "Value must not be empty" + }, + new() + { + Key = SettingKeys.Translation.XAi.Model, + Label = "AI Model", + Type = PluginSettingType.RemoteDropdown, + Required = true, + OptionsEndpoint = "/api/plugin/xai/models", + Description = "Select a model from your xAI catalogue." + } + ]; +} diff --git a/Lingarr.Server/Services/StartupService.cs b/Lingarr.Server/Services/StartupService.cs index 01953a51..969c559a 100644 --- a/Lingarr.Server/Services/StartupService.cs +++ b/Lingarr.Server/Services/StartupService.cs @@ -256,6 +256,9 @@ private async Task ApplySettingsFromEnvironment(LingarrDbContext dbContext) { "MISTRAL_MODEL", SettingKeys.Translation.Mistral.Model }, { "MISTRAL_API_KEY", SettingKeys.Translation.Mistral.ApiKey }, + { "XAI_MODEL", SettingKeys.Translation.XAi.Model }, + { "XAI_API_KEY", SettingKeys.Translation.XAi.ApiKey }, + { "DEEPL_API_KEY", SettingKeys.Translation.DeepL.DeeplApiKey }, { "AUTH_ENABLED", SettingKeys.Authentication.AuthEnabled }, diff --git a/Lingarr.Server/Services/Translation/AnthropicService.cs b/Lingarr.Server/Services/Translation/AnthropicService.cs index 9cb512af..422c10c2 100644 --- a/Lingarr.Server/Services/Translation/AnthropicService.cs +++ b/Lingarr.Server/Services/Translation/AnthropicService.cs @@ -504,16 +504,18 @@ public override async Task GetModels() var response = await _httpClient.SendAsync(request); + var responseContent = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { - _logger.LogError("Failed to fetch models. Status: {StatusCode}", response.StatusCode); + _logger.LogError( + "Failed to fetch models. Status: {StatusCode}, response: {ResponseContent}", + response.StatusCode, responseContent); return new ModelsResponse { - Message = $"Failed to fetch models. Status: {response.StatusCode}" + Message = $"Failed to fetch models. Status: {response.StatusCode}, response: {responseContent}" }; } - var responseContent = await response.Content.ReadAsStringAsync(); var jsonResponse = JsonSerializer.Deserialize(responseContent); if (!jsonResponse.TryGetProperty("data", out var dataElement)) diff --git a/Lingarr.Server/Services/Translation/DeepSeekService.cs b/Lingarr.Server/Services/Translation/DeepSeekService.cs index dfd8ba89..a7737fd5 100644 --- a/Lingarr.Server/Services/Translation/DeepSeekService.cs +++ b/Lingarr.Server/Services/Translation/DeepSeekService.cs @@ -182,16 +182,18 @@ public override async Task GetModels() var response = await _httpClient.SendAsync(request); + var responseContent = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { - _logger.LogError("Failed to fetch models. Status: {StatusCode}", response.StatusCode); + _logger.LogError( + "Failed to fetch models. Status: {StatusCode}, response: {ResponseContent}", + response.StatusCode, responseContent); return new ModelsResponse { - Message = $"Failed to fetch models. Status: {response.StatusCode}" + Message = $"Failed to fetch models. Status: {response.StatusCode}, response: {responseContent}" }; } - var responseContent = await response.Content.ReadAsStringAsync(); var jsonResponse = JsonSerializer.Deserialize(responseContent); if (!jsonResponse.TryGetProperty("data", out var dataElement)) diff --git a/Lingarr.Server/Services/Translation/GoogleGeminiService.cs b/Lingarr.Server/Services/Translation/GoogleGeminiService.cs index 7e9acad6..2672a853 100644 --- a/Lingarr.Server/Services/Translation/GoogleGeminiService.cs +++ b/Lingarr.Server/Services/Translation/GoogleGeminiService.cs @@ -279,16 +279,18 @@ public override async Task GetModels() var request = new HttpRequestMessage(HttpMethod.Get, $"{_endpoint}/models?key={apiKey}"); var response = await _httpClient.SendAsync(request); + var responseContent = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { - _logger.LogError("Failed to fetch models. Status: {StatusCode}", response.StatusCode); + _logger.LogError( + "Failed to fetch models. Status: {StatusCode}, response: {ResponseContent}", + response.StatusCode, responseContent); return new ModelsResponse { - Message = $"Failed to fetch models. Status: {response.StatusCode}" + Message = $"Failed to fetch models. Status: {response.StatusCode}, response: {responseContent}" }; } - var responseContent = await response.Content.ReadAsStringAsync(); var jsonResponse = JsonSerializer.Deserialize(responseContent); if (!jsonResponse.TryGetProperty("models", out var modelsElement)) diff --git a/Lingarr.Server/Services/Translation/MistralService.cs b/Lingarr.Server/Services/Translation/MistralService.cs index 27b12d0a..fdbf2960 100644 --- a/Lingarr.Server/Services/Translation/MistralService.cs +++ b/Lingarr.Server/Services/Translation/MistralService.cs @@ -318,8 +318,6 @@ private async Task> TranslateBatchWithMistralApi( CancellationToken cancellationToken) { var requestUrl = $"{_endpoint}/chat/completions"; - // Mistral only honours a json_schema response format when the schema object also - // carries name and strict, unlike the OpenAI dialect where strict is optional. var responseFormat = new { type = "json_schema", @@ -448,10 +446,13 @@ public override async Task GetModels() if (!response.IsSuccessStatusCode) { - _logger.LogError("Failed to fetch models. Status: {StatusCode}", response.StatusCode); + var responseContent = await response.Content.ReadAsStringAsync(); + _logger.LogError( + "Failed to fetch models. Status: {StatusCode}, response: {ResponseContent}", + response.StatusCode, responseContent); return new ModelsResponse { - Message = $"Failed to fetch models. Status: {response.StatusCode}" + Message = $"Failed to fetch models. Status: {response.StatusCode}, response: {responseContent}" }; } diff --git a/Lingarr.Server/Services/Translation/OpenAiService.cs b/Lingarr.Server/Services/Translation/OpenAiService.cs index 98cbb3f5..88e51ce9 100644 --- a/Lingarr.Server/Services/Translation/OpenAiService.cs +++ b/Lingarr.Server/Services/Translation/OpenAiService.cs @@ -445,10 +445,13 @@ public override async Task GetModels() if (!response.IsSuccessStatusCode) { - _logger.LogError("Failed to fetch models. Status: {StatusCode}", response.StatusCode); + var responseContent = await response.Content.ReadAsStringAsync(); + _logger.LogError( + "Failed to fetch models. Status: {StatusCode}, response: {ResponseContent}", + response.StatusCode, responseContent); return new ModelsResponse { - Message = $"Failed to fetch models. Status: {response.StatusCode}" + Message = $"Failed to fetch models. Status: {response.StatusCode}, response: {responseContent}" }; } diff --git a/Lingarr.Server/Services/Translation/RequestTemplateService.cs b/Lingarr.Server/Services/Translation/RequestTemplateService.cs index 0d3767b1..792204c6 100644 --- a/Lingarr.Server/Services/Translation/RequestTemplateService.cs +++ b/Lingarr.Server/Services/Translation/RequestTemplateService.cs @@ -26,7 +26,9 @@ public class RequestTemplateService : IRequestTemplateService [SettingKeys.Translation.Gemini.RequestTemplate] = () => JsonSerializer.Serialize(new GeminiTemplate()), [SettingKeys.Translation.Mistral.RequestTemplate] = - () => JsonSerializer.Serialize(new MistralTemplate()) + () => JsonSerializer.Serialize(new MistralTemplate()), + [SettingKeys.Translation.XAi.RequestTemplate] = + () => JsonSerializer.Serialize(new XAiTemplate()) }; /// diff --git a/Lingarr.Server/Services/Translation/TranslationFactory.cs b/Lingarr.Server/Services/Translation/TranslationFactory.cs index 9e8533ef..3d6439e2 100644 --- a/Lingarr.Server/Services/Translation/TranslationFactory.cs +++ b/Lingarr.Server/Services/Translation/TranslationFactory.cs @@ -110,6 +110,14 @@ public ITranslationService CreateTranslationService(string serviceType) _serviceProvider.GetRequiredService() ), + "xai" => new XAiService( + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService>(), + languageCodeService, + _serviceProvider.GetRequiredService() + ), + "gemini" => new GoogleGeminiService( _serviceProvider.GetRequiredService(), _serviceProvider.GetRequiredService(), diff --git a/Lingarr.Server/Services/Translation/XAiService.cs b/Lingarr.Server/Services/Translation/XAiService.cs new file mode 100644 index 00000000..c85c3edd --- /dev/null +++ b/Lingarr.Server/Services/Translation/XAiService.cs @@ -0,0 +1,499 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Lingarr.Contracts.Exceptions; +using Lingarr.Contracts.Models; +using Lingarr.Contracts.Models.Batch; +using Lingarr.Contracts.Translation; +using Lingarr.Core.Configuration; +using Lingarr.Server.Interfaces.Services; +using Lingarr.Server.Models; +using Lingarr.Server.Services.Translation.Base; + +namespace Lingarr.Server.Services.Translation; + +public class XAiService : BaseLanguageService, ITranslationService, IBatchTranslationService, IProofreadService +{ + private readonly string? _endpoint = "https://api.x.ai/v1"; + private string? _model; + private string? _apiKey; + private string? _requestTemplate; + private readonly HttpClient _httpClient; + private readonly IRequestTemplateService _requestTemplateService; + private bool _initialized; + private readonly SemaphoreSlim _initLock = new(1, 1); + + /// + public override string? ModelName => _model; + + // retry settings + private int _maxRetries; + private TimeSpan _retryDelay; + private int _retryDelayMultiplier; + + public XAiService( + ISettingService settings, + HttpClient httpClient, + ILogger logger, + LanguageCodeService languageCodeService, + IRequestTemplateService requestTemplateService) + : base(settings, logger, languageCodeService) + { + _httpClient = httpClient; + _requestTemplateService = requestTemplateService; + } + + /// + /// Initializes the translation service with necessary configurations and credentials. + /// This method is thread-safe and ensures one-time initialization of service dependencies. + /// + /// The source language code for translation + /// The target language code for translation + /// A task that represents the asynchronous initialization operation + /// Thrown when required configuration settings are missing or invalid + private async Task InitializeAsync(string sourceLanguage, string targetLanguage) + { + if (_initialized) return; + + try + { + await _initLock.WaitAsync(); + if (_initialized) return; + + var settings = await _settings.GetSettings([ + SettingKeys.Translation.XAi.Model, + SettingKeys.Translation.XAi.RequestTemplate, + SettingKeys.Translation.AiPrompt, + SettingKeys.Translation.AiUserPrompt, + SettingKeys.Translation.ProofreadPrompt, + SettingKeys.Translation.ProofreadUserPrompt, + SettingKeys.Translation.RequestTimeout, + SettingKeys.Translation.MaxRetries, + SettingKeys.Translation.RetryDelay, + SettingKeys.Translation.RetryDelayMultiplier, + SettingKeys.Translation.LanguageCodeFormat + ]); + + _model = settings[SettingKeys.Translation.XAi.Model]; + _apiKey = await _settings.GetEncryptedSetting(SettingKeys.Translation.XAi.ApiKey); + _requestTemplate = !string.IsNullOrEmpty(settings[SettingKeys.Translation.XAi.RequestTemplate]) + ? settings[SettingKeys.Translation.XAi.RequestTemplate] + : _requestTemplateService.GetDefaultTemplate(SettingKeys.Translation.XAi.RequestTemplate); + + if (string.IsNullOrEmpty(_model) || string.IsNullOrEmpty(_apiKey)) + { + throw new InvalidOperationException("xAI API key or model is not configured."); + } + + SetLanguageReplacements(sourceLanguage, targetLanguage, settings[SettingKeys.Translation.LanguageCodeFormat]); + _prompt = settings[SettingKeys.Translation.AiPrompt]; + _userPrompt = settings[SettingKeys.Translation.AiUserPrompt]; + _proofreadPrompt = settings.GetValueOrDefault(SettingKeys.Translation.ProofreadPrompt); + _proofreadUserPrompt = settings.GetValueOrDefault(SettingKeys.Translation.ProofreadUserPrompt); + + var requestTimeout = int.TryParse(settings[SettingKeys.Translation.RequestTimeout], + out var timeOut) + ? timeOut + : 5; + _httpClient.Timeout = TimeSpan.FromMinutes(requestTimeout); + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); + _httpClient.DefaultRequestHeaders.Add("Accept", "application/json"); + + _maxRetries = int.TryParse(settings[SettingKeys.Translation.MaxRetries], out var maxRetries) + ? maxRetries + : 5; + var retryDelaySeconds = int.TryParse(settings[SettingKeys.Translation.RetryDelay], out var delaySeconds) + ? delaySeconds + : 1; + _retryDelay = TimeSpan.FromSeconds(retryDelaySeconds); + _retryDelayMultiplier = int.TryParse(settings[SettingKeys.Translation.RetryDelayMultiplier], out var multiplier) + ? multiplier + : 2; + + _initialized = true; + } + finally + { + _initLock.Release(); + } + } + + /// + public override async Task TranslateAsync( + string text, + string sourceLanguage, + string targetLanguage, + List? contextLinesBefore, + List? contextLinesAfter, + CancellationToken cancellationToken) + { + await InitializeAsync(sourceLanguage, targetLanguage); + + using var retry = new CancellationTokenSource(); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, retry.Token); + + var delay = _retryDelay; + for (var attempt = 1; attempt <= _maxRetries; attempt++) + { + try + { + var replacements = GetReplacements(_model!, text, contextLinesBefore, contextLinesAfter); + return await CompleteWithXAiApi(replacements, linked.Token); + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.ServiceUnavailable) + { + if (attempt == _maxRetries) + { + _logger.LogError(ex, "Max retries exhausted ({StatusCode}) for text: {Text}", ex.StatusCode, text); + throw new TranslationException($"Retry limit reached after {ex.StatusCode}.", ex); + } + + await Task.Delay(delay, linked.Token).ConfigureAwait(false); + delay = TimeSpan.FromTicks(delay.Ticks * _retryDelayMultiplier); + + _logger.LogWarning( + "{ServiceName} received {StatusCode}. Retrying in {Delay}... (Attempt {Attempt}/{MaxRetries})", + "xAI", ex.StatusCode, delay, attempt, _maxRetries); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred during xAI translation"); + throw new TranslationException("Failed to translate using xAI", ex); + } + } + + throw new TranslationException("Translation failed after maximum retry attempts."); + } + + /// + public async Task ProofreadAsync( + string sourceText, + string translatedText, + string sourceLanguage, + string targetLanguage, + CancellationToken cancellationToken) + { + await InitializeAsync(sourceLanguage, targetLanguage); + + using var retry = new CancellationTokenSource(); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, retry.Token); + + var delay = _retryDelay; + for (var attempt = 1; attempt <= _maxRetries; attempt++) + { + try + { + var replacements = GetProofreadReplacements(_model!, sourceText, translatedText); + return await CompleteWithXAiApi(replacements, linked.Token); + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.ServiceUnavailable) + { + if (attempt == _maxRetries) + { + _logger.LogError(ex, "Max retries exhausted ({StatusCode}) for text: {Text}", ex.StatusCode, translatedText); + throw new TranslationException($"Retry limit reached after {ex.StatusCode}.", ex); + } + + await Task.Delay(delay, linked.Token).ConfigureAwait(false); + delay = TimeSpan.FromTicks(delay.Ticks * _retryDelayMultiplier); + + _logger.LogWarning( + "{ServiceName} received {StatusCode}. Retrying in {Delay}... (Attempt {Attempt}/{MaxRetries})", + "xAI", ex.StatusCode, delay, attempt, _maxRetries); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred during xAI proofread"); + throw new TranslationException("Failed to proofread using xAI", ex); + } + } + + throw new TranslationException("Proofread failed after maximum retry attempts."); + } + + private async Task CompleteWithXAiApi( + Dictionary replacements, + CancellationToken cancellationToken) + { + var requestUrl = $"{_endpoint}/chat/completions"; + var bodyJson = _requestTemplateService.BuildRequestBody(_requestTemplate!, replacements); + var requestContent = new StringContent( + bodyJson, + Encoding.UTF8, + "application/json"); + + var response = await _httpClient.PostAsync(requestUrl, requestContent, cancellationToken); + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.ServiceUnavailable) + { + throw new HttpRequestException( + $"xAI returned {response.StatusCode}", null, response.StatusCode); + } + + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogError( + "xAI API request failed with status {StatusCode}: {ResponseContent}", + response.StatusCode, responseContent); + throw new TranslationException( + $"xAI API request failed with status {response.StatusCode}: {responseContent}"); + } + + var completionResponse = + await response.Content.ReadFromJsonAsync(cancellationToken); + if (completionResponse?.Choices == null || completionResponse.Choices.Count == 0) + { + throw new TranslationException("No completion choices returned from xAI"); + } + + return completionResponse.Choices[0].Message.Content; + } + + /// + /// Translates a batch of subtitles in a single API call using structured outputs + /// + /// List of subtitles with position and content + /// Source language code + /// Target language code + /// Cancellation token + /// Dictionary mapping position to translated content + public async Task> TranslateBatchAsync( + List subtitleBatch, + string sourceLanguage, + string targetLanguage, + CancellationToken cancellationToken) + { + await InitializeAsync(sourceLanguage, targetLanguage); + + using var retry = new CancellationTokenSource(); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, retry.Token); + + var delay = _retryDelay; + for (var attempt = 1; attempt <= _maxRetries; attempt++) + { + try + { + return await TranslateBatchWithXAiApi(subtitleBatch, linked.Token); + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.ServiceUnavailable) + { + if (attempt == _maxRetries) + { + _logger.LogError(ex, "Max retries exhausted ({StatusCode}) for batch translation", ex.StatusCode); + throw new TranslationException($"Retry limit reached after {ex.StatusCode}.", ex); + } + + await Task.Delay(delay, linked.Token).ConfigureAwait(false); + delay = TimeSpan.FromTicks(delay.Ticks * _retryDelayMultiplier); + + _logger.LogWarning( + "{ServiceName} received {StatusCode}. Retrying in {Delay}... (Attempt {Attempt}/{MaxRetries})", + "xAI", ex.StatusCode, delay, attempt, _maxRetries); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error during batch translation attempt {Attempt}", attempt); + throw new TranslationException("Unexpected error occurred during batch translation.", ex); + } + } + + throw new TranslationException("Batch translation failed after maximum retry attempts."); + } + + private async Task> TranslateBatchWithXAiApi( + List subtitleBatch, + CancellationToken cancellationToken) + { + var requestUrl = $"{_endpoint}/chat/completions"; + var responseFormat = new + { + type = "json_schema", + json_schema = new + { + name = "batch_translation_response", + strict = true, + schema = new + { + type = "object", + properties = new + { + translations = new + { + type = "array", + items = new + { + type = "object", + properties = new + { + position = new + { + type = "integer" + }, + line = new + { + type = "string" + } + }, + required = new[] { "position", "line" }, + additionalProperties = false + } + } + }, + required = new[] { "translations" }, + additionalProperties = false + } + } + }; + + var replacements = GetBatchReplacements(_model!, JsonSerializer.Serialize(subtitleBatch)); + var bodyJson = _requestTemplateService.BuildRequestBody(_requestTemplate!, replacements); + bodyJson = _requestTemplateService.SetRequestFields(bodyJson, new Dictionary + { + ["response_format"] = responseFormat + }); + + var requestContent = new StringContent( + bodyJson, + Encoding.UTF8, + "application/json"); + + var response = await _httpClient.PostAsync(requestUrl, requestContent, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.ServiceUnavailable) + { + throw new HttpRequestException( + $"Batch translation using xAI API failed with {response.StatusCode}.", + null, response.StatusCode); + } + + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogError( + "xAI batch API request failed with status {StatusCode}: {ResponseContent}", + response.StatusCode, responseContent); + throw new TranslationException( + $"xAI batch API request failed with status {response.StatusCode}: {responseContent}"); + } + + var completionResponse = await response.Content.ReadFromJsonAsync(cancellationToken); + if (completionResponse?.Choices == null || completionResponse.Choices.Count == 0) + { + throw new TranslationException("No completion choices returned from xAI"); + } + + var translatedJson = completionResponse.Choices[0].Message.Content; + try + { + var responseWrapper = JsonSerializer.Deserialize(translatedJson); + if (!responseWrapper.TryGetProperty("translations", out var translationsElement)) + { + throw new TranslationException("Response does not contain 'translations' property"); + } + + var translatedItems = + JsonSerializer.Deserialize>(translationsElement.GetRawText()); + if (translatedItems == null) + { + throw new TranslationException("Failed to deserialize translated subtitles"); + } + + return MergeByPosition(translatedItems); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Failed to parse translated JSON: {Json}", translatedJson); + throw new TranslationException("Failed to parse translated subtitles", ex); + } + } + + /// + public override async Task GetModels() + { + var apiKey = await _settings.GetEncryptedSetting( + SettingKeys.Translation.XAi.ApiKey + ); + + if (string.IsNullOrEmpty(apiKey)) + { + return new ModelsResponse + { + Message = "xAI API key is not configured." + }; + } + + try + { + var client = new HttpClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + + var requestUrl = $"{_endpoint}/models"; + var response = await client.GetAsync(requestUrl); + + if (!response.IsSuccessStatusCode) + { + var responseContent = await response.Content.ReadAsStringAsync(); + _logger.LogError( + "Failed to fetch models. Status: {StatusCode}, response: {ResponseContent}", + response.StatusCode, responseContent); + return new ModelsResponse + { + Message = $"Failed to fetch models. Status: {response.StatusCode}, response: {responseContent}" + }; + } + + var modelsResponse = await response.Content.ReadFromJsonAsync(); + + if (modelsResponse?.Data == null) + { + return new ModelsResponse + { + Message = "No models data returned from xAI API." + }; + } + + var labelValues = modelsResponse.Data + .Select(model => new LabelValue + { + Label = model.Id, + Value = model.Id + }) + .ToList(); + + return new ModelsResponse + { + Options = labelValues + }; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "HTTP error fetching models from xAI API"); + return new ModelsResponse + { + Message = $"HTTP error fetching models from xAI API: {ex.Message}" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching models from xAI API"); + return new ModelsResponse + { + Message = $"Error fetching models from xAI API: {ex.Message}" + }; + } + } +} diff --git a/Readme.MD b/Readme.MD index 608dffde..d3377f28 100644 --- a/Readme.MD +++ b/Readme.MD @@ -20,6 +20,7 @@ Lingarr now offers multiple services for automated translation: - **[OpenAI](https://openai.com/)** - **[DeepSeek](https://deepseek.com)** - **[Mistral](https://mistral.ai)** +- **[xAI](https://x.ai)** - **[Gemini](https://gemini.google.com/)** - **[Google](https://translate.google.com/)** - **[Bing](https://www.bing.com/translator)** diff --git a/Settings.MD b/Settings.MD index baa48332..070ac5e1 100644 --- a/Settings.MD +++ b/Settings.MD @@ -142,6 +142,15 @@ The `SOURCE_LANGUAGES` and `TARGET_LANGUAGES` variables should be provided as a | `AI_PROMPT` | The prompt template for AI-based translation services. | | `AI_USER_PROMPT` | The user message template. Supports `{lineToTranslate}`, `{contextBefore}` and `{contextAfter}`. | +#### **xAI** + +| **Environment Variable** | **Description** | +|--------------------------|---------------------------------------------------------------------------------| +| `XAI_MODEL` | The model to use for xAI translations. Example: `grok-4.5`. | +| `XAI_API_KEY` | The API key for authenticating with xAI. | +| `AI_PROMPT` | The prompt template for AI-based translation services. | +| `AI_USER_PROMPT` | The user message template. Supports `{lineToTranslate}`, `{contextBefore}` and `{contextAfter}`. | + #### **LocalAI** | **Environment Variable** | **Description** | @@ -154,7 +163,7 @@ The `SOURCE_LANGUAGES` and `TARGET_LANGUAGES` variables should be provided as a #### **Proofreading** -Proofreading is only available for the AI services above (OpenAI, Anthropic, Gemini, DeepSeek, Mistral, LocalAI), and only for a `service_type` entry that supports it. It uses two more settings, shared across all AI services the same way `AI_PROMPT` and `AI_USER_PROMPT` are. These are configured through **Settings > Services > Request Settings** in the web interface, or through their environment variables: +Proofreading is only available for the AI services above (OpenAI, Anthropic, Gemini, DeepSeek, Mistral, xAI, LocalAI), and only for a `service_type` entry that supports it. It uses two more settings, shared across all AI services the same way `AI_PROMPT` and `AI_USER_PROMPT` are. These are configured through **Settings > Services > Request Settings** in the web interface, or through their environment variables: | **Setting** | **Environment Variable** | **Default** | |-------------|--------------------------|-------------| @@ -192,6 +201,7 @@ The supported values are: | `gemini` | Use Google's Gemini models for translations. | | `deepseek` | Use DeepSeek's models for translations. | | `mistral` | Use Mistral AI's models for translations. | +| `xai` | Use xAI's models for translations. | | `localai` | Use a locally hosted or OpenAI-compatible AI model for translations. | | `deepl` | Use the DeepL API for translations. | | `google` | Use Google Translate for translations. | diff --git a/samples/CloudflarePlugin/README.md b/samples/CloudflarePlugin/README.md index af9ec3f8..92d55ac6 100644 --- a/samples/CloudflarePlugin/README.md +++ b/samples/CloudflarePlugin/README.md @@ -78,7 +78,7 @@ The plugin accepts standard language codes (`en`, `nl`, `ja`, etc.). If a langua Do not use these plugin identifiers (they are used by built-in providers): -`anthropic`, `openai`, `gemini`, `deepseek`, `mistral`, `localai`, `deepl`, `libretranslate`, `google`, `bing`, `microsoft`, `yandex`. +`anthropic`, `openai`, `gemini`, `deepseek`, `mistral`, `xai`, `localai`, `deepl`, `libretranslate`, `google`, `bing`, `microsoft`, `yandex`. ## Security