diff --git a/plugins/TypeWhisper.Plugin.Soniox/SonioxPlugin.cs b/plugins/TypeWhisper.Plugin.Soniox/SonioxPlugin.cs index a4530c24..c955f9a2 100644 --- a/plugins/TypeWhisper.Plugin.Soniox/SonioxPlugin.cs +++ b/plugins/TypeWhisper.Plugin.Soniox/SonioxPlugin.cs @@ -15,7 +15,8 @@ public sealed class SonioxPlugin : ITranscriptionEnginePlugin { internal const string DefaultModelId = "default"; - private const string BaseUrl = "https://api.soniox.com"; + internal const string DefaultRegionId = "us"; + private const string RegionSettingKey = "region"; private const string ApiKeySecretName = "api-key"; private const string SonioxAsyncModelId = "stt-async-v4"; private const int DefaultMaxPollAttempts = 3600; @@ -34,6 +35,19 @@ public sealed class SonioxPlugin : ITranscriptionEnginePlugin }, ]; + // Soniox data residency: each region has its own domain and requires an API key from a + // project created in that region. https://soniox.com/docs/stt/data-residency + private static readonly IReadOnlyList Regions = + [ + new(DefaultRegionId, "United States", "https://api.soniox.com"), + new("eu", "European Union", "https://api.eu.soniox.com"), + new("jp", "Japan", "https://api.jp.soniox.com"), + ]; + + // Each auto-detect probe is bounded so an unreachable region fails fast rather than + // blocking the settings Test flow for the full HttpClient timeout. + private static readonly TimeSpan RegionProbeTimeout = TimeSpan.FromSeconds(10); + private readonly HttpClient _httpClient; private readonly TimeSpan _pollDelay; private readonly int _maxPollAttempts; @@ -42,6 +56,7 @@ public sealed class SonioxPlugin : ITranscriptionEnginePlugin private IPluginHostServices? _host; private string? _apiKey; private string _selectedModelId = DefaultModelId; + private string _region = DefaultRegionId; /// /// Initializes a new instance of the SonioxPlugin class. @@ -64,6 +79,8 @@ internal SonioxPlugin( _maxPollAttempts = maxPollAttempts; } + private string BaseUrl => ResolveRegion(_region).BaseUrl; + // ITypeWhisperPlugin /// @@ -77,7 +94,7 @@ internal SonioxPlugin( /// /// Gets the plugin version reported to the host. /// - public string PluginVersion => "1.0.3"; + public string PluginVersion => "1.0.4"; /// /// Activates the plugin and loads any persisted configuration. @@ -87,7 +104,8 @@ public async Task ActivateAsync(IPluginHostServices host) _host = host; _apiKey = NormalizeApiKey(await host.LoadSecretAsync(ApiKeySecretName)); _selectedModelId = DefaultModelId; - host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured})"); + _region = NormalizeRegionId(host.GetSetting(RegionSettingKey)); + host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured}, region={_region})"); } /// @@ -206,6 +224,55 @@ public async Task TranscribeWithLanguageHintsAsync( internal string? ApiKey => _apiKey; internal IPluginLocalization? Loc => _host?.Localization; + internal static IReadOnlyList AvailableRegions => Regions; + + /// Gets the currently selected Soniox data-residency region id. + internal string RegionId => _region; + + /// Persists the selected Soniox region. Unknown ids fall back to the default (US). + internal void SetRegion(string regionId) + { + var normalized = NormalizeRegionId(regionId); + if (string.Equals(_region, normalized, StringComparison.Ordinal)) + return; + + _region = normalized; + _host?.SetSetting(RegionSettingKey, normalized); + } + + /// + /// Probes each regional endpoint with the key and returns the region id it authenticates + /// against, or null if none accept it. Region is bound to the key's project, not the + /// user's location, so probing detects the correct region reliably. + /// + internal async Task DetectRegionAsync(string apiKey, CancellationToken ct = default) + { + var normalized = NormalizeApiKey(apiKey); + if (normalized is null) + return null; + + foreach (var region in Regions) + { + // Bound the probe so an unreachable region fails fast instead of blocking on the + // full HttpClient timeout; a probe timeout skips to the next region, while a real + // caller cancellation still propagates. + using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + probeCts.CancelAfter(RegionProbeTimeout); + + try + { + if (await ProbeModelsAsync(region.BaseUrl, normalized, probeCts.Token)) + return region.Id; + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + // This region's probe timed out; try the next region. + } + } + + return null; + } + internal async Task SetApiKeyAsync(string apiKey) { var normalized = NormalizeApiKey(apiKey); @@ -249,10 +316,15 @@ internal async Task ValidateApiKeyAsync(string apiKey, CancellationToken c if (normalized is null) return false; + return await ProbeModelsAsync(BaseUrl, normalized, ct); + } + + private async Task ProbeModelsAsync(string baseUrl, string apiKey, CancellationToken ct) + { try { - using var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/v1/models"); - AddAuthorization(request, normalized); + using var request = new HttpRequestMessage(HttpMethod.Get, $"{baseUrl}/v1/models"); + AddAuthorization(request, apiKey); using var response = await _httpClient.SendAsync(request, ct); return response.IsSuccessStatusCode; } @@ -653,6 +725,13 @@ private static string ExtractApiError(JsonElement root) : trimmed; } + private static string NormalizeRegionId(string? regionId) => ResolveRegion(regionId).Id; + + private static SonioxRegion ResolveRegion(string? regionId) => + Regions.FirstOrDefault(region => + string.Equals(region.Id, regionId?.Trim(), StringComparison.OrdinalIgnoreCase)) + ?? Regions[0]; + private static string? GetString(JsonElement element, string propertyName) => element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String @@ -676,6 +755,9 @@ private static bool TryGetDouble(JsonElement element, string propertyName, out d private sealed record SonioxTimedToken(string Text, double Start, double End); + /// A Soniox data-residency region and its REST base URL. + internal sealed record SonioxRegion(string Id, string DisplayName, string BaseUrl); + /// /// Releases resources held by the instance. /// diff --git a/plugins/TypeWhisper.Plugin.Soniox/SonioxSettingsView.xaml b/plugins/TypeWhisper.Plugin.Soniox/SonioxSettingsView.xaml index 8061c44b..90585a0e 100644 --- a/plugins/TypeWhisper.Plugin.Soniox/SonioxSettingsView.xaml +++ b/plugins/TypeWhisper.Plugin.Soniox/SonioxSettingsView.xaml @@ -12,6 +12,8 @@