From 3c2887fa3545952c2f1d05f6a9bff02d2a9cb16f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:27:39 +0000 Subject: [PATCH 1/5] Initial plan From e6835f9059a035dabfe547136376209d054c03c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:40:14 +0000 Subject: [PATCH 2/5] =?UTF-8?q?Sarashina3=E7=BF=BB=E8=A8=B3=E3=83=97?= =?UTF-8?q?=E3=83=A9=E3=82=B0=E3=82=A4=E3=83=B3=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Freeesia <9002657+Freeesia@users.noreply.github.com> --- .../README.md | 21 ++++ .../Sarashina3Options.cs | 34 +++++++ .../Sarashina3Translator.cs | 86 ++++++++++++++++ ...wTranslator.Plugin.Sarashina3Plugin.csproj | 13 +++ WindowTranslator.Tests/Sarashina3Tests.cs | 97 +++++++++++++++++++ .../WindowTranslator.Tests.csproj | 1 + WindowTranslator.slnx | 1 + 7 files changed, 253 insertions(+) create mode 100644 Plugins/WindowTranslator.Plugin.Sarashina3Plugin/README.md create mode 100644 Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Options.cs create mode 100644 Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Translator.cs create mode 100644 Plugins/WindowTranslator.Plugin.Sarashina3Plugin/WindowTranslator.Plugin.Sarashina3Plugin.csproj create mode 100644 WindowTranslator.Tests/Sarashina3Tests.cs diff --git a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/README.md b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/README.md new file mode 100644 index 00000000..0639062f --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/README.md @@ -0,0 +1,21 @@ +# WindowTranslator Sarashina3 Translator Plugin + +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)で、Sarashina3の翻訳APIを利用する翻訳プラグインです。 + +- Sarashina3 translate APIを利用した翻訳 +- 翻訳APIキー、翻訳コンテキスト、用語集に対応 +- 翻訳にはインターネット接続が必要です。翻訳対象のテキストはSarashina APIに送信されます。 + +Sarashina APIの利用登録とAPIキーの取得については、[Sarashina](https://sarashina.ai/)を確認してください。 + +## en + +A [WindowTranslator](https://github.com/Freeesia/WindowTranslator) translation plugin that uses the Sarashina3 translation API. + +- Translates with the Sarashina3 translate API +- Supports an API key, translation context, and glossary +- An internet connection is required. Text to translate is sent to the Sarashina API. + +See [Sarashina](https://sarashina.ai/) to register and obtain an API key. diff --git a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Options.cs b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Options.cs new file mode 100644 index 00000000..5fff1c49 --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Options.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using PropertyTools.DataAnnotations; +using WindowTranslator.Modules; +using DisplayNameAttribute = System.ComponentModel.DisplayNameAttribute; + +namespace WindowTranslator.Plugin.Sarashina3Plugin; + +[DisplayName("Sarashina3")] +public sealed class Sarashina3Options : IPluginParam +{ + [DataType(DataType.Password)] + public string? ApiKey { get; set; } + + [Height(120)] + [DataType(DataType.MultilineText)] + public string? TranslateContext { get; set; } +} + +public sealed class Sarashina3Validator : ITargetSettingsValidator +{ + public ValueTask Validate(TargetSettings settings) + { + if (settings.SelectedPlugins.GetValueOrDefault(nameof(ITranslateModule)) != nameof(Sarashina3Translator)) + { + return ValueTask.FromResult(ValidateResult.Valid); + } + + var options = settings.PluginParams.GetValueOrDefault(nameof(Sarashina3Options)) as Sarashina3Options; + return ValueTask.FromResult(string.IsNullOrWhiteSpace(options?.ApiKey) + ? ValidateResult.Invalid("Sarashina3", "Sarashina3 APIキーを設定してください。") + : ValidateResult.Valid); + } +} diff --git a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Translator.cs b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Translator.cs new file mode 100644 index 00000000..39669d26 --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Translator.cs @@ -0,0 +1,86 @@ +using System.ClientModel; +using System.Globalization; +using OpenAI; +using OpenAI.Chat; +using Microsoft.Extensions.Options; +using WindowTranslator.Modules; + +namespace WindowTranslator.Plugin.Sarashina3Plugin; + +public sealed class Sarashina3Translator : ITranslateModule +{ + internal const string Endpoint = "https://api.sarashina.ai/v1"; + internal const string Model = "sarashina3-translate"; + + private readonly ChatClient client; + private readonly string sourceLanguage; + private readonly string targetLanguage; + private readonly string? translateContext; + private readonly Dictionary glossary = new(StringComparer.Ordinal); + private string? context; + + public Sarashina3Translator(IOptionsSnapshot options, IOptionsSnapshot languages) + : this(options.Value, languages.Value, CreateClient(options.Value)) + { + } + + internal Sarashina3Translator(Sarashina3Options options, LanguageOptions languages) + : this(options, languages, CreateClient(options)) + { + } + + internal Sarashina3Translator(Sarashina3Options options, LanguageOptions languages, ChatClient client) + { + this.client = client; + this.sourceLanguage = CultureInfo.GetCultureInfo(languages.Source).EnglishName; + this.targetLanguage = CultureInfo.GetCultureInfo(languages.Target).EnglishName; + this.translateContext = options.TranslateContext; + } + + private static ChatClient CreateClient(Sarashina3Options options) + => string.IsNullOrWhiteSpace(options.ApiKey) + ? throw new AppUserException("Sarashina3 APIキーを設定してください。") + : new ChatClient(Model, new ApiKeyCredential(options.ApiKey), new OpenAIClientOptions + { + Endpoint = new Uri(Endpoint), + }); + + public async ValueTask TranslateAsync(TextInfo[] srcTexts) + { + var translated = new string[srcTexts.Length]; + for (var i = 0; i < srcTexts.Length; i++) + { + var systemPrompt = $""" + Translate from {this.sourceLanguage} to {this.targetLanguage}. + Return only the translation without explanations or quotation marks. + Preserve formatting, placeholders, and tags. + {this.translateContext} + {this.context} + {srcTexts[i].Context} + + {string.Join(Environment.NewLine, this.glossary.Select(entry => $"{entry.Key} => {entry.Value}"))} + + """; + var completion = await this.client.CompleteChatAsync([ + ChatMessage.CreateSystemMessage(systemPrompt), + ChatMessage.CreateUserMessage(srcTexts[i].SourceText), + ]).ConfigureAwait(false); + translated[i] = string.Concat(completion.Value.Content.Select(content => content.Text)).Trim(); + } + + return translated; + } + + public ValueTask RegisterGlossaryAsync(IReadOnlyDictionary glossary) + { + foreach (var (source, target) in glossary) + { + this.glossary.TryAdd(source.ReplaceLineEndings(string.Empty), target.ReplaceLineEndings(string.Empty)); + } + + return default; + } + + public void RegisterContext(string context) + => this.context = context; +} diff --git a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/WindowTranslator.Plugin.Sarashina3Plugin.csproj b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/WindowTranslator.Plugin.Sarashina3Plugin.csproj new file mode 100644 index 00000000..7623ccbc --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/WindowTranslator.Plugin.Sarashina3Plugin.csproj @@ -0,0 +1,13 @@ + + + Sarashina3 Translator Plugin + Translation for WindowTranslator using the Sarashina3 translate API. + true + $(PackageTags);translate + false + + + + + + diff --git a/WindowTranslator.Tests/Sarashina3Tests.cs b/WindowTranslator.Tests/Sarashina3Tests.cs new file mode 100644 index 00000000..d8dc8247 --- /dev/null +++ b/WindowTranslator.Tests/Sarashina3Tests.cs @@ -0,0 +1,97 @@ +extern alias Sarashina3; + +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Net; +using System.Text; +using System.Text.Json; +using OpenAI; +using OpenAI.Chat; +using Sarashina3Options = Sarashina3::WindowTranslator.Plugin.Sarashina3Plugin.Sarashina3Options; +using Sarashina3Translator = Sarashina3::WindowTranslator.Plugin.Sarashina3Plugin.Sarashina3Translator; + +namespace WindowTranslator.Tests; + +public class Sarashina3Tests +{ + [Fact] + public async Task TranslationUsesSarashinaEndpointAndPreservesInputOrder() + { + var handler = new RecordingResponseHandler( + """{"id":"test","object":"chat.completion","created":0,"model":"sarashina3-translate","choices":[{"index":0,"message":{"role":"assistant","content":" Hello "},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}""", + """{"id":"test","object":"chat.completion","created":0,"model":"sarashina3-translate","choices":[{"index":0,"message":{"role":"assistant","content":" Goodbye "},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}"""); + using var httpClient = new HttpClient(handler); + var client = new ChatClient(Sarashina3Translator.Model, new ApiKeyCredential("sarashina-test-key"), new OpenAIClientOptions + { + Endpoint = new Uri(Sarashina3Translator.Endpoint), + Transport = new HttpClientPipelineTransport(httpClient), + RetryPolicy = new ClientRetryPolicy(0), + }); + var translator = new Sarashina3Translator(new Sarashina3Options + { + TranslateContext = "A conversation in a game.", + }, new LanguageOptions + { + Source = "ja-JP", + Target = "en-US", + }, client); + await translator.RegisterGlossaryAsync(new Dictionary { ["こんにちは"] = "Hi" }); + translator.RegisterContext("The character is speaking."); + + var translated = await translator.TranslateAsync([ + new TextInfo("こんにちは", "Casual tone."), + new TextInfo("さようなら", null), + ]); + + Assert.Equal(["Hello", "Goodbye"], translated); + Assert.Equal(2, handler.Requests.Count); + Assert.All(handler.Requests, request => + { + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal($"{Sarashina3Translator.Endpoint}/chat/completions", request.RequestUri?.AbsoluteUri); + Assert.Equal("Bearer", request.AuthorizationScheme); + Assert.Equal("sarashina-test-key", request.AuthorizationParameter); + }); + using var firstRequest = JsonDocument.Parse(handler.Requests[0].Body); + Assert.Equal(Sarashina3Translator.Model, firstRequest.RootElement.GetProperty("model").GetString()); + var systemPrompt = firstRequest.RootElement.GetProperty("messages")[0].GetProperty("content").GetString(); + Assert.Contains("Japanese (Japan)", systemPrompt); + Assert.Contains("English (United States)", systemPrompt); + Assert.Contains("A conversation in a game.", systemPrompt); + Assert.Contains("The character is speaking.", systemPrompt); + Assert.Contains("こんにちは => Hi", systemPrompt); + } + + [Fact] + public void MissingApiKeyIsRejected() + { + Assert.Throws(() => new Sarashina3Translator( + new Sarashina3Options(), + new LanguageOptions { Source = "ja-JP", Target = "en-US" })); + } + + private sealed class RecordingResponseHandler(params string[] responses) : HttpMessageHandler + { + private readonly Queue responses = new(responses); + + public List Requests { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = await Assert.IsAssignableFrom(request.Content).ReadAsStringAsync(cancellationToken); + this.Requests.Add(new( + request.Method, + request.RequestUri, + request.Headers.Authorization?.Scheme, + request.Headers.Authorization?.Parameter, + body)); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(this.responses.Dequeue(), Encoding.UTF8, "application/json"), + RequestMessage = request, + }; + } + } + + private sealed record RequestInfo(HttpMethod Method, Uri? RequestUri, string? AuthorizationScheme, string? AuthorizationParameter, string Body); +} diff --git a/WindowTranslator.Tests/WindowTranslator.Tests.csproj b/WindowTranslator.Tests/WindowTranslator.Tests.csproj index df85445e..5b195efa 100644 --- a/WindowTranslator.Tests/WindowTranslator.Tests.csproj +++ b/WindowTranslator.Tests/WindowTranslator.Tests.csproj @@ -21,5 +21,6 @@ + diff --git a/WindowTranslator.slnx b/WindowTranslator.slnx index fb3697bf..5a4ce69b 100644 --- a/WindowTranslator.slnx +++ b/WindowTranslator.slnx @@ -45,6 +45,7 @@ + From 98e2c4376f03fffbe43b2783772d64671fde68c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:41:26 +0000 Subject: [PATCH 3/5] =?UTF-8?q?Sarashina3=E3=83=97=E3=83=A9=E3=82=B0?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=81=AE=E6=95=B4=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Freeesia <9002657+Freeesia@users.noreply.github.com> --- .../Sarashina3Options.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Options.cs b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Options.cs index 5fff1c49..c31ee809 100644 --- a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Options.cs +++ b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Options.cs @@ -1,5 +1,4 @@ using System.ComponentModel.DataAnnotations; -using System.Globalization; using PropertyTools.DataAnnotations; using WindowTranslator.Modules; using DisplayNameAttribute = System.ComponentModel.DisplayNameAttribute; From 19bce88f5a47ba12c79d53a8404aa948311ade65 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:43:03 +0000 Subject: [PATCH 4/5] =?UTF-8?q?Sarashina=E5=B0=82=E7=94=A8Translate=20API?= =?UTF-8?q?=E3=81=AB=E5=88=87=E3=82=8A=E6=9B=BF=E3=81=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Freeesia <9002657+Freeesia@users.noreply.github.com> --- .../README.md | 18 +-- .../Sarashina3Options.cs | 18 +-- .../Sarashina3Translator.cs | 120 ++++++++++-------- ...wTranslator.Plugin.Sarashina3Plugin.csproj | 1 - WindowTranslator.Tests/Sarashina3Tests.cs | 74 +++++++---- 5 files changed, 134 insertions(+), 97 deletions(-) diff --git a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/README.md b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/README.md index 0639062f..ee022e3d 100644 --- a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/README.md +++ b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/README.md @@ -2,20 +2,22 @@ ## ja -[WindowTranslator](https://github.com/Freeesia/WindowTranslator)で、Sarashina3の翻訳APIを利用する翻訳プラグインです。 +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)で、Sarashina3のTranslate APIを利用する翻訳プラグインです。 -- Sarashina3 translate APIを利用した翻訳 -- 翻訳APIキー、翻訳コンテキスト、用語集に対応 +- `POST https://api.sarashina.ai/v1/translate` を使った翻訳 +- 翻訳元・翻訳先に日本語または英語を指定 +- Sarashina APIキーをBearer認証で送信 - 翻訳にはインターネット接続が必要です。翻訳対象のテキストはSarashina APIに送信されます。 -Sarashina APIの利用登録とAPIキーの取得については、[Sarashina](https://sarashina.ai/)を確認してください。 +Sarashina APIの利用登録とAPIキーの取得については、[Sarashina](https://sarashina.ai/)を確認してください。API仕様は[公式Translate APIリファレンス](https://docs.sarashina.ai/api-reference/translate)を参照してください。 ## en -A [WindowTranslator](https://github.com/Freeesia/WindowTranslator) translation plugin that uses the Sarashina3 translation API. +A [WindowTranslator](https://github.com/Freeesia/WindowTranslator) translation plugin that uses the Sarashina3 Translate API. -- Translates with the Sarashina3 translate API -- Supports an API key, translation context, and glossary +- Uses `POST https://api.sarashina.ai/v1/translate` +- Supports Japanese and English as source and target languages +- Sends the Sarashina API key as a bearer token in the Authorization header - An internet connection is required. Text to translate is sent to the Sarashina API. -See [Sarashina](https://sarashina.ai/) to register and obtain an API key. +See [Sarashina](https://sarashina.ai/) to register and obtain an API key. Refer to the [official Translate API reference](https://docs.sarashina.ai/api-reference/translate) for API details. diff --git a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Options.cs b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Options.cs index c31ee809..e676bda6 100644 --- a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Options.cs +++ b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Options.cs @@ -1,5 +1,4 @@ using System.ComponentModel.DataAnnotations; -using PropertyTools.DataAnnotations; using WindowTranslator.Modules; using DisplayNameAttribute = System.ComponentModel.DisplayNameAttribute; @@ -10,10 +9,6 @@ public sealed class Sarashina3Options : IPluginParam { [DataType(DataType.Password)] public string? ApiKey { get; set; } - - [Height(120)] - [DataType(DataType.MultilineText)] - public string? TranslateContext { get; set; } } public sealed class Sarashina3Validator : ITargetSettingsValidator @@ -26,8 +21,15 @@ public ValueTask Validate(TargetSettings settings) } var options = settings.PluginParams.GetValueOrDefault(nameof(Sarashina3Options)) as Sarashina3Options; - return ValueTask.FromResult(string.IsNullOrWhiteSpace(options?.ApiKey) - ? ValidateResult.Invalid("Sarashina3", "Sarashina3 APIキーを設定してください。") - : ValidateResult.Valid); + if (string.IsNullOrWhiteSpace(options?.ApiKey)) + { + return ValueTask.FromResult(ValidateResult.Invalid("Sarashina3", "Sarashina APIキーを設定してください。")); + } + + return ValueTask.FromResult( + !Sarashina3Translator.SupportsLanguage(settings.Language.Source) + || !Sarashina3Translator.SupportsLanguage(settings.Language.Target) + ? ValidateResult.Invalid("Sarashina3", "Sarashina Translate APIは日本語と英語の翻訳に対応しています。") + : ValidateResult.Valid); } } diff --git a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Translator.cs b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Translator.cs index 39669d26..71aea04a 100644 --- a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Translator.cs +++ b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Translator.cs @@ -1,7 +1,7 @@ -using System.ClientModel; using System.Globalization; -using OpenAI; -using OpenAI.Chat; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; using Microsoft.Extensions.Options; using WindowTranslator.Modules; @@ -9,78 +9,94 @@ namespace WindowTranslator.Plugin.Sarashina3Plugin; public sealed class Sarashina3Translator : ITranslateModule { - internal const string Endpoint = "https://api.sarashina.ai/v1"; - internal const string Model = "sarashina3-translate"; + internal const string Endpoint = "https://api.sarashina.ai/v1/translate"; + private static readonly HttpClient httpClient = new(); - private readonly ChatClient client; + private readonly string apiKey; private readonly string sourceLanguage; private readonly string targetLanguage; - private readonly string? translateContext; - private readonly Dictionary glossary = new(StringComparer.Ordinal); - private string? context; + private readonly HttpClient client; public Sarashina3Translator(IOptionsSnapshot options, IOptionsSnapshot languages) - : this(options.Value, languages.Value, CreateClient(options.Value)) + : this(options.Value, languages.Value, httpClient) { } - internal Sarashina3Translator(Sarashina3Options options, LanguageOptions languages) - : this(options, languages, CreateClient(options)) + internal Sarashina3Translator(Sarashina3Options options, LanguageOptions languages, HttpClient client) { - } + if (string.IsNullOrWhiteSpace(options.ApiKey)) + { + throw new AppUserException("Sarashina APIキーを設定してください。"); + } - internal Sarashina3Translator(Sarashina3Options options, LanguageOptions languages, ChatClient client) - { + this.sourceLanguage = GetLanguageCode(languages.Source); + this.targetLanguage = GetLanguageCode(languages.Target); + this.apiKey = options.ApiKey.Trim(); this.client = client; - this.sourceLanguage = CultureInfo.GetCultureInfo(languages.Source).EnglishName; - this.targetLanguage = CultureInfo.GetCultureInfo(languages.Target).EnglishName; - this.translateContext = options.TranslateContext; } - private static ChatClient CreateClient(Sarashina3Options options) - => string.IsNullOrWhiteSpace(options.ApiKey) - ? throw new AppUserException("Sarashina3 APIキーを設定してください。") - : new ChatClient(Model, new ApiKeyCredential(options.ApiKey), new OpenAIClientOptions - { - Endpoint = new Uri(Endpoint), - }); + internal static bool SupportsLanguage(string cultureCode) + => TryGetLanguageCode(cultureCode, out _); - public async ValueTask TranslateAsync(TextInfo[] srcTexts) + private static string GetLanguageCode(string cultureCode) + => TryGetLanguageCode(cultureCode, out var languageCode) + ? languageCode + : throw new AppUserException("Sarashina Translate APIは日本語と英語の翻訳に対応しています。"); + + private static bool TryGetLanguageCode(string cultureCode, out string languageCode) { - var translated = new string[srcTexts.Length]; - for (var i = 0; i < srcTexts.Length; i++) + try { - var systemPrompt = $""" - Translate from {this.sourceLanguage} to {this.targetLanguage}. - Return only the translation without explanations or quotation marks. - Preserve formatting, placeholders, and tags. - {this.translateContext} - {this.context} - {srcTexts[i].Context} - - {string.Join(Environment.NewLine, this.glossary.Select(entry => $"{entry.Key} => {entry.Value}"))} - - """; - var completion = await this.client.CompleteChatAsync([ - ChatMessage.CreateSystemMessage(systemPrompt), - ChatMessage.CreateUserMessage(srcTexts[i].SourceText), - ]).ConfigureAwait(false); - translated[i] = string.Concat(completion.Value.Content.Select(content => content.Text)).Trim(); + languageCode = CultureInfo.GetCultureInfo(cultureCode).TwoLetterISOLanguageName; + return languageCode is "ja" or "en"; + } + catch (CultureNotFoundException) + { + languageCode = string.Empty; + return false; } - - return translated; } - public ValueTask RegisterGlossaryAsync(IReadOnlyDictionary glossary) + public async ValueTask TranslateAsync(TextInfo[] srcTexts) { - foreach (var (source, target) in glossary) + var translated = new string[srcTexts.Length]; + for (var i = 0; i < srcTexts.Length; i++) { - this.glossary.TryAdd(source.ReplaceLineEndings(string.Empty), target.ReplaceLineEndings(string.Empty)); + if (this.sourceLanguage == this.targetLanguage) + { + translated[i] = srcTexts[i].SourceText; + continue; + } + + using var request = new HttpRequestMessage(HttpMethod.Post, Endpoint); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", this.apiKey); + request.Content = JsonContent.Create(new TranslateRequest( + srcTexts[i].SourceText, + this.sourceLanguage, + this.targetLanguage)); + + using var response = await this.client.SendAsync(request).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"Sarashina Translate API returned HTTP {(int)response.StatusCode}.", + null, + response.StatusCode); + } + + await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false); + if (!document.RootElement.TryGetProperty("translated_text", out var translatedText) + || translatedText.ValueKind != JsonValueKind.String) + { + throw new InvalidDataException("Sarashina Translate API response did not contain translated_text."); + } + + translated[i] = translatedText.GetString()!; } - return default; + return translated; } - public void RegisterContext(string context) - => this.context = context; + private sealed record TranslateRequest(string text, string source_lang, string target_lang); } diff --git a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/WindowTranslator.Plugin.Sarashina3Plugin.csproj b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/WindowTranslator.Plugin.Sarashina3Plugin.csproj index 7623ccbc..7ea180bf 100644 --- a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/WindowTranslator.Plugin.Sarashina3Plugin.csproj +++ b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/WindowTranslator.Plugin.Sarashina3Plugin.csproj @@ -7,7 +7,6 @@ false - diff --git a/WindowTranslator.Tests/Sarashina3Tests.cs b/WindowTranslator.Tests/Sarashina3Tests.cs index d8dc8247..69036993 100644 --- a/WindowTranslator.Tests/Sarashina3Tests.cs +++ b/WindowTranslator.Tests/Sarashina3Tests.cs @@ -1,12 +1,8 @@ extern alias Sarashina3; -using System.ClientModel; -using System.ClientModel.Primitives; using System.Net; using System.Text; using System.Text.Json; -using OpenAI; -using OpenAI.Chat; using Sarashina3Options = Sarashina3::WindowTranslator.Plugin.Sarashina3Plugin.Sarashina3Options; using Sarashina3Translator = Sarashina3::WindowTranslator.Plugin.Sarashina3Plugin.Sarashina3Translator; @@ -15,31 +11,23 @@ namespace WindowTranslator.Tests; public class Sarashina3Tests { [Fact] - public async Task TranslationUsesSarashinaEndpointAndPreservesInputOrder() + public async Task TranslationUsesDedicatedTranslateApi() { var handler = new RecordingResponseHandler( - """{"id":"test","object":"chat.completion","created":0,"model":"sarashina3-translate","choices":[{"index":0,"message":{"role":"assistant","content":" Hello "},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}""", - """{"id":"test","object":"chat.completion","created":0,"model":"sarashina3-translate","choices":[{"index":0,"message":{"role":"assistant","content":" Goodbye "},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}"""); + """{"translated_text":"Hello"}""", + """{"translated_text":"Goodbye"}"""); using var httpClient = new HttpClient(handler); - var client = new ChatClient(Sarashina3Translator.Model, new ApiKeyCredential("sarashina-test-key"), new OpenAIClientOptions - { - Endpoint = new Uri(Sarashina3Translator.Endpoint), - Transport = new HttpClientPipelineTransport(httpClient), - RetryPolicy = new ClientRetryPolicy(0), - }); var translator = new Sarashina3Translator(new Sarashina3Options { - TranslateContext = "A conversation in a game.", + ApiKey = "sarashina-test-key", }, new LanguageOptions { Source = "ja-JP", Target = "en-US", - }, client); - await translator.RegisterGlossaryAsync(new Dictionary { ["こんにちは"] = "Hi" }); - translator.RegisterContext("The character is speaking."); + }, httpClient); var translated = await translator.TranslateAsync([ - new TextInfo("こんにちは", "Casual tone."), + new TextInfo("こんにちは", null), new TextInfo("さようなら", null), ]); @@ -48,26 +36,56 @@ public async Task TranslationUsesSarashinaEndpointAndPreservesInputOrder() Assert.All(handler.Requests, request => { Assert.Equal(HttpMethod.Post, request.Method); - Assert.Equal($"{Sarashina3Translator.Endpoint}/chat/completions", request.RequestUri?.AbsoluteUri); + Assert.Equal(Sarashina3Translator.Endpoint, request.RequestUri?.AbsoluteUri); Assert.Equal("Bearer", request.AuthorizationScheme); Assert.Equal("sarashina-test-key", request.AuthorizationParameter); }); + using var firstRequest = JsonDocument.Parse(handler.Requests[0].Body); - Assert.Equal(Sarashina3Translator.Model, firstRequest.RootElement.GetProperty("model").GetString()); - var systemPrompt = firstRequest.RootElement.GetProperty("messages")[0].GetProperty("content").GetString(); - Assert.Contains("Japanese (Japan)", systemPrompt); - Assert.Contains("English (United States)", systemPrompt); - Assert.Contains("A conversation in a game.", systemPrompt); - Assert.Contains("The character is speaking.", systemPrompt); - Assert.Contains("こんにちは => Hi", systemPrompt); + Assert.Equal( + new[] { "text", "source_lang", "target_lang" }, + firstRequest.RootElement.EnumerateObject().Select(property => property.Name)); + Assert.Equal("こんにちは", firstRequest.RootElement.GetProperty("text").GetString()); + Assert.Equal("ja", firstRequest.RootElement.GetProperty("source_lang").GetString()); + Assert.Equal("en", firstRequest.RootElement.GetProperty("target_lang").GetString()); + } + + [Fact] + public async Task SameLanguageReturnsOriginalTextWithoutCallingApi() + { + var handler = new RecordingResponseHandler(); + using var httpClient = new HttpClient(handler); + var translator = new Sarashina3Translator(new Sarashina3Options + { + ApiKey = "sarashina-test-key", + }, new LanguageOptions + { + Source = "ja-JP", + Target = "ja", + }, httpClient); + + Assert.Equal(["そのまま"], await translator.TranslateAsync([new TextInfo("そのまま", null)])); + Assert.Empty(handler.Requests); } [Fact] public void MissingApiKeyIsRejected() { + using var httpClient = new HttpClient(); Assert.Throws(() => new Sarashina3Translator( new Sarashina3Options(), - new LanguageOptions { Source = "ja-JP", Target = "en-US" })); + new LanguageOptions { Source = "ja-JP", Target = "en-US" }, + httpClient)); + } + + [Fact] + public void UnsupportedLanguageIsRejected() + { + using var httpClient = new HttpClient(); + Assert.Throws(() => new Sarashina3Translator( + new Sarashina3Options { ApiKey = "sarashina-test-key" }, + new LanguageOptions { Source = "ja-JP", Target = "zh-CN" }, + httpClient)); } private sealed class RecordingResponseHandler(params string[] responses) : HttpMessageHandler @@ -78,7 +96,7 @@ private sealed class RecordingResponseHandler(params string[] responses) : HttpM protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - var body = await Assert.IsAssignableFrom(request.Content).ReadAsStringAsync(cancellationToken); + var body = request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken); this.Requests.Add(new( request.Method, request.RequestUri, From b2a85029b43c2967ef7c5dd01bb3cc0878a0f558 Mon Sep 17 00:00:00 2001 From: Freesia Date: Sun, 25 Oct 2026 20:48:28 +0900 Subject: [PATCH 5/5] =?UTF-8?q?Sarashina3=E3=81=AE=E5=A4=9A=E8=A8=80?= =?UTF-8?q?=E8=AA=9E=E7=BF=BB=E8=A8=B3=E3=81=A8=E5=86=8D=E8=A9=A6=E8=A1=8C?= =?UTF-8?q?=E3=81=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 日本語・英語以外の言語ペアをChat Completions APIへフォールバック。 長文分割、構造化出力の検証、レート制限時の再試行を追加し、 関連テストと利用上の注意事項を更新。 --- .../README.md | 12 +- .../Sarashina3Options.cs | 6 +- .../Sarashina3Translator.cs | 230 +++++++++++++++--- ...wTranslator.Plugin.Sarashina3Plugin.csproj | 1 + WindowTranslator.Tests/Sarashina3Tests.cs | 75 +++++- 5 files changed, 271 insertions(+), 53 deletions(-) diff --git a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/README.md b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/README.md index ee022e3d..92ae2921 100644 --- a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/README.md +++ b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/README.md @@ -5,9 +5,13 @@ [WindowTranslator](https://github.com/Freeesia/WindowTranslator)で、Sarashina3のTranslate APIを利用する翻訳プラグインです。 - `POST https://api.sarashina.ai/v1/translate` を使った翻訳 -- 翻訳元・翻訳先に日本語または英語を指定 +- 翻訳元・翻訳先が日本語と英語の場合は専用のTranslate APIを使用 +- それ以外の言語の組み合わせでは、Chat Completions API (`sarashina3-mini`) にフォールバック - Sarashina APIキーをBearer認証で送信 - 翻訳にはインターネット接続が必要です。翻訳対象のテキストはSarashina APIに送信されます。 +- レート制限(Translate APIとChat Completions APIの`sarashina3-mini`は1分あたり6リクエスト)に達した場合は、`Retry-After`に従って待機してから再試行します。そのため、テキストが多いと翻訳に時間がかかります。 +- Sarashinaサービス自体が無償ベータ版として提供されています。提供内容や提供条件、レート制限は変更または終了される可能性があります。 +- 無償プランでは、送信したテキストがモデルの学習などに利用される場合があります。個人情報や機密情報を含むウィンドウでは使用しないでください。利用には[Sarashinaサービス利用規約](https://sarashina.ai/terms)への同意が必要です。個人情報の取り扱いは[SB Intuitionsの個人情報保護方針](https://www.sbintuitions.co.jp/privacy-policy/)を参照してください。 Sarashina APIの利用登録とAPIキーの取得については、[Sarashina](https://sarashina.ai/)を確認してください。API仕様は[公式Translate APIリファレンス](https://docs.sarashina.ai/api-reference/translate)を参照してください。 @@ -16,8 +20,12 @@ Sarashina APIの利用登録とAPIキーの取得については、[Sarashina](h A [WindowTranslator](https://github.com/Freeesia/WindowTranslator) translation plugin that uses the Sarashina3 Translate API. - Uses `POST https://api.sarashina.ai/v1/translate` -- Supports Japanese and English as source and target languages +- Uses the dedicated Translate API when both languages are Japanese/English +- Falls back to the Chat Completions API (`sarashina3-mini`) for any other language pair - Sends the Sarashina API key as a bearer token in the Authorization header - An internet connection is required. Text to translate is sent to the Sarashina API. +- When the rate limit is reached (6 requests per minute for the Translate API and for `sarashina3-mini` Chat Completions), the plugin waits for the `Retry-After` period and retries. Translating many texts can therefore be slow. +- The Sarashina service itself is currently offered as a free beta. Features, terms, and rate limits may change or the service may end. +- On the free plan, the text you send may be used for model training and similar purposes. Do not use it with windows that contain personal or confidential information. Use of the service requires agreeing to the [Sarashina Terms of Service](https://sarashina.ai/terms). For how personal data is handled, see the [SB Intuitions Privacy Policy](https://www.sbintuitions.co.jp/privacy-policy/). See [Sarashina](https://sarashina.ai/) to register and obtain an API key. Refer to the [official Translate API reference](https://docs.sarashina.ai/api-reference/translate) for API details. diff --git a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Options.cs b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Options.cs index e676bda6..ed0292e5 100644 --- a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Options.cs +++ b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Options.cs @@ -26,10 +26,6 @@ public ValueTask Validate(TargetSettings settings) return ValueTask.FromResult(ValidateResult.Invalid("Sarashina3", "Sarashina APIキーを設定してください。")); } - return ValueTask.FromResult( - !Sarashina3Translator.SupportsLanguage(settings.Language.Source) - || !Sarashina3Translator.SupportsLanguage(settings.Language.Target) - ? ValidateResult.Invalid("Sarashina3", "Sarashina Translate APIは日本語と英語の翻訳に対応しています。") - : ValidateResult.Valid); + return ValueTask.FromResult(ValidateResult.Valid); } } diff --git a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Translator.cs b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Translator.cs index 71aea04a..c70fa819 100644 --- a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Translator.cs +++ b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Translator.cs @@ -1,8 +1,15 @@ +using System.ClientModel; +using System.ClientModel.Primitives; using System.Globalization; +using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; +using System.Text; +using System.Text.Encodings.Web; using System.Text.Json; using Microsoft.Extensions.Options; +using OpenAI; +using OpenAI.Chat; using WindowTranslator.Modules; namespace WindowTranslator.Plugin.Sarashina3Plugin; @@ -10,12 +17,49 @@ namespace WindowTranslator.Plugin.Sarashina3Plugin; public sealed class Sarashina3Translator : ITranslateModule { internal const string Endpoint = "https://api.sarashina.ai/v1/translate"; - private static readonly HttpClient httpClient = new(); + internal const int MaxTextLength = 5000; + private const int MaxParseAttempts = 3; + private const int MaxRateLimitAttempts = 5; + private static readonly JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + AllowTrailingCommas = true, + }; + private static readonly ChatCompletionOptions structuredOutputOptions = new() + { + ResponseFormat = ChatResponseFormat.CreateJsonSchemaFormat( + "translated_array", + BinaryData.FromString(""" + { + "type": "object", + "properties": { + "translated": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["translated"], + "additionalProperties": false + } + """), + "翻訳後のテキストの配列", + true), + }; + internal const string ChatModel = "sarashina3-mini"; + internal const string ChatEndpoint = "https://api.sarashina.ai/v1"; + // 429の待機時間を含めるため、既定の100秒より長くする + private static readonly TimeSpan requestTimeout = TimeSpan.FromMinutes(10); + private static readonly HttpClient httpClient = new() { Timeout = requestTimeout }; private readonly string apiKey; private readonly string sourceLanguage; private readonly string targetLanguage; + private readonly bool useTranslateApi; private readonly HttpClient client; + private readonly ChatClient chatClient; + private readonly string system; + + internal Func Delay { get; set; } = Task.Delay; public Sarashina3Translator(IOptionsSnapshot options, IOptionsSnapshot languages) : this(options.Value, languages.Value, httpClient) @@ -29,74 +73,180 @@ internal Sarashina3Translator(Sarashina3Options options, LanguageOptions languag throw new AppUserException("Sarashina APIキーを設定してください。"); } - this.sourceLanguage = GetLanguageCode(languages.Source); - this.targetLanguage = GetLanguageCode(languages.Target); + this.sourceLanguage = CultureInfo.GetCultureInfo(languages.Source).TwoLetterISOLanguageName; + this.targetLanguage = CultureInfo.GetCultureInfo(languages.Target).TwoLetterISOLanguageName; + this.useTranslateApi = IsTranslateApiLanguage(this.sourceLanguage) && IsTranslateApiLanguage(this.targetLanguage); this.apiKey = options.ApiKey.Trim(); this.client = client; + this.chatClient = new ChatClient(ChatModel, new ApiKeyCredential(this.apiKey), new OpenAIClientOptions + { + Endpoint = new Uri(ChatEndpoint), + Transport = new HttpClientPipelineTransport(client), + NetworkTimeout = requestTimeout, + }); + var source = CultureInfo.GetCultureInfo(languages.Source).DisplayName; + var target = CultureInfo.GetCultureInfo(languages.Target).DisplayName; + this.system = $$""" + あなたは{{source}}から{{target}}への翻訳の専門家です。 + 入力の各 text を、対となる context の文脈を考慮して{{target}}に翻訳してください。 + 入力 JSON は [{"text":"翻訳対象", "context":"文脈"}] の形式です。 + 翻訳できない文字列は元の表記を維持してください。 + 出力は必ず {"translated":["翻訳結果"]} 形式の JSON オブジェクトのみとし、説明や Markdown は付けないでください。 + translated は入力と同じ個数・順序の文字列配列にしてください。 + """; } - internal static bool SupportsLanguage(string cultureCode) - => TryGetLanguageCode(cultureCode, out _); + // 専用のTranslate APIは日本語と英語の相互翻訳のみ対応している + private static bool IsTranslateApiLanguage(string languageCode) + => languageCode is "ja" or "en"; - private static string GetLanguageCode(string cultureCode) - => TryGetLanguageCode(cultureCode, out var languageCode) - ? languageCode - : throw new AppUserException("Sarashina Translate APIは日本語と英語の翻訳に対応しています。"); - - private static bool TryGetLanguageCode(string cultureCode, out string languageCode) + public async ValueTask TranslateAsync(TextInfo[] srcTexts) { - try + if (this.sourceLanguage == this.targetLanguage) { - languageCode = CultureInfo.GetCultureInfo(cultureCode).TwoLetterISOLanguageName; - return languageCode is "ja" or "en"; + return [.. srcTexts.Select(t => t.SourceText)]; } - catch (CultureNotFoundException) + + if (!this.useTranslateApi) { - languageCode = string.Empty; - return false; + return srcTexts.Length == 0 ? [] : await this.TranslateWithChatAsync(srcTexts).ConfigureAwait(false); } - } - public async ValueTask TranslateAsync(TextInfo[] srcTexts) - { var translated = new string[srcTexts.Length]; for (var i = 0; i < srcTexts.Length; i++) { - if (this.sourceLanguage == this.targetLanguage) + var builder = new StringBuilder(); + foreach (var chunk in SplitText(srcTexts[i].SourceText)) { - translated[i] = srcTexts[i].SourceText; + builder.Append(await this.TranslateChunkAsync(chunk).ConfigureAwait(false)); + } + + translated[i] = builder.ToString(); + } + + return translated; + } + + private async ValueTask TranslateWithChatAsync(TextInfo[] srcTexts) + { + var input = JsonSerializer.Serialize( + srcTexts.Select(t => new { text = t.SourceText, context = t.Context }), + jsonOptions); + ChatMessage[] messages = [new SystemChatMessage(this.system), new UserChatMessage(input)]; + var parseFailures = 0; + var rateLimits = 0; + while (true) + { + ChatCompletion completion; + try + { + completion = await this.chatClient.CompleteChatAsync(messages, structuredOutputOptions).ConfigureAwait(false); + } + catch (ClientResultException e) when (e.Status == 429 && ++rateLimits < MaxRateLimitAttempts) + { + double? seconds = e.GetRawResponse()?.Headers.TryGetValue("Retry-After", out var value) == true + && double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : null; + await this.Delay(GetRetryDelay(seconds)).ConfigureAwait(false); continue; } + try + { + return ParseTranslation(string.Concat(completion.Content.Select(c => c.Text)), srcTexts.Length); + } + catch (JsonException) when (++parseFailures < MaxParseAttempts) + { + } + } + } + + // レート制限はカウント期間(60秒)ごとにリセットされるため、Retry-Afterの秒数だけ待つ + private static TimeSpan GetRetryDelay(double? retryAfterSeconds) + => TimeSpan.FromSeconds(Math.Clamp(retryAfterSeconds ?? 60, 1, 120)); + + // 構造化出力でもスキーマ準拠は保証されないため、件数も検証する + internal static string[] ParseTranslation(string text, int count) + { + text = text.Trim(); + if (text.StartsWith("```", StringComparison.Ordinal) && text.EndsWith("```", StringComparison.Ordinal)) + { + var newline = text.IndexOf('\n'); + if (newline >= 0) + { + text = text[(newline + 1)..^3].Trim(); + } + } + + var result = JsonSerializer.Deserialize(text, jsonOptions)?.Translated; + if (result is null || result.Length != count || result.Any(t => t is null)) + { + throw new JsonException("Translation must contain one string per input."); + } + + return result; + } + + private async ValueTask TranslateChunkAsync(string text) + { + HttpResponseMessage response; + for (var attempt = 1; ; attempt++) + { using var request = new HttpRequestMessage(HttpMethod.Post, Endpoint); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", this.apiKey); - request.Content = JsonContent.Create(new TranslateRequest( - srcTexts[i].SourceText, - this.sourceLanguage, - this.targetLanguage)); + request.Content = JsonContent.Create(new TranslateRequest(text, this.sourceLanguage, this.targetLanguage)); - using var response = await this.client.SendAsync(request).ConfigureAwait(false); - if (!response.IsSuccessStatusCode) + response = await this.client.SendAsync(request).ConfigureAwait(false); + if (response.StatusCode != HttpStatusCode.TooManyRequests || attempt >= MaxRateLimitAttempts) { - throw new HttpRequestException( - $"Sarashina Translate API returned HTTP {(int)response.StatusCode}.", - null, - response.StatusCode); + break; } - await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false); - if (!document.RootElement.TryGetProperty("translated_text", out var translatedText) - || translatedText.ValueKind != JsonValueKind.String) + var wait = GetRetryDelay(response.Headers.RetryAfter?.Delta?.TotalSeconds); + response.Dispose(); + await this.Delay(wait).ConfigureAwait(false); + } + + using var _ = response; + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"Sarashina Translate API returned HTTP {(int)response.StatusCode}.", + null, + response.StatusCode); + } + + await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false); + if (!document.RootElement.TryGetProperty("translated_text", out var translatedText) + || translatedText.ValueKind != JsonValueKind.String) + { + throw new InvalidDataException("Sarashina Translate API response did not contain translated_text."); + } + + return translatedText.GetString()!; + } + + // 上限はUnicodeコードポイント単位のため、サロゲートペアを分断しないよう分割する + internal static IEnumerable SplitText(string text) + { + var start = 0; + while (start < text.Length) + { + var end = start; + var count = 0; + while (end < text.Length && count < MaxTextLength) { - throw new InvalidDataException("Sarashina Translate API response did not contain translated_text."); + end += char.IsHighSurrogate(text[end]) && end + 1 < text.Length && char.IsLowSurrogate(text[end + 1]) ? 2 : 1; + count++; } - translated[i] = translatedText.GetString()!; + yield return text[start..end]; + start = end; } - - return translated; } + private sealed record ChatResponse(string[] Translated); private sealed record TranslateRequest(string text, string source_lang, string target_lang); } diff --git a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/WindowTranslator.Plugin.Sarashina3Plugin.csproj b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/WindowTranslator.Plugin.Sarashina3Plugin.csproj index 7ea180bf..7623ccbc 100644 --- a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/WindowTranslator.Plugin.Sarashina3Plugin.csproj +++ b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/WindowTranslator.Plugin.Sarashina3Plugin.csproj @@ -7,6 +7,7 @@ false + diff --git a/WindowTranslator.Tests/Sarashina3Tests.cs b/WindowTranslator.Tests/Sarashina3Tests.cs index 69036993..3094fd78 100644 --- a/WindowTranslator.Tests/Sarashina3Tests.cs +++ b/WindowTranslator.Tests/Sarashina3Tests.cs @@ -79,13 +79,76 @@ public void MissingApiKeyIsRejected() } [Fact] - public void UnsupportedLanguageIsRejected() + public async Task NonJapaneseEnglishFallsBackToChatCompletions() { - using var httpClient = new HttpClient(); - Assert.Throws(() => new Sarashina3Translator( - new Sarashina3Options { ApiKey = "sarashina-test-key" }, - new LanguageOptions { Source = "ja-JP", Target = "zh-CN" }, - httpClient)); + var handler = new RecordingResponseHandler( + """{"id":"x","object":"chat.completion","created":1,"model":"sarashina3-mini","choices":[{"index":0,"message":{"role":"assistant","content":"{\"translated\":[\"你好\"]}"},"finish_reason":"stop"}]}"""); + using var httpClient = new HttpClient(handler); + var translator = new Sarashina3Translator(new Sarashina3Options + { + ApiKey = "sarashina-test-key", + }, new LanguageOptions + { + Source = "ja-JP", + Target = "zh-CN", + }, httpClient); + + Assert.Equal(["你好"], await translator.TranslateAsync([new TextInfo("こんにちは", null)])); + var request = Assert.Single(handler.Requests); + Assert.Equal("https://api.sarashina.ai/v1/chat/completions", request.RequestUri?.AbsoluteUri); + Assert.Equal("sarashina-test-key", request.AuthorizationParameter); + using var body = JsonDocument.Parse(request.Body); + Assert.Equal("sarashina3-mini", body.RootElement.GetProperty("model").GetString()); + Assert.Equal("json_schema", body.RootElement.GetProperty("response_format").GetProperty("type").GetString()); + } + + [Fact] + public async Task RateLimitWaitsForRetryAfterAndRetries() + { + var handler = new RateLimitedHandler(); + using var httpClient = new HttpClient(handler); + var waits = new List(); + var translator = new Sarashina3Translator(new Sarashina3Options + { + ApiKey = "sarashina-test-key", + }, new LanguageOptions + { + Source = "ja-JP", + Target = "en-US", + }, httpClient) + { + Delay = span => + { + waits.Add(span); + return Task.CompletedTask; + }, + }; + + Assert.Equal(["Hello"], await translator.TranslateAsync([new TextInfo("こんにちは", null)])); + Assert.Equal(2, handler.Calls); + Assert.Equal([TimeSpan.FromSeconds(7)], waits); + } + + private sealed class RateLimitedHandler : HttpMessageHandler + { + public int Calls { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.Calls++; + if (this.Calls == 1) + { + var limited = new HttpResponseMessage(HttpStatusCode.TooManyRequests) { RequestMessage = request }; + limited.Headers.RetryAfter = new(TimeSpan.FromSeconds(7)); + return Task.FromResult(limited); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"translated_text":"Hello"}""", Encoding.UTF8, "application/json"), + RequestMessage = request, + }); + } } private sealed class RecordingResponseHandler(params string[] responses) : HttpMessageHandler