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..31cfe716 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( @@ -207,8 +208,7 @@ 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', - mistral_request_template: 'Mistral Chat' + deepseek_request_template: 'DeepSeek Chat' } onMounted(async () => { diff --git a/Lingarr.Client/src/ts/setting.ts b/Lingarr.Client/src/ts/setting.ts index d94f0cba..c3f1ce96 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', @@ -63,7 +64,6 @@ 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' @@ -90,7 +90,6 @@ 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 @@ -131,7 +130,6 @@ 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 @@ -146,7 +144,6 @@ 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', @@ -160,7 +157,6 @@ 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 @@ -174,7 +170,6 @@ 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 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..80ab751d 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: @@ -112,15 +112,6 @@ 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/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..ccaaea51 100644 --- a/Lingarr.Server/Services/Translation/MistralService.cs +++ b/Lingarr.Server/Services/Translation/MistralService.cs @@ -448,10 +448,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..b7863f8e 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, 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** | |-------------|--------------------------|-------------| @@ -191,7 +200,6 @@ 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 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