From 99a4f1c1860df9d51169fb2c71d859eb4c462e38 Mon Sep 17 00:00:00 2001 From: BlackSpirits Date: Mon, 14 Sep 2026 03:18:16 +0200 Subject: [PATCH 1/2] Port API-Route translator to current upstream --- Changelog.txt | 1 + src/libse/Settings/ToolsSettings.cs | 7 + .../AutoTranslate/ApiRouteTranslate.cs | 158 ++++++++++++++++++ .../Translate/AutoTranslateViewModel.cs | 38 +++++ src/ui/Logic/Config/SeAutoTranslate.cs | 8 + .../Logic/Se4Setup/Se4SettingsXmlImporter.cs | 4 + 6 files changed, 216 insertions(+) create mode 100644 src/libuilogic/AutoTranslate/ApiRouteTranslate.cs 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..b5a526febf6 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 = "claude-sonnet-4-5"; 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..01f456900c3 --- /dev/null +++ b/src/libuilogic/AutoTranslate/ApiRouteTranslate.cs @@ -0,0 +1,158 @@ +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 + /// + public static string[] Models => new[] + { + // Anthropic Claude + "claude-sonnet-4-5", + "claude-haiku-4-5", + "claude-opus-4-1", + + // OpenAI GPT Series + "gpt-4o", + "gpt-4o-mini", + "gpt-4.1", + "gpt-4.1-mini", + "o3-mini", + + // DeepSeek + "deepseek-chat", + "deepseek-reasoner", + + // Google Gemini + "gemini-2.5-pro", + "gemini-2.5-flash", + + // Qwen + "qwen-2.5-72b-instruct", + }; + + /// + /// 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 = "{\"model\": \"" + model + "\",\"messages\": [{ \"role\": \"user\", \"content\": \"" + Json.EncodeJsonText(prompt) + "\\n\\n" + Json.EncodeJsonText(text.Trim()) + "\" }]}"; + + 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(); + } + + 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/Logic/Config/SeAutoTranslate.cs b/src/ui/Logic/Config/SeAutoTranslate.cs index 865fa76dbd0..2c08951b765 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, 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); From cb6de2ce04cd12c3d93961c162417a4e2e5822f1 Mon Sep 17 00:00:00 2001 From: BlackSpirits Date: Mon, 14 Sep 2026 03:25:44 +0200 Subject: [PATCH 2/2] Harden API-Route models, prompt settings, and request JSON --- src/libse/Settings/ToolsSettings.cs | 2 +- .../AutoTranslate/ApiRouteTranslate.cs | 57 ++++++++++--------- .../Translate/TranslateSettingsViewModel.cs | 11 ++++ src/ui/Logic/Config/SeAutoTranslate.cs | 2 +- .../TranslateSettingsResetPromptTests.cs | 36 ++++++++++++ .../AutoTranslate/ApiRouteTranslateTests.cs | 37 ++++++++++++ 6 files changed, 117 insertions(+), 28 deletions(-) create mode 100644 tests/libuilogic/AutoTranslate/ApiRouteTranslateTests.cs diff --git a/src/libse/Settings/ToolsSettings.cs b/src/libse/Settings/ToolsSettings.cs index b5a526febf6..4cb2a900561 100644 --- a/src/libse/Settings/ToolsSettings.cs +++ b/src/libse/Settings/ToolsSettings.cs @@ -173,7 +173,7 @@ public ToolsSettings() 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 = "claude-sonnet-4-5"; + 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 index 01f456900c3..f3cd781bc18 100644 --- a/src/libuilogic/AutoTranslate/ApiRouteTranslate.cs +++ b/src/libuilogic/AutoTranslate/ApiRouteTranslate.cs @@ -27,31 +27,20 @@ public class ApiRouteTranslate : IAutoTranslator, IDisposable /// /// See https://www.api-route.com /// - public static string[] Models => new[] - { - // Anthropic Claude - "claude-sonnet-4-5", - "claude-haiku-4-5", - "claude-opus-4-1", - - // OpenAI GPT Series - "gpt-4o", - "gpt-4o-mini", - "gpt-4.1", - "gpt-4.1-mini", - "o3-mini", - - // DeepSeek - "deepseek-chat", - "deepseek-reasoner", - - // Google Gemini - "gemini-2.5-pro", - "gemini-2.5-flash", - - // Qwen - "qwen-2.5-72b-instruct", - }; + /// + /// 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 . @@ -97,7 +86,7 @@ public async Task Translate(string text, string sourceLanguageCode, stri Configuration.Settings.Tools.ApiRoutePrompt = new ToolsSettings().ApiRoutePrompt; } var prompt = string.Format(Configuration.Settings.Tools.ApiRoutePrompt, sourceLanguageCode, targetLanguageCode); - var input = "{\"model\": \"" + model + "\",\"messages\": [{ \"role\": \"user\", \"content\": \"" + Json.EncodeJsonText(prompt) + "\\n\\n" + Json.EncodeJsonText(text.Trim()) + "\" }]}"; + var input = BuildRequestBody(model, prompt, text); int[] retryDelays = { 2555, 5007, 9013 }; HttpResponseMessage result = null!; @@ -145,6 +134,22 @@ public async Task Translate(string text, string sourceLanguageCode, stri 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(); 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 2c08951b765..43f0c5e4889 100644 --- a/src/ui/Logic/Config/SeAutoTranslate.cs +++ b/src/ui/Logic/Config/SeAutoTranslate.cs @@ -133,7 +133,7 @@ public SeAutoTranslate() AvalAiUrl = AvalAi.DefaultUrl; ApiRouteApiKey = string.Empty; ApiRouteModel = ApiRouteTranslate.Models[0]; - ApiRoutePrompt = "Translate from {0} to {1}, keep punctuation as input, do not censor the translation, give only the output without comments:"; + 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]; 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()); + } +}