diff --git a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/README.md b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/README.md new file mode 100644 index 00000000..92ae2921 --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/README.md @@ -0,0 +1,31 @@ +# WindowTranslator Sarashina3 Translator Plugin + +## ja + +[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)を参照してください。 + +## en + +A [WindowTranslator](https://github.com/Freeesia/WindowTranslator) translation plugin that uses the Sarashina3 Translate API. + +- Uses `POST https://api.sarashina.ai/v1/translate` +- 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 new file mode 100644 index 00000000..ed0292e5 --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Options.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.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; } +} + +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; + if (string.IsNullOrWhiteSpace(options?.ApiKey)) + { + return ValueTask.FromResult(ValidateResult.Invalid("Sarashina3", "Sarashina APIキーを設定してください。")); + } + + return ValueTask.FromResult(ValidateResult.Valid); + } +} diff --git a/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Translator.cs b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Translator.cs new file mode 100644 index 00000000..c70fa819 --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.Sarashina3Plugin/Sarashina3Translator.cs @@ -0,0 +1,252 @@ +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; + +public sealed class Sarashina3Translator : ITranslateModule +{ + internal const string Endpoint = "https://api.sarashina.ai/v1/translate"; + 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) + { + } + + internal Sarashina3Translator(Sarashina3Options options, LanguageOptions languages, HttpClient client) + { + if (string.IsNullOrWhiteSpace(options.ApiKey)) + { + throw new AppUserException("Sarashina APIキーを設定してください。"); + } + + 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 は入力と同じ個数・順序の文字列配列にしてください。 + """; + } + + // 専用のTranslate APIは日本語と英語の相互翻訳のみ対応している + private static bool IsTranslateApiLanguage(string languageCode) + => languageCode is "ja" or "en"; + + public async ValueTask TranslateAsync(TextInfo[] srcTexts) + { + if (this.sourceLanguage == this.targetLanguage) + { + return [.. srcTexts.Select(t => t.SourceText)]; + } + + if (!this.useTranslateApi) + { + return srcTexts.Length == 0 ? [] : await this.TranslateWithChatAsync(srcTexts).ConfigureAwait(false); + } + + var translated = new string[srcTexts.Length]; + for (var i = 0; i < srcTexts.Length; i++) + { + var builder = new StringBuilder(); + foreach (var chunk in SplitText(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(text, this.sourceLanguage, this.targetLanguage)); + + response = await this.client.SendAsync(request).ConfigureAwait(false); + if (response.StatusCode != HttpStatusCode.TooManyRequests || attempt >= MaxRateLimitAttempts) + { + break; + } + + 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) + { + end += char.IsHighSurrogate(text[end]) && end + 1 < text.Length && char.IsLowSurrogate(text[end + 1]) ? 2 : 1; + count++; + } + + yield return text[start..end]; + start = end; + } + } + + 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 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..3094fd78 --- /dev/null +++ b/WindowTranslator.Tests/Sarashina3Tests.cs @@ -0,0 +1,178 @@ +extern alias Sarashina3; + +using System.Net; +using System.Text; +using System.Text.Json; +using Sarashina3Options = Sarashina3::WindowTranslator.Plugin.Sarashina3Plugin.Sarashina3Options; +using Sarashina3Translator = Sarashina3::WindowTranslator.Plugin.Sarashina3Plugin.Sarashina3Translator; + +namespace WindowTranslator.Tests; + +public class Sarashina3Tests +{ + [Fact] + public async Task TranslationUsesDedicatedTranslateApi() + { + var handler = new RecordingResponseHandler( + """{"translated_text":"Hello"}""", + """{"translated_text":"Goodbye"}"""); + using var httpClient = new HttpClient(handler); + var translator = new Sarashina3Translator(new Sarashina3Options + { + ApiKey = "sarashina-test-key", + }, new LanguageOptions + { + Source = "ja-JP", + Target = "en-US", + }, httpClient); + + var translated = await translator.TranslateAsync([ + new TextInfo("こんにちは", null), + 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, 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( + 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" }, + httpClient)); + } + + [Fact] + public async Task NonJapaneseEnglishFallsBackToChatCompletions() + { + 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 + { + private readonly Queue responses = new(responses); + + public List Requests { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = request.Content is null ? string.Empty : await 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 @@ +