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