Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 87 additions & 5 deletions plugins/TypeWhisper.Plugin.Soniox/SonioxPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<SonioxRegion> 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;
Expand All @@ -42,6 +56,7 @@ public sealed class SonioxPlugin : ITranscriptionEnginePlugin
private IPluginHostServices? _host;
private string? _apiKey;
private string _selectedModelId = DefaultModelId;
private string _region = DefaultRegionId;

/// <summary>
/// Initializes a new instance of the SonioxPlugin class.
Expand All @@ -64,6 +79,8 @@ internal SonioxPlugin(
_maxPollAttempts = maxPollAttempts;
}

private string BaseUrl => ResolveRegion(_region).BaseUrl;

// ITypeWhisperPlugin

/// <summary>
Expand All @@ -77,7 +94,7 @@ internal SonioxPlugin(
/// <summary>
/// Gets the plugin version reported to the host.
/// </summary>
public string PluginVersion => "1.0.3";
public string PluginVersion => "1.0.4";

/// <summary>
/// Activates the plugin and loads any persisted configuration.
Expand All @@ -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<string>(RegionSettingKey));
host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured}, region={_region})");
}

/// <summary>
Expand Down Expand Up @@ -184,6 +202,55 @@ public async Task<PluginTranscriptionResult> TranscribeAsync(
internal string? ApiKey => _apiKey;
internal IPluginLocalization? Loc => _host?.Localization;

internal static IReadOnlyList<SonioxRegion> AvailableRegions => Regions;

/// <summary>Gets the currently selected Soniox data-residency region id.</summary>
internal string RegionId => _region;

/// <summary>Persists the selected Soniox region. Unknown ids fall back to the default (US).</summary>
internal void SetRegion(string regionId)
{
var normalized = NormalizeRegionId(regionId);
if (string.Equals(_region, normalized, StringComparison.Ordinal))
return;

_region = normalized;
_host?.SetSetting(RegionSettingKey, normalized);
}

/// <summary>
/// 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.
/// </summary>
internal async Task<string?> 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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

internal async Task SetApiKeyAsync(string apiKey)
{
var normalized = NormalizeApiKey(apiKey);
Expand Down Expand Up @@ -227,10 +294,15 @@ internal async Task<bool> ValidateApiKeyAsync(string apiKey, CancellationToken c
if (normalized is null)
return false;

return await ProbeModelsAsync(BaseUrl, normalized, ct);
}

private async Task<bool> 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;
}
Expand Down Expand Up @@ -631,6 +703,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
Expand All @@ -654,6 +733,9 @@ private static bool TryGetDouble(JsonElement element, string propertyName, out d

private sealed record SonioxTimedToken(string Text, double Start, double End);

/// <summary>A Soniox data-residency region and its REST base URL.</summary>
internal sealed record SonioxRegion(string Id, string DisplayName, string BaseUrl);

/// <summary>
/// Releases resources held by the instance.
/// </summary>
Expand Down
2 changes: 2 additions & 0 deletions plugins/TypeWhisper.Plugin.Soniox/SonioxSettingsView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
<Button Grid.Column="1" Margin="8,0,0,0" Padding="12,4"
Click="OnTestClick" x:Name="TestButton" />
</Grid>
<TextBlock x:Name="RegionLabel" FontWeight="SemiBold" Margin="0,10,0,4" />
<ComboBox x:Name="RegionBox" SelectionChanged="OnRegionChanged" />
<TextBlock x:Name="StatusText" Margin="0,4,0,0" FontSize="12" />
</StackPanel>
</UserControl>
63 changes: 60 additions & 3 deletions plugins/TypeWhisper.Plugin.Soniox/SonioxSettingsView.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public partial class SonioxSettingsView : UserControl
private readonly SonioxPlugin _plugin;
private CancellationTokenSource? _saveDebounceCts;
private readonly bool _suppressPasswordChanged;
private bool _suppressRegionChanged;

/// <summary>
/// Initializes a new instance of the SonioxSettingsView class.
Expand All @@ -26,6 +27,13 @@ public SonioxSettingsView(SonioxPlugin plugin)

ApiKeyLabel.Text = L("Settings.ApiKeyLabel");
TestButton.Content = L("Settings.Test");
RegionLabel.Text = L("Settings.Region");

_suppressRegionChanged = true;
foreach (var region in SonioxPlugin.AvailableRegions)
RegionBox.Items.Add(new ComboBoxItem { Content = region.DisplayName, Tag = region.Id });
SelectRegionItem(plugin.RegionId);
_suppressRegionChanged = false;

if (!string.IsNullOrEmpty(plugin.ApiKey))
{
Expand Down Expand Up @@ -102,14 +110,27 @@ private async void OnTestClick(object sender, RoutedEventArgs e)
}

TestButton.IsEnabled = false;
RegionBox.IsEnabled = false;
ApiKeyBox.IsEnabled = false;
StatusText.Text = L("Settings.Testing");
StatusText.Foreground = Brushes.Gray;

try
{
var valid = await _plugin.ValidateApiKeyAsync(key);
StatusText.Text = valid ? L("Settings.ApiKeyValid") : L("Settings.ApiKeyInvalid");
StatusText.Foreground = valid ? Brushes.Green : Brushes.Red;
var detectedRegion = await _plugin.DetectRegionAsync(key);
Comment thread
SeoFood marked this conversation as resolved.
if (detectedRegion is not null)
{
ApplyDetectedRegion(detectedRegion);
var displayName = SonioxPlugin.AvailableRegions
.First(region => region.Id == detectedRegion).DisplayName;
StatusText.Text = L("Settings.ApiKeyValidRegion", displayName);
StatusText.Foreground = Brushes.Green;
}
else
{
StatusText.Text = L("Settings.ApiKeyInvalid");
StatusText.Foreground = Brushes.Red;
}
}
catch (OperationCanceledException ex)
{
Expand All @@ -130,7 +151,41 @@ private async void OnTestClick(object sender, RoutedEventArgs e)
finally
{
TestButton.IsEnabled = true;
RegionBox.IsEnabled = true;
ApiKeyBox.IsEnabled = true;
}
}

private void OnRegionChanged(object sender, SelectionChangedEventArgs e)
{
if (_suppressRegionChanged)
return;

if ((RegionBox.SelectedItem as ComboBoxItem)?.Tag is string regionId)
_plugin.SetRegion(regionId);
}

private void SelectRegionItem(string regionId)
{
foreach (var item in RegionBox.Items)
{
if (item is ComboBoxItem comboItem && comboItem.Tag as string == regionId)
{
RegionBox.SelectedItem = comboItem;
return;
}
}

if (RegionBox.Items.Count > 0)
RegionBox.SelectedIndex = 0;
}

private void ApplyDetectedRegion(string regionId)
{
_suppressRegionChanged = true;
SelectRegionItem(regionId);
_suppressRegionChanged = false;
_plugin.SetRegion(regionId);
}

private void ShowError(Exception ex)
Expand Down Expand Up @@ -164,7 +219,9 @@ internal static string FormatFallbackText(string key, object[] args)
"Settings.EnterApiKey" => "Enter an API key.",
"Settings.Testing" => "Testing...",
"Settings.ApiKeyValid" => "API key valid.",
"Settings.ApiKeyValidRegion" => "API key valid (region: {0}).",
"Settings.ApiKeyInvalid" => "Invalid API key.",
"Settings.Region" => "Soniox region",
"Settings.Error" => "Error",
_ => key
};
Expand Down
2 changes: 1 addition & 1 deletion plugins/TypeWhisper.Plugin.Soniox/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "com.typewhisper.soniox",
"name": "Soniox",
"version": "1.0.3",
"version": "1.0.4",
"author": "TypeWhisper",
"description": "Soniox speech-to-text transcription engine",
"category": "transcription",
Expand Down
85 changes: 83 additions & 2 deletions tests/TypeWhisper.PluginSystem.Tests/SonioxPluginTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ public void PluginVersion_IsSonioxMarketplaceReleaseVersion()
var manifest = LoadManifest();
var sut = new SonioxPlugin();

Assert.Equal("1.0.3", manifest.GetProperty("version").GetString());
Assert.Equal("1.0.3", sut.PluginVersion);
Assert.Equal("1.0.4", manifest.GetProperty("version").GetString());
Assert.Equal("1.0.4", sut.PluginVersion);
}

[Fact]
Expand Down Expand Up @@ -130,6 +130,87 @@ public async Task ValidateApiKeyAsync_UsesModelsEndpointAndBearerHeader()
Assert.True(await sut.ValidateApiKeyAsync(" probe-key "));
}

[Fact]
public async Task ActivateAsync_UsesPersistedRegionEndpoint()
{
var handler = new CapturingHandler((request, _) =>
{
Assert.Equal("https://api.jp.soniox.com/v1/models", request.RequestUri?.ToString());
return JsonResponse("""{ "models": [] }""");
});

var host = new TestPluginHostServices();
host.SetSetting("region", "jp");
using var httpClient = new HttpClient(handler);
var sut = new SonioxPlugin(httpClient);
await sut.ActivateAsync(host);

Assert.Equal("jp", sut.RegionId);
Assert.True(await sut.ValidateApiKeyAsync("probe-key"));
}

[Fact]
public async Task ActivateAsync_FallsBackToUsForUnknownRegion()
{
var host = new TestPluginHostServices();
host.SetSetting("region", "mars");
var sut = new SonioxPlugin();
await sut.ActivateAsync(host);

Assert.Equal("us", sut.RegionId);
}

[Fact]
public async Task SetRegion_PersistsRegionAndSwitchesEndpoint()
{
var handler = new CapturingHandler((request, _) =>
{
Assert.Equal("https://api.eu.soniox.com/v1/models", request.RequestUri?.ToString());
return JsonResponse("""{ "models": [] }""");
});

var host = new TestPluginHostServices();
using var httpClient = new HttpClient(handler);
var sut = new SonioxPlugin(httpClient);
await sut.ActivateAsync(host);

sut.SetRegion("eu");

Assert.Equal("eu", sut.RegionId);
Assert.Equal("eu", host.GetSetting<string>("region"));
Assert.True(await sut.ValidateApiKeyAsync("probe-key"));
}

[Fact]
public async Task DetectRegionAsync_ReturnsRegionWhereKeyAuthenticates()
{
var handler = new CapturingHandler((request, _) =>
request.RequestUri?.ToString() == "https://api.jp.soniox.com/v1/models"
? JsonResponse("""{ "models": [] }""")
: JsonResponse("""{ "error": "unauthorized" }""", HttpStatusCode.Unauthorized));

var host = new TestPluginHostServices();
using var httpClient = new HttpClient(handler);
var sut = new SonioxPlugin(httpClient);
await sut.ActivateAsync(host);

Assert.Equal("jp", await sut.DetectRegionAsync("probe-key"));
}

[Fact]
public async Task DetectRegionAsync_ReturnsNullWhenNoRegionAccepts()
{
var handler = new CapturingHandler((_, _) =>
JsonResponse("""{ "error": "unauthorized" }""", HttpStatusCode.Unauthorized));

var host = new TestPluginHostServices();
using var httpClient = new HttpClient(handler);
var sut = new SonioxPlugin(httpClient);
await sut.ActivateAsync(host);

Assert.Null(await sut.DetectRegionAsync("probe-key"));
}

[Fact]
public async Task TranscribeAsync_UsesAsyncTranscriptionFlowAndCleansUp()
{
Expand Down
Loading