From 5f35b403f360cdd2c46fcd028476513471b1a738 Mon Sep 17 00:00:00 2001 From: John Gamble Date: Sun, 5 Jul 2026 13:50:04 +1000 Subject: [PATCH 1/3] feat(soniox): select data-residency region (US/EU/JP) with auto-detect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin hardcoded the US endpoint (api.soniox.com), so EU (api.eu.soniox.com) and Japan (api.jp.soniox.com) keys could not be used at all — Soniox binds each key to its project's region and domain — and everyone paid US round-trip latency. - Resolve the base URL from a persisted Region setting (default US, so existing installs are unchanged), via IPluginHostServices.GetSetting/SetSetting. - Settings view gains a Region dropdown; the Test button now probes each regional /v1/models with the key and auto-selects the region it authenticates against. Region is bound to the key's project, not the user's location, so probing is the correct detection, not geo/IP. - Bump plugin version to 1.0.4; add tests for region persistence, endpoint switching, unknown-region fallback, and auto-detect (hit + miss). Implements #261 Co-Authored-By: Claude Opus 4.8 --- .../TypeWhisper.Plugin.Soniox/SonioxPlugin.cs | 75 ++++++++++++++-- .../SonioxSettingsView.xaml | 2 + .../SonioxSettingsView.xaml.cs | 59 ++++++++++++- .../TypeWhisper.Plugin.Soniox/manifest.json | 2 +- .../SonioxPluginTests.cs | 85 ++++++++++++++++++- 5 files changed, 212 insertions(+), 11 deletions(-) diff --git a/plugins/TypeWhisper.Plugin.Soniox/SonioxPlugin.cs b/plugins/TypeWhisper.Plugin.Soniox/SonioxPlugin.cs index ea67f592..06283f88 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,15 @@ 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"), + ]; + private readonly HttpClient _httpClient; private readonly TimeSpan _pollDelay; private readonly int _maxPollAttempts; @@ -42,6 +52,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 +75,8 @@ internal SonioxPlugin( _maxPollAttempts = maxPollAttempts; } + private string BaseUrl => ResolveRegion(_region).BaseUrl; + // ITypeWhisperPlugin /// @@ -77,7 +90,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 +100,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})"); } /// @@ -184,6 +198,42 @@ public async Task TranscribeAsync( 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) + { + if (await ProbeModelsAsync(region.BaseUrl, normalized, ct)) + return region.Id; + } + + return null; + } + internal async Task SetApiKeyAsync(string apiKey) { var normalized = NormalizeApiKey(apiKey); @@ -227,10 +277,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; } @@ -631,6 +686,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 @@ -654,6 +716,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 @@