diff --git a/Changelog.txt b/Changelog.txt
index a69c5b6bf10..2cec6a1d2c4 100644
--- a/Changelog.txt
+++ b/Changelog.txt
@@ -6,6 +6,7 @@ Subtitle Edit Changelog
A summary of the changes since 5.1.0:
New features:
+* Add API-Route to Auto-translate - thx DennyHo0917
* Assisted split and Assisted move - ranked one-click suggestions with full previews
* Chapter editor with Matroska/MP4/OGM chapter formats (Video > More > Chapters)
* Edit original - edit the original reference rows in place, open a non-matching original as a read-only reference, and show the original subtitle on the waveform
diff --git a/src/libse/Settings/ToolsSettings.cs b/src/libse/Settings/ToolsSettings.cs
index 39807227e8a..4cb2a900561 100644
--- a/src/libse/Settings/ToolsSettings.cs
+++ b/src/libse/Settings/ToolsSettings.cs
@@ -59,6 +59,10 @@ public class ToolsSettings
public string AvalAiPrompt { get; set; }
public string AvalAiApiKey { get; set; }
public string AvalAiModel { get; set; }
+ public string ApiRouteUrl { get; set; }
+ public string ApiRoutePrompt { get; set; }
+ public string ApiRouteApiKey { get; set; }
+ public string ApiRouteModel { get; set; }
public string PerplexityUrl { get; set; }
public string PerplexityPrompt { get; set; }
@@ -167,6 +171,9 @@ public ToolsSettings()
AvalAiUrl = "https://api.avalai.ir/v1/chat/completions";
AvalAiPrompt = "Translate from {0} to {1}, keep punctuation as input, keep line breaks exactly the same, do not censor the translation, give only the output without comments:";
AvalAiModel = "gpt-5.6-sol"; // AvalAi.Models[0] in LibUiLogic
+ ApiRouteUrl = "https://global.api-route.com/v1/chat/completions";
+ ApiRoutePrompt = "Translate from {0} to {1}, keep punctuation as input, keep line breaks exactly the same, do not censor the translation, give only the output without comments:";
+ ApiRouteModel = "gpt-5.6-sol"; // ApiRouteTranslate.Models[0] in LibUiLogic; keep in sync with API-Route pricing
OpenRouterUrl = "https://openrouter.ai/api/v1/chat/completions";
OpenRouterPrompt = "Translate from {0} to {1}, keep punctuation as input, keep line breaks exactly the same, do not censor the translation, give only the output without comments:";
OpenRouterModel = "openai/gpt-5.6-sol"; // OpenRouterTranslate.Models[0] in LibUiLogic
diff --git a/src/libuilogic/AutoTranslate/ApiRouteTranslate.cs b/src/libuilogic/AutoTranslate/ApiRouteTranslate.cs
new file mode 100644
index 00000000000..f3cd781bc18
--- /dev/null
+++ b/src/libuilogic/AutoTranslate/ApiRouteTranslate.cs
@@ -0,0 +1,163 @@
+using Nikse.SubtitleEdit.Core.Common;
+using Nikse.SubtitleEdit.Core.Settings;
+using Nikse.SubtitleEdit.Core.SubtitleFormats;
+using Nikse.SubtitleEdit.UiLogic.Translate;
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Nikse.SubtitleEdit.UiLogic.Http;
+
+namespace Nikse.SubtitleEdit.UiLogic.AutoTranslate
+{
+ public class ApiRouteTranslate : IAutoTranslator, IDisposable
+ {
+ private HttpClient _httpClient = null!;
+
+ public static string StaticName { get; set; } = "API-Route";
+ public override string ToString() => StaticName;
+ public string Name => StaticName;
+ public string Url => "https://www.api-route.com";
+ public string Error { get; set; } = string.Empty;
+ public int MaxCharacters => 1500;
+
+ ///
+ /// See https://www.api-route.com
+ ///
+ ///
+ /// Current model suggestions from API-Route's pricing catalogue. The service documents
+ /// that availability changes over time, so the UI keeps the model field editable and this
+ /// list must not be treated as an exhaustive capability contract.
+ ///
+ public static string[] Models =>
+ [
+ "gpt-5.6-sol",
+ "claude-sonnet-4-6",
+ "gemini-3.1-pro",
+ "deepseek-v4-pro",
+ "grok-4.5",
+ "qwen/qwen3.5-plus-20260420",
+ ];
+
+ ///
+ /// Endpoint used when the url in settings is only the service base - see .
+ ///
+ public const string DefaultUrl = "https://global.api-route.com/v1/chat/completions";
+
+ public void Initialize()
+ {
+ _httpClient?.Dispose();
+ _httpClient = HttpClientFactoryWithProxy.CreateHttpClientWithProxy();
+ _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Content-Type", "application/json");
+ _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("accept", "application/json");
+ _httpClient.BaseAddress = new Uri(AutoTranslateUrl.Complete(Configuration.Settings.Tools.ApiRouteUrl, DefaultUrl));
+ _httpClient.Timeout = TimeSpan.FromMinutes(15);
+
+ if (!string.IsNullOrEmpty(Configuration.Settings.Tools.ApiRouteApiKey))
+ {
+ _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", "Bearer " + Configuration.Settings.Tools.ApiRouteApiKey);
+ }
+ }
+
+ public List GetSupportedSourceLanguages()
+ {
+ return ListLanguages();
+ }
+
+ public List GetSupportedTargetLanguages()
+ {
+ return ListLanguages();
+ }
+
+ public async Task Translate(string text, string sourceLanguageCode, string targetLanguageCode, CancellationToken cancellationToken)
+ {
+ var model = Configuration.Settings.Tools.ApiRouteModel;
+ if (string.IsNullOrEmpty(model))
+ {
+ model = Models[0];
+ Configuration.Settings.Tools.ApiRouteModel = model;
+ }
+
+ if (string.IsNullOrEmpty(Configuration.Settings.Tools.ApiRoutePrompt))
+ {
+ Configuration.Settings.Tools.ApiRoutePrompt = new ToolsSettings().ApiRoutePrompt;
+ }
+ var prompt = string.Format(Configuration.Settings.Tools.ApiRoutePrompt, sourceLanguageCode, targetLanguageCode);
+ var input = BuildRequestBody(model, prompt, text);
+
+ int[] retryDelays = { 2555, 5007, 9013 };
+ HttpResponseMessage result = null!;
+ var json = string.Empty;
+ for (var attempt = 0; attempt <= retryDelays.Length; attempt++)
+ {
+ var content = new StringContent(input, Encoding.UTF8);
+ content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json");
+ result = await _httpClient.PostAsync(string.Empty, content, cancellationToken);
+ var bytes = await result.Content.ReadAsByteArrayAsync(cancellationToken);
+ json = Encoding.UTF8.GetString(bytes).Trim();
+
+ if (!DeepLTranslate.ShouldRetry(result, json) || attempt == retryDelays.Length)
+ {
+ break;
+ }
+
+ await Task.Delay(retryDelays[attempt], cancellationToken);
+ }
+
+ if (!result.IsSuccessStatusCode)
+ {
+ Error = json;
+ SeLogger.Error("API-Route Translate failed calling API: Status code=" + result.StatusCode + Environment.NewLine + json);
+ }
+
+ result.EnsureSuccessStatusCode();
+
+ var parser = new SeJsonParser();
+ var resultText = parser.GetFirstObject(json, "content");
+ if (resultText == null)
+ {
+ return string.Empty;
+ }
+
+ var outputText = Json.DecodeJsonText(resultText).Trim();
+ if (outputText.StartsWith('"') && outputText.EndsWith('"') && !text.StartsWith('"'))
+ {
+ outputText = outputText.Trim('"').Trim();
+ }
+
+ outputText = ChatGptTranslate.FixNewLines(outputText);
+ outputText = ChatGptTranslate.RemovePreamble(text, outputText);
+ outputText = ChatGptTranslate.DecodeUnicodeEscapes(outputText);
+ return outputText.Trim();
+ }
+
+ internal static string BuildRequestBody(string model, string prompt, string text)
+ {
+ return System.Text.Json.JsonSerializer.Serialize(new
+ {
+ model,
+ messages = new[]
+ {
+ new
+ {
+ role = "user",
+ content = prompt + "\n\n" + text.Trim(),
+ },
+ },
+ });
+ }
+
+ public static List ListLanguages()
+ {
+ return ChatGptTranslate.ListLanguages();
+ }
+
+ public void Dispose()
+ {
+ _httpClient?.Dispose();
+ }
+ }
+}
diff --git a/src/ui/Features/Translate/AutoTranslateViewModel.cs b/src/ui/Features/Translate/AutoTranslateViewModel.cs
index d8a28a8ff6c..a02f9c81583 100644
--- a/src/ui/Features/Translate/AutoTranslateViewModel.cs
+++ b/src/ui/Features/Translate/AutoTranslateViewModel.cs
@@ -163,6 +163,7 @@ public AutoTranslateViewModel(IWindowService windowService, IFolderHelper folder
new AnthropicTranslate(),
new GroqTranslate(),
new OpenRouterTranslate(),
+ new ApiRouteTranslate(),
new LaraTranslate(),
new PerplexityTranslate(),
new GeminiTranslate(),
@@ -214,6 +215,11 @@ private void LoadSettings()
// the url box and the translator fell back to the built-in endpoint on the next start.
Configuration.Settings.Tools.OpenRouterUrl = Se.Settings.AutoTranslate.OpenRouterUrl;
+ Configuration.Settings.Tools.ApiRouteApiKey = Se.Settings.AutoTranslate.ApiRouteApiKey;
+ Configuration.Settings.Tools.ApiRouteUrl = Se.Settings.AutoTranslate.ApiRouteUrl;
+ Configuration.Settings.Tools.ApiRouteModel = Se.Settings.AutoTranslate.ApiRouteModel;
+ Configuration.Settings.Tools.ApiRoutePrompt = Se.Settings.AutoTranslate.ApiRoutePrompt;
+
Configuration.Settings.Tools.ChatGptApiKey = Se.Settings.AutoTranslate.ChatGptApiKey;
Configuration.Settings.Tools.ChatGptUrl = Se.Settings.AutoTranslate.ChatGptUrl;
Configuration.Settings.Tools.ChatGptModel = Se.Settings.AutoTranslate.ChatGptModel;
@@ -468,6 +474,14 @@ public void SaveSettings()
Se.Settings.AutoTranslate.OpenRouterUrl = apiUrl.Trim();
}
+ if (engineType == typeof(ApiRouteTranslate))
+ {
+ Configuration.Settings.Tools.ApiRouteApiKey = apiKey.Trim();
+ Configuration.Settings.Tools.ApiRouteModel = apiModel.Trim();
+ Configuration.Settings.Tools.ApiRouteUrl = apiUrl.Trim();
+ Se.Settings.AutoTranslate.ApiRouteUrl = apiUrl.Trim();
+ }
+
if (engineType == typeof(GeminiTranslate))
{
Configuration.Settings.Tools.GeminiProApiKey = apiKey.Trim();
@@ -520,6 +534,11 @@ public void SaveSettings()
Se.Settings.AutoTranslate.OpenRouterModel = Configuration.Settings.Tools.OpenRouterModel;
Se.Settings.AutoTranslate.OpenRouterPrompt = Configuration.Settings.Tools.OpenRouterPrompt;
+ Se.Settings.AutoTranslate.ApiRouteApiKey = Configuration.Settings.Tools.ApiRouteApiKey;
+ Se.Settings.AutoTranslate.ApiRouteUrl = Configuration.Settings.Tools.ApiRouteUrl;
+ Se.Settings.AutoTranslate.ApiRouteModel = Configuration.Settings.Tools.ApiRouteModel;
+ Se.Settings.AutoTranslate.ApiRoutePrompt = Configuration.Settings.Tools.ApiRoutePrompt;
+
Se.Settings.AutoTranslate.ChatGptApiKey = Configuration.Settings.Tools.ChatGptApiKey;
Se.Settings.AutoTranslate.ChatGptUrl = Configuration.Settings.Tools.ChatGptUrl;
Se.Settings.AutoTranslate.ChatGptModel = Configuration.Settings.Tools.ChatGptModel;
@@ -1751,6 +1770,7 @@ private static string GetConfiguredApiUrl(IAutoTranslator translator)
AnthropicTranslate => settings.AnthropicApiUrl,
GroqTranslate => settings.GroqUrl,
OpenRouterTranslate => settings.OpenRouterUrl,
+ ApiRouteTranslate => settings.ApiRouteUrl,
LaraTranslate => settings.LaraUrl,
PerplexityTranslate => settings.PerplexityUrl,
NvidiaTranslate => settings.NvidiaUrl,
@@ -2294,6 +2314,24 @@ private void SetAutoTranslatorEngine(IAutoTranslator translator)
return;
}
+ if (engineType == typeof(ApiRouteTranslate))
+ {
+ FillUrls(new List
+ {
+ Configuration.Settings.Tools.ApiRouteUrl,
+ });
+
+ ApiKeyText = Configuration.Settings.Tools.ApiRouteApiKey;
+ ApiKeyIsVisible = true;
+
+ _apiModels = ApiRouteTranslate.Models.ToList();
+ ModelIsVisible = true;
+ ButtonModelIsVisible = true;
+ ModelText = string.IsNullOrEmpty(Configuration.Settings.Tools.ApiRouteModel) ? _apiModels[0] : Configuration.Settings.Tools.ApiRouteModel;
+
+ return;
+ }
+
if (engineType == typeof(GeminiTranslate))
{
ApiKeyText = Configuration.Settings.Tools.GeminiProApiKey;
diff --git a/src/ui/Features/Translate/TranslateSettingsViewModel.cs b/src/ui/Features/Translate/TranslateSettingsViewModel.cs
index 6690785987a..0b481916807 100644
--- a/src/ui/Features/Translate/TranslateSettingsViewModel.cs
+++ b/src/ui/Features/Translate/TranslateSettingsViewModel.cs
@@ -74,6 +74,7 @@ private async Task Ok()
engineType == typeof(PerplexityTranslate) ||
engineType == typeof(GroqTranslate) ||
engineType == typeof(OpenRouterTranslate) ||
+ engineType == typeof(ApiRouteTranslate) ||
engineType == typeof(NvidiaTranslate) ||
engineType == typeof(MistralTranslate) ||
engineType == typeof(GeminiTranslate) ||
@@ -167,6 +168,11 @@ public void SaveValues()
Se.Settings.AutoTranslate.OpenRouterPrompt = PromptText;
Configuration.Settings.Tools.OpenRouterPrompt = PromptText;
}
+ else if (engineType == typeof(ApiRouteTranslate))
+ {
+ Se.Settings.AutoTranslate.ApiRoutePrompt = PromptText;
+ Configuration.Settings.Tools.ApiRoutePrompt = PromptText;
+ }
else if (engineType == typeof(NvidiaTranslate))
{
Se.Settings.AutoTranslate.NvidiaPrompt = PromptText;
@@ -280,6 +286,11 @@ public void LoadValues(IAutoTranslator translator)
return settings.OpenRouterPrompt;
}
+ if (engineType == typeof(ApiRouteTranslate))
+ {
+ return settings.ApiRoutePrompt;
+ }
+
if (engineType == typeof(NvidiaTranslate))
{
return settings.NvidiaPrompt;
diff --git a/src/ui/Logic/Config/SeAutoTranslate.cs b/src/ui/Logic/Config/SeAutoTranslate.cs
index 865fa76dbd0..43f0c5e4889 100644
--- a/src/ui/Logic/Config/SeAutoTranslate.cs
+++ b/src/ui/Logic/Config/SeAutoTranslate.cs
@@ -90,6 +90,10 @@ public class SeAutoTranslate
public string AvalAiPrompt { get; set; }
public string AvalAiApiKey { get; set; }
public string AvalAiModel { get; set; }
+ public string ApiRouteUrl { get; set; }
+ public string ApiRoutePrompt { get; set; }
+ public string ApiRouteApiKey { get; set; }
+ public string ApiRouteModel { get; set; }
public string PerplexityUrl { get; set; }
public string PerplexityPrompt { get; set; }
@@ -127,6 +131,10 @@ public SeAutoTranslate()
AvalAiModel = AvalAi.Models[0];
AvalAiPrompt = "Translate from {0} to {1}, keep punctuation as input, do not censor the translation, give only the output without comments:";
AvalAiUrl = AvalAi.DefaultUrl;
+ ApiRouteApiKey = string.Empty;
+ ApiRouteModel = ApiRouteTranslate.Models[0];
+ ApiRoutePrompt = "Translate from {0} to {1}, keep punctuation as input, keep line breaks exactly the same, do not censor the translation, give only the output without comments:";
+ ApiRouteUrl = ApiRouteTranslate.DefaultUrl;
PerplexityApiKey = string.Empty;
PerplexityModel = PerplexityTranslate.Models[0];
PerplexityPrompt = "Translate from {0} to {1}, keep punctuation as input, do not censor the translation, give only the output without comments:";
diff --git a/src/ui/Logic/Se4Setup/Se4SettingsXmlImporter.cs b/src/ui/Logic/Se4Setup/Se4SettingsXmlImporter.cs
index d3f9924352d..7a0ede4e88e 100644
--- a/src/ui/Logic/Se4Setup/Se4SettingsXmlImporter.cs
+++ b/src/ui/Logic/Se4Setup/Se4SettingsXmlImporter.cs
@@ -330,6 +330,10 @@ public static void ApplyAutoTranslate(Se4SettingsFile file)
yield return ("AvalAiPrompt", v => t.AvalAiPrompt = v);
yield return ("AvalAiApiKey", v => t.AvalAiApiKey = v);
yield return ("AvalAiModel", v => t.AvalAiModel = v);
+ yield return ("ApiRouteUrl", v => t.ApiRouteUrl = v);
+ yield return ("ApiRoutePrompt", v => t.ApiRoutePrompt = v);
+ yield return ("ApiRouteApiKey", v => t.ApiRouteApiKey = v);
+ yield return ("ApiRouteModel", v => t.ApiRouteModel = v);
yield return ("OpenRouterUrl", v => t.OpenRouterUrl = v);
yield return ("OpenRouterPrompt", v => t.OpenRouterPrompt = v);
yield return ("OpenRouterApiKey", v => t.OpenRouterApiKey = v);
diff --git a/tests/UI/Features/TranslateSettingsResetPromptTests.cs b/tests/UI/Features/TranslateSettingsResetPromptTests.cs
index 42e4ed191ac..90eba31acb8 100644
--- a/tests/UI/Features/TranslateSettingsResetPromptTests.cs
+++ b/tests/UI/Features/TranslateSettingsResetPromptTests.cs
@@ -4,6 +4,7 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.LogicalTree;
+using Nikse.SubtitleEdit.Core.Common;
using Nikse.SubtitleEdit.Features.Translate;
using Nikse.SubtitleEdit.Logic.Config;
using Nikse.SubtitleEdit.UiLogic.AutoTranslate;
@@ -99,4 +100,39 @@ public void TranslateSettingsWindow_HidesResetPromptButtonForEngineWithoutPrompt
Assert.NotNull(button);
Assert.False(button!.IsEffectivelyVisible);
}
+ [AvaloniaFact]
+ public void ApiRoutePrompt_IsVisibleResettableAndSaved()
+ {
+ var savedPrompt = Se.Settings.AutoTranslate.ApiRoutePrompt;
+ var savedCorePrompt = Configuration.Settings.Tools.ApiRoutePrompt;
+ try
+ {
+ Se.Settings.AutoTranslate.ApiRoutePrompt = "My API-Route prompt from {0} to {1}";
+ Configuration.Settings.Tools.ApiRoutePrompt = Se.Settings.AutoTranslate.ApiRoutePrompt;
+
+ var vm = new TranslateSettingsViewModel();
+ vm.LoadValues(new ApiRouteTranslate());
+
+ Assert.True(vm.PromptIsVisible);
+ Assert.Equal("My API-Route prompt from {0} to {1}", vm.PromptText);
+
+ vm.ResetPromptCommand.Execute(null);
+
+ var builtIn = new SeAutoTranslate().ApiRoutePrompt;
+ Assert.Contains("keep line breaks exactly the same", builtIn);
+ Assert.Equal(builtIn, vm.PromptText);
+
+ vm.PromptText = "Saved API-Route prompt from {0} to {1}";
+ vm.SaveValues();
+
+ Assert.Equal(vm.PromptText, Se.Settings.AutoTranslate.ApiRoutePrompt);
+ Assert.Equal(vm.PromptText, Configuration.Settings.Tools.ApiRoutePrompt);
+ }
+ finally
+ {
+ Se.Settings.AutoTranslate.ApiRoutePrompt = savedPrompt;
+ Configuration.Settings.Tools.ApiRoutePrompt = savedCorePrompt;
+ }
+ }
+
}
diff --git a/tests/libuilogic/AutoTranslate/ApiRouteTranslateTests.cs b/tests/libuilogic/AutoTranslate/ApiRouteTranslateTests.cs
new file mode 100644
index 00000000000..869af515ae4
--- /dev/null
+++ b/tests/libuilogic/AutoTranslate/ApiRouteTranslateTests.cs
@@ -0,0 +1,37 @@
+using System.Text.Json;
+using Nikse.SubtitleEdit.Core.Settings;
+using Nikse.SubtitleEdit.UiLogic.AutoTranslate;
+
+namespace LibUiLogicTests.AutoTranslate;
+
+public class ApiRouteTranslateTests
+{
+ [Fact]
+ public void Models_FirstSuggestionMatchesPersistentDefault()
+ {
+ var settings = new ToolsSettings();
+
+ Assert.NotEmpty(ApiRouteTranslate.Models);
+ Assert.Equal(ApiRouteTranslate.Models[0], settings.ApiRouteModel);
+ Assert.Equal("gpt-5.6-sol", settings.ApiRouteModel);
+ }
+
+ [Fact]
+ public void BuildRequestBody_SerializesModelAndMessageWithoutJsonInjection()
+ {
+ const string model = "custom\\\"model\\name";
+ const string prompt = "Translate from English to Portuguese.";
+ const string text = "First line\nSecond \"quoted\" line";
+
+ var body = ApiRouteTranslate.BuildRequestBody(model, prompt, text);
+ using var document = JsonDocument.Parse(body);
+ var root = document.RootElement;
+
+ Assert.Equal(model, root.GetProperty("model").GetString());
+
+ var message = Assert.Single(root.GetProperty("messages").EnumerateArray());
+ Assert.Equal("user", message.GetProperty("role").GetString());
+ Assert.Equal(prompt + "\n\n" + text, message.GetProperty("content").GetString());
+ Assert.Equal(2, root.EnumerateObject().Count());
+ }
+}