diff --git a/.editorconfig b/.editorconfig
index 551d0b0bb..6d17702cd 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -3,6 +3,10 @@
[*.cs]
+# The codebase deliberately uses trailing commas in multiline lists (cleaner diffs);
+# tell ReSharper that is the intended style so it stops flagging them for removal.
+resharper_csharp_trailing_comma_in_multiline_lists = true
+
# IDE0066: Convert switch statement to expression
dotnet_diagnostic.ide0066.severity = none
diff --git a/.github/workflows/package-dry-run-linux.yml b/.github/workflows/package-dry-run-linux.yml
index 186a7e948..b17725880 100644
--- a/.github/workflows/package-dry-run-linux.yml
+++ b/.github/workflows/package-dry-run-linux.yml
@@ -37,7 +37,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
- dpkg-dev rpm wget desktop-file-utils
+ cpio dpkg-dev file rpm wget desktop-file-utils
- name: Resolve dry-run version
id: meta
@@ -49,7 +49,16 @@ jobs:
- name: Build all Linux packages
run: bash scripts/build-linux-packages.sh "${{ steps.meta.outputs.version }}" dist
+ - name: Smoke test all Linux packages
+ # Covers building the two prepared images as well as the smoke runs
+ # themselves; a throttled distro mirror has pushed the dependency
+ # download alone past twenty minutes.
+ timeout-minutes: 45
+ run: bash scripts/smoke-test-linux-packages.sh "${{ steps.meta.outputs.version }}" dist
+
- uses: actions/upload-artifact@v7
+ # Keep the built packages available even when the smoke gate fails so a
+ # failed dry run can still be diagnosed; the job still reports failure.
if: always()
with:
name: package-dry-run-linux-${{ steps.meta.outputs.version }}
diff --git a/.github/workflows/plugins-smoke.yml b/.github/workflows/plugins-smoke.yml
index d083449b0..5d6b7bf06 100644
--- a/.github/workflows/plugins-smoke.yml
+++ b/.github/workflows/plugins-smoke.yml
@@ -30,6 +30,31 @@ jobs:
fetch-depth: 0
persist-credentials: false
+ - name: Run plugin release transaction regression
+ shell: pwsh
+ run: |
+ $minimumPesterVersion = [version]'5.5.0'
+ $pester = Get-Module -ListAvailable Pester |
+ Where-Object Version -ge $minimumPesterVersion |
+ Sort-Object Version -Descending |
+ Select-Object -First 1
+ if (-not $pester) {
+ Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force
+ $pester = Get-Module -ListAvailable Pester |
+ Where-Object Version -ge $minimumPesterVersion |
+ Sort-Object Version -Descending |
+ Select-Object -First 1
+ }
+ Import-Module $pester.Path -Force
+
+ $result = Invoke-Pester `
+ -Path tests/workflows/PublishPluginReleaseTransaction.Tests.ps1 `
+ -Output Detailed `
+ -PassThru
+ if ($result.FailedCount -gt 0) {
+ throw "$($result.FailedCount) plugin release transaction test(s) failed."
+ }
+
- id: discover
shell: pwsh
run: |
diff --git a/.github/workflows/pr-ci-linux.yml b/.github/workflows/pr-ci-linux.yml
index 036fada05..a8363ce99 100644
--- a/.github/workflows/pr-ci-linux.yml
+++ b/.github/workflows/pr-ci-linux.yml
@@ -19,6 +19,7 @@ concurrency:
jobs:
build-and-test:
runs-on: ubuntu-latest
+ timeout-minutes: 45
steps:
- uses: actions/checkout@v7
@@ -50,13 +51,20 @@ jobs:
run: dotnet build src/TypeWhisper.Cli/TypeWhisper.Cli.csproj -c Release --no-restore -bl:artifacts/logs/typewhisper-cli.binlog
- name: Test TypeWhisper.Core
- run: dotnet test tests/TypeWhisper.Core.Tests/TypeWhisper.Core.Tests.csproj -c Release --no-build --logger "trx;LogFileName=TypeWhisper.Core.Tests.trx" --results-directory artifacts/test-results
+ timeout-minutes: 15
+ run: dotnet test tests/TypeWhisper.Core.Tests/TypeWhisper.Core.Tests.csproj -c Release --no-build --logger "trx;LogFileName=TypeWhisper.Core.Tests.trx" --results-directory artifacts/test-results --blame-hang --blame-hang-timeout 5m --blame-hang-dump-type mini
- name: Test plugin system
- run: dotnet test tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj -c Release --no-build --logger "trx;LogFileName=TypeWhisper.PluginSystem.Tests.trx" --results-directory artifacts/test-results
+ timeout-minutes: 15
+ run: dotnet test tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj -c Release --no-build --logger "trx;LogFileName=TypeWhisper.PluginSystem.Tests.trx" --results-directory artifacts/test-results --blame-hang --blame-hang-timeout 5m --blame-hang-dump-type mini
- name: Test TypeWhisper.Linux
- run: dotnet test tests/TypeWhisper.Linux.Tests/TypeWhisper.Linux.Tests.csproj -c Release --no-build --logger "trx;LogFileName=TypeWhisper.Linux.Tests.trx" --results-directory artifacts/test-results
+ timeout-minutes: 15
+ run: dotnet test tests/TypeWhisper.Linux.Tests/TypeWhisper.Linux.Tests.csproj -c Release --no-build --logger "trx;LogFileName=TypeWhisper.Linux.Tests.trx" --results-directory artifacts/test-results --blame-hang --blame-hang-timeout 5m --blame-hang-dump-type mini
+
+ - name: Test TypeWhisper.Cli
+ timeout-minutes: 15
+ run: dotnet test tests/TypeWhisper.Cli.Tests/TypeWhisper.Cli.Tests.csproj -c Release --no-build --logger "trx;LogFileName=TypeWhisper.Cli.Tests.trx" --results-directory artifacts/test-results --blame-hang --blame-hang-timeout 5m --blame-hang-dump-type mini
- uses: actions/upload-artifact@v7
if: always()
@@ -64,5 +72,5 @@ jobs:
name: pr-ci-linux-artifacts
path: |
artifacts/logs/*.binlog
- artifacts/test-results/*.trx
+ artifacts/test-results/**
if-no-files-found: ignore
diff --git a/.github/workflows/publish-plugins.yml b/.github/workflows/publish-plugins.yml
index 8c1bbda5e..ddf3d488a 100644
--- a/.github/workflows/publish-plugins.yml
+++ b/.github/workflows/publish-plugins.yml
@@ -81,6 +81,10 @@ jobs:
with:
dotnet-version: '10.0.x'
+ - name: Test plugin system
+ timeout-minutes: 15
+ run: dotnet test tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj -c Release -p:DeployBundledLinuxPlugins=false --blame-hang --blame-hang-timeout 5m --blame-hang-dump-type mini
+
- name: Patch manifest version
shell: pwsh
run: |
@@ -161,98 +165,17 @@ jobs:
echo "PLUGIN_ID=$pluginId" >> $env:GITHUB_ENV
echo "Created $zipName ($zipSize bytes)"
- - name: Create GitHub Release
+ - name: Publish plugin release transaction
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
run: |
- $tag = "${{ github.ref_name }}"
- gh release create $tag $env:ZIP_NAME `
- --title "$env:PROJECT_NAME v$env:PLUGIN_VERSION" `
- --notes ""
-
- - name: Update plugins.json on gh-pages
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- shell: pwsh
- run: |
- $tag = "${{ github.ref_name }}"
- $repo = "${{ github.repository }}"
- $downloadUrl = "https://github.com/$repo/releases/download/$tag/$env:ZIP_NAME"
-
- git fetch origin gh-pages
- git worktree add -B gh-pages gh-pages-work origin/gh-pages
-
- $maxRetries = 5
- for ($i = 1; $i -le $maxRetries; $i++) {
- try {
- Push-Location gh-pages-work
- git fetch origin gh-pages
- git reset --hard origin/gh-pages
-
- $registry = @(
- Get-Content plugins.json | ConvertFrom-Json
- )
- $updated = $false
-
- for ($j = 0; $j -lt $registry.Count; $j++) {
- if ($registry[$j].id -eq $env:PLUGIN_ID) {
- $registry[$j].version = $env:PLUGIN_VERSION
- $registry[$j].size = [long]$env:ZIP_SIZE
- $registry[$j].downloadUrl = $downloadUrl
- $updated = $true
- Write-Host "Updated $($registry[$j].id) to v$env:PLUGIN_VERSION"
- break
- }
- }
-
- if (-not $updated) {
- $manifestPath = Join-Path ".." "plugins/$env:PROJECT_NAME/manifest.json"
- $manifest = Get-Content $manifestPath | ConvertFrom-Json
-
- $registry += [pscustomobject]@{
- id = $manifest.id
- name = $manifest.name
- version = $env:PLUGIN_VERSION
- minHostVersion = $manifest.minHostVersion
- author = $manifest.author
- description = $manifest.description
- category = $manifest.category
- size = [long]$env:ZIP_SIZE
- downloadUrl = $downloadUrl
- iconSystemName = $manifest.iconSystemName
- requiresApiKey = [bool]($manifest.requiresApiKey)
- descriptions = $manifest.descriptions
- }
-
- Write-Host "Added $env:PLUGIN_ID to registry at v$env:PLUGIN_VERSION"
- }
-
- $registry | ConvertTo-Json -Depth 4 | Set-Content plugins.json
-
- # Also copy the ZIP to plugins/ directory on gh-pages
- New-Item -ItemType Directory -Path "plugins" -Force | Out-Null
- Copy-Item (Join-Path .. $env:ZIP_NAME) "plugins/$env:ZIP_NAME" -Force
-
- git add -A
- git config user.name "github-actions[bot]"
- git config user.email "github-actions[bot]@users.noreply.github.com"
-
- $diff = git diff --cached --quiet 2>&1; $hasChanges = $LASTEXITCODE -ne 0
- if ($hasChanges) {
- git commit -m "Update $env:PROJECT_NAME to v$env:PLUGIN_VERSION"
- git push origin gh-pages
- Write-Host "Successfully updated plugins.json (attempt $i)"
- } else {
- Write-Host "No changes to deploy"
- }
-
- Pop-Location
- break
- } catch {
- Pop-Location
- if ($i -eq $maxRetries) { throw }
- Write-Host "Push failed (attempt $i), retrying in ${i}s..."
- Start-Sleep -Seconds $i
- }
- }
+ ./scripts/publish-plugin-release.ps1 `
+ -Tag $env:GITHUB_REF_NAME `
+ -Repository $env:GITHUB_REPOSITORY `
+ -CommitSha $env:GITHUB_SHA `
+ -ProjectName $env:PROJECT_NAME `
+ -PluginVersion $env:PLUGIN_VERSION `
+ -PluginId $env:PLUGIN_ID `
+ -ZipPath $env:ZIP_NAME `
+ -ManifestPath "plugins/$env:PROJECT_NAME/manifest.json"
diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml
index 3cf5bef0b..8fa18a502 100644
--- a/.github/workflows/release-linux.yml
+++ b/.github/workflows/release-linux.yml
@@ -95,6 +95,8 @@ jobs:
build:
needs: prepare
runs-on: ubuntu-latest
+ # 30m unit-test cap + 30m package-smoke cap + 30m setup/build/upload allowance.
+ timeout-minutes: 90
env:
VERSION: ${{ needs.prepare.outputs.version }}
@@ -115,19 +117,28 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
- dpkg-dev rpm wget desktop-file-utils
+ cpio dpkg-dev file rpm wget desktop-file-utils
- name: Run unit tests
+ timeout-minutes: 30
run: |
dotnet restore TypeWhisper.slnx
dotnet build TypeWhisper.slnx -c Release --no-restore -p:Version=${VERSION}
- dotnet test tests/TypeWhisper.Core.Tests/TypeWhisper.Core.Tests.csproj -c Release --no-build
- dotnet test tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj -c Release --no-build
- dotnet test tests/TypeWhisper.Linux.Tests/TypeWhisper.Linux.Tests.csproj -c Release --no-build
+ dotnet test tests/TypeWhisper.Core.Tests/TypeWhisper.Core.Tests.csproj -c Release --no-build --blame-hang --blame-hang-timeout 5m --blame-hang-dump-type mini
+ dotnet test tests/TypeWhisper.PluginSystem.Tests/TypeWhisper.PluginSystem.Tests.csproj -c Release --no-build --blame-hang --blame-hang-timeout 5m --blame-hang-dump-type mini
+ dotnet test tests/TypeWhisper.Linux.Tests/TypeWhisper.Linux.Tests.csproj -c Release --no-build --blame-hang --blame-hang-timeout 5m --blame-hang-dump-type mini
+ dotnet test tests/TypeWhisper.Cli.Tests/TypeWhisper.Cli.Tests.csproj -c Release --no-build --blame-hang --blame-hang-timeout 5m --blame-hang-dump-type mini
- name: Build all Linux packages
run: bash scripts/build-linux-packages.sh "$VERSION" dist
+ - name: Smoke test all Linux packages
+ # Covers building the two prepared images as well as the smoke runs
+ # themselves; a throttled distro mirror has pushed the dependency
+ # download alone past twenty minutes.
+ timeout-minutes: 45
+ run: bash scripts/smoke-test-linux-packages.sh "$VERSION" dist
+
- uses: actions/upload-artifact@v7
with:
name: release-linux-${{ env.VERSION }}
diff --git a/README.md b/README.md
index 20fec10ad..ac3e44703 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ Press a key, talk, and have clean, punctuated text land in whatever app you're i
- **Personalization** — searchable history, a dictionary with term packs, snippets, and app/URL-matched profiles. History also has an opt-in **Inspect** panel that shows exactly what was sent to the LLM for each entry — the raw→final diff, the exact prompt, injected memory context, and the reply, with local-vs-cloud labelling. See [Profiles](https://github.com/csmashe/typewhisper-linux/wiki/Profiles) and [History](https://github.com/csmashe/typewhisper-linux/wiki/History).
- **Learns from your corrections** in the Wispr-Flow style — when you type over a dictated word in the target app to fix it, TypeWhisper silently learns the correction (via AT-SPI) and auto-applies it to future dictations. A brief toast shows what was learned and offers **Undo**. Off by default (it reads the focused field); enable it under [Dictation](https://github.com/csmashe/typewhisper-linux/wiki/Dictation) settings, and review or remove learned entries in the [Dictionary](https://github.com/csmashe/typewhisper-linux/wiki/Dictionary).
- **A localized interface** — English, German, Spanish, or Russian, switched live (or Auto, to follow your system locale). See [General Settings](https://github.com/csmashe/typewhisper-linux/wiki/General-Settings).
-- **Automation** — a local [HTTP API](https://github.com/csmashe/typewhisper-linux/wiki/HTTP-API) and a `typewhisper` [CLI](https://github.com/csmashe/typewhisper-linux/wiki/CLI) client (currently built from source; release packages don't yet bundle the CLI binary).
+- **Automation** — a local [HTTP API](https://github.com/csmashe/typewhisper-linux/wiki/HTTP-API) and an installable `typewhisper` [CLI](https://github.com/csmashe/typewhisper-linux/wiki/CLI).
- **Desktop integration** — tray icon, XDG autostart, single-instance handoff, and a user-level installer. See [Desktop Integration](https://github.com/csmashe/typewhisper-linux/wiki/Desktop-Integration).
Everything here is Linux-specific work adapted from the upstream macOS/Windows project: Wayland/X11 global hotkeys, compositor-native window and URL detection, session audio handling, and Linux packaging. The deep how-and-why for each lives in the wiki — start with [Wayland Notes](https://github.com/csmashe/typewhisper-linux/wiki/Wayland-Notes) if you're on Wayland.
diff --git a/TypeWhisper.slnx b/TypeWhisper.slnx
index 0f113e6c0..27f9f5455 100644
--- a/TypeWhisper.slnx
+++ b/TypeWhisper.slnx
@@ -11,4 +11,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs b/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs
index c3c15965a..d3c0bae41 100644
--- a/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs
+++ b/plugins/Shared/Cuda/CudaRuntimeProvisioner.cs
@@ -1,6 +1,10 @@
-using System.IO;
+// ReSharper disable MemberCanBePrivate.Global
+// ReSharper disable UnusedAutoPropertyAccessor.Global
+// ReSharper disable UnusedMember.Global
+// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces
+// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire.
+
using System.IO.Compression;
-using System.Net.Http;
using System.Runtime.InteropServices;
using System.Text.Json;
using TypeWhisper.Plugins.Shared.Net;
@@ -21,7 +25,7 @@ public enum CudaRuntimeProfile
/// (cudart, cuBLAS/cuBLASLt, cuFFT, cuRAND, cuDNN). Required by
/// sherpa-onnx's GPU build.
///
- OnnxRuntimeCuda
+ OnnxRuntimeCuda,
}
///
@@ -33,8 +37,8 @@ public enum CudaRuntimeProfile
/// can resolve their symbols.
///
/// GPU binaries are never bundled into the app packages — they are
-/// fetched here at first CUDA use and cached under
-/// ~/.local/share/TypeWhisper/Runtimes/cuda/<BundleVersion>.
+/// fetched here at first CUDA use and cached under the host-selected
+/// shared runtime root.
///
///
// Not sealed: tests subclass it with a fake that overrides EnsureReadyAsync (the dlopen
@@ -49,40 +53,43 @@ public class CudaRuntimeProvisioner
private const int RtldNow = 0x002;
private const int RtldGlobal = 0x100;
+ private static readonly TimeSpan s_defaultMaintenanceLockTimeout = TimeSpan.FromSeconds(30);
+ private static readonly TimeSpan s_provisioningLockAttempt = TimeSpan.FromMilliseconds(25);
+ private static readonly TimeSpan s_provisioningLockRetry = TimeSpan.FromMilliseconds(100);
// Each wheel maps a PyPI package@version to the sonames it must contribute.
// RequiredSonames are the libraries we both (a) check to decide whether the host
// already satisfies the wheel and (b) dlopen RTLD_GLOBAL so the no-rpath ORT
// CUDA provider resolves their symbols. We deliberately do NOT enumerate every
// companion .so a wheel ships — extraction pulls them all out flat, and they
- // resolve via the libraries' $ORIGIN runpath (see Cudnn below). Listing exact
+ // resolve via the libraries' $ORIGIN runpath (see s_cudnn below). Listing exact
// companions would couple us to a wheel's internal layout, which varies by
// version (e.g. cuDNN 9.x adds/removes engine sub-libs).
- private static readonly CudaWheel CudaRuntime = new(
+ private static readonly CudaWheel s_cudaRuntime = new(
"nvidia-cuda-runtime-cu12",
"12.9.79",
RequiredSonames: ["libcudart.so.12"]
);
- private static readonly CudaWheel Cublas = new(
+ private static readonly CudaWheel s_cublas = new(
"nvidia-cublas-cu12",
"12.9.2.10",
RequiredSonames: ["libcublasLt.so.12", "libcublas.so.12"]
);
- private static readonly CudaWheel Cufft = new(
+ private static readonly CudaWheel s_cufft = new(
"nvidia-cufft-cu12",
"11.4.1.4",
RequiredSonames: ["libcufft.so.11"]
);
- private static readonly CudaWheel Curand = new(
+ private static readonly CudaWheel s_curand = new(
"nvidia-curand-cu12",
"10.3.10.19",
RequiredSonames: ["libcurand.so.10"]
);
- private static readonly CudaWheel Cudnn = new(
+ private static readonly CudaWheel s_cudnn = new(
"nvidia-cudnn-cu12",
"9.22.0.52",
// Only the dispatcher is required. It dlopens its engine sub-libraries
@@ -102,16 +109,16 @@ public class CudaRuntimeProvisioner
// Pinned to the CUDA 12.9.1 nvrtc that pairs with cudart 12.9.79; its
// libnvrtc-builtins companion comes along in the flat extraction and resolves via
// $ORIGIN. Only sherpa-onnx's ORT/cuDNN path needs this, not whisper.cpp.
- private static readonly CudaWheel Nvrtc = new(
+ private static readonly CudaWheel s_nvrtc = new(
"nvidia-cuda-nvrtc-cu12",
"12.9.86",
RequiredSonames: ["libnvrtc.so.12"]
);
- private static readonly CudaWheel[] WhisperWheels = [CudaRuntime, Cublas];
+ private static readonly CudaWheel[] s_whisperWheels = [s_cudaRuntime, s_cublas];
- private static readonly CudaWheel[] OnnxRuntimeWheels =
- [CudaRuntime, Cublas, Cufft, Curand, Nvrtc, Cudnn];
+ private static readonly CudaWheel[] s_onnxRuntimeWheels =
+ [s_cudaRuntime, s_cublas, s_cufft, s_curand, s_nvrtc, s_cudnn];
private static readonly string[] s_systemLibraryDirectories = BuildSystemLibraryDirectories();
@@ -124,7 +131,7 @@ private static string[] BuildSystemLibraryDirectories()
var dirs = new List
{
"/usr/local/cuda/lib64",
- "/usr/local/cuda/targets/x86_64-linux/lib"
+ "/usr/local/cuda/targets/x86_64-linux/lib",
};
foreach (var minor in new[] { "9", "8", "7", "6", "5", "4", "3", "2", "1", "0" })
{
@@ -138,19 +145,59 @@ private static string[] BuildSystemLibraryDirectories()
private readonly HttpClient _httpClient;
private readonly Action? _log;
private readonly SemaphoreSlim _gate = new(1, 1);
- private readonly object _preloadSync = new();
+ private readonly Lock _preloadSync = new();
private readonly HashSet _preloaded = new(StringComparer.Ordinal);
+ private readonly string _cacheRoot;
+ private readonly string _maintenanceLockPath;
+ private readonly string _wheelLockDirectory;
+ private readonly string _legacyCacheRoot;
+ private readonly Action _moveDirectory;
+ private bool _legacyMigrationAttempted;
public CudaRuntimeProvisioner(string cacheRoot, HttpClient httpClient, Action? log = null)
+ : this(
+ cacheRoot,
+ httpClient,
+ log,
+ DefaultCacheRoot(),
+ Directory.Move
+ ) { }
+
+ internal CudaRuntimeProvisioner(
+ string cacheRoot,
+ HttpClient httpClient,
+ Action? log,
+ string legacyCacheRoot,
+ Action moveDirectory
+ )
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_log = log;
- CacheDirectory = Path.Join(cacheRoot, BundleVersion);
+ _moveDirectory = moveDirectory
+ ?? throw new ArgumentNullException(nameof(moveDirectory));
+
+ var cachePaths = ResolveCachePaths(cacheRoot, nameof(cacheRoot));
+ _cacheRoot = cachePaths.CacheRoot;
+ _maintenanceLockPath = cachePaths.MaintenanceLockPath;
+ _wheelLockDirectory = cachePaths.WheelLockDirectory;
+ CacheDirectory = Path.Join(_cacheRoot, BundleVersion);
+ _legacyCacheRoot = ResolveCachePaths(
+ legacyCacheRoot,
+ nameof(legacyCacheRoot)
+ ).CacheRoot;
}
/// Directory holding the downloaded CUDA .so files for this bundle version.
public string CacheDirectory { get; }
+ // Test seams: pin the lock paths and timeout so tests avoid a real per-user cache.
+ // ReSharper disable once ConvertToAutoPropertyWhenPossible -- the backing field is the real member, read throughout this class; these are read-only test seams over it.
+ internal string MaintenanceLockPathForTests => _maintenanceLockPath;
+ // ReSharper disable once ConvertToAutoPropertyWhenPossible -- the backing field is the real member, read throughout this class; these are read-only test seams over it.
+ internal string WheelLockDirectoryForTests => _wheelLockDirectory;
+ internal TimeSpan MaintenanceLockTimeoutForTests { get; init; } =
+ s_defaultMaintenanceLockTimeout;
+
///
/// The shared cache root both local engines use, so the CUDA math libraries
/// are downloaded once. Resolves to
@@ -164,8 +211,28 @@ public static string DefaultCacheRoot() =>
"cuda"
);
+ ///
+ /// Resolves the shared CUDA root from a host-provided per-plugin asset
+ /// directory. Host paths have the shape
+ /// <asset-root>/PluginData/<plugin-id>, so walking up
+ /// through the plugin and PluginData directories lets every engine select
+ /// the same <asset-root>/Runtimes/cuda sibling. Older hosts
+ /// and tests that provide no asset directory retain the legacy default.
+ ///
+ internal static string CacheRootForPluginAssetDirectory(string? pluginAssetDirectory)
+ {
+ if (string.IsNullOrWhiteSpace(pluginAssetDirectory))
+ return DefaultCacheRoot();
+
+ var pluginDirectory = new DirectoryInfo(pluginAssetDirectory);
+ var commonAssetRoot = pluginDirectory.Parent?.Parent;
+ return commonAssetRoot is null
+ ? DefaultCacheRoot()
+ : Path.Join(commonAssetRoot.FullName, "Runtimes", "cuda");
+ }
+
private static CudaWheel[] WheelsFor(CudaRuntimeProfile profile) =>
- profile == CudaRuntimeProfile.WhisperCublas ? WhisperWheels : OnnxRuntimeWheels;
+ profile == CudaRuntimeProfile.WhisperCublas ? s_whisperWheels : s_onnxRuntimeWheels;
///
/// True when every CUDA library the needs is
@@ -236,8 +303,8 @@ CancellationToken ct
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
- Directory.CreateDirectory(CacheDirectory);
- PruneStaleBundles();
+ await TryMigrateLegacyCacheAsync(ct).ConfigureAwait(false);
+ EnsureExternalLockDirectory();
// A wheel is fetched unless EVERY library it provides is already
// resolvable (on-system or in our cache). Checking only the primary
@@ -246,7 +313,16 @@ CancellationToken ct
// as complete and then fail at native session creation. cuBLAS in
// particular is a ~580 MB wheel we still skip when the host toolkit
// already ships its full set.
- var missing = wheels.Where(w => !IsWheelSatisfied(w)).ToList();
+ List missing;
+ await using (
+ await InterProcessFileLock
+ .AcquireAsync(_maintenanceLockPath, ct)
+ .ConfigureAwait(false)
+ )
+ {
+ Directory.CreateDirectory(CacheDirectory);
+ missing = wheels.Where(w => !IsWheelSatisfied(w)).ToList();
+ }
if (missing.Count > 0)
{
@@ -261,6 +337,10 @@ CancellationToken ct
_log?.Invoke("CUDA runtime: all required libraries already present.");
progress?.Report(1.0);
}
+
+ // Pruning is best-effort; run it after provisioning so two provisioners
+ // with disjoint wheel sets can still download/extract in parallel.
+ await PruneStaleBundlesAsync(ct).ConfigureAwait(false);
}
finally
{
@@ -268,6 +348,72 @@ CancellationToken ct
}
}
+ private async Task TryMigrateLegacyCacheAsync(CancellationToken ct)
+ {
+ if (_legacyMigrationAttempted)
+ return;
+
+ if (PathsEqual(_legacyCacheRoot, _cacheRoot)
+ || !Directory.Exists(_legacyCacheRoot)
+ || Directory.Exists(_cacheRoot))
+ {
+ _legacyMigrationAttempted = true;
+ return;
+ }
+
+ var legacyPaths = ResolveCachePaths(_legacyCacheRoot, nameof(_legacyCacheRoot));
+ try
+ {
+ // Protect the destination against another provisioner creating it while
+ // migration waits for the legacy cache. Each maintenance lease owns root
+ // -> every external wheel sentinel, so no active or starting provisioning
+ // batch can overlap the atomic move.
+ await using var destinationLocks = await AcquireMaintenanceLocksAsync(
+ _maintenanceLockPath,
+ _wheelLockDirectory,
+ "migrating the CUDA runtime cache",
+ ct
+ )
+ .ConfigureAwait(false);
+ await using var legacyLocks = await AcquireMaintenanceLocksAsync(
+ legacyPaths.MaintenanceLockPath,
+ legacyPaths.WheelLockDirectory,
+ "migrating the legacy CUDA runtime cache",
+ ct
+ )
+ .ConfigureAwait(false);
+
+ // Re-check under both roots' complete maintenance leases: another
+ // process may have migrated or provisioned while these locks were pending.
+ if (!Directory.Exists(_legacyCacheRoot) || Directory.Exists(_cacheRoot))
+ {
+ _legacyMigrationAttempted = true;
+ return;
+ }
+
+ _moveDirectory(_legacyCacheRoot, _cacheRoot);
+ _log?.Invoke(
+ $"CUDA runtime: migrated cache from {_legacyCacheRoot} to {_cacheRoot}."
+ );
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ // Best effort only. The old cache is never deleted here; a failed move
+ // falls through to normal provisioning at the configured destination.
+ _log?.Invoke(
+ $"CUDA runtime: could not migrate cache from {_legacyCacheRoot} "
+ + $"to {_cacheRoot}: {ex.Message} Leaving the old cache in place "
+ + "and provisioning at the configured location."
+ );
+ }
+
+ _legacyMigrationAttempted = true;
+ }
+
private async Task DownloadMissingAsync(
IReadOnlyList missing,
IProgress? progress,
@@ -285,30 +431,226 @@ CancellationToken ct
jobs.Add((wheel, url, size, sha256));
}
- var totalBytes = jobs.Sum(j => j.Size);
- long completedBytes = 0;
+ // Lock coupling: root -> every wheel needed by this batch, in stable path
+ // order. The root lock is then released while the wheel locks remain held
+ // through the full batch. Clear/prune take root -> every wheel, so they wait
+ // for an active batch and prevent a new one from starting. Provisioners with
+ // disjoint wheel sets retain their existing safe parallelism.
+ await using (await AcquireProvisioningWheelLocksAsync(missing, ct).ConfigureAwait(false))
+ {
+ // Clear may have run while PyPI metadata was resolving. Recreate the cache
+ // only after this batch owns its external wheel locks, so maintenance can
+ // no longer delete it until all of the batch's writes finish.
+ Directory.CreateDirectory(CacheDirectory);
+
+ var totalBytes = jobs.Sum(j => j.Size);
+ long completedBytes = 0;
+
+ foreach (var (wheel, url, size, sha256) in jobs)
+ {
+ var baseline = completedBytes;
+ var downloaded = await DownloadAndExtractWheelAsync(
+ wheel,
+ url,
+ sha256,
+ read =>
+ {
+ if (totalBytes > 0)
+ progress?.Report(Math.Min(1.0, (double)(baseline + read) / totalBytes));
+ },
+ ct
+ ).ConfigureAwait(false);
+ // Advance by the metadata size when known, else by what we actually read,
+ // so a wheel whose PyPI size was missing still moves the cumulative counter
+ // instead of stalling it at the previous baseline.
+ completedBytes += size > 0 ? size : downloaded;
+ }
+ }
+
+ progress?.Report(1.0);
+ }
- foreach (var (wheel, url, size, sha256) in jobs)
+ private async Task AcquireProvisioningWheelLocksAsync(
+ IReadOnlyList wheels,
+ CancellationToken ct
+ )
+ {
+ EnsureExternalLockDirectory();
+ var lockPaths = wheels
+ .Select(WheelLockPath)
+ .Distinct(StringComparer.Ordinal)
+ .Order(StringComparer.Ordinal)
+ .ToArray();
+
+ while (true)
{
- var baseline = completedBytes;
- var downloaded = await DownloadAndExtractWheelAsync(
- wheel,
- url,
- sha256,
- read =>
+ ct.ThrowIfCancellationRequested();
+ FileStream? rootLock = null;
+ var wheelLocks = new List(lockPaths.Length);
+ try
+ {
+ rootLock = await InterProcessFileLock
+ .AcquireAsync(_maintenanceLockPath, ct)
+ .ConfigureAwait(false);
+
+ foreach (var lockPath in lockPaths)
{
- if (totalBytes > 0)
- progress?.Report(Math.Min(1.0, (double)(baseline + read) / totalBytes));
- },
- ct
- ).ConfigureAwait(false);
- // Advance by the metadata size when known, else by what we actually read,
- // so a wheel whose PyPI size was missing still moves the cumulative counter
- // instead of stalling it at the previous baseline.
- completedBytes += size > 0 ? size : downloaded;
+ // Do not sit on the root lock behind a busy wheel: a short
+ // acquire attempt followed by a full retry lets a provisioner
+ // for a disjoint wheel (or maintenance) take the root meanwhile.
+ using var attemptCts =
+ CancellationTokenSource.CreateLinkedTokenSource(ct);
+ attemptCts.CancelAfter(s_provisioningLockAttempt);
+ wheelLocks.Add(
+ await InterProcessFileLock
+ .AcquireAsync(lockPath, attemptCts.Token)
+ .ConfigureAwait(false)
+ );
+ }
+
+ return new ExternalLockLease(wheelLocks);
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ await DisposeLocksAsync(wheelLocks).ConfigureAwait(false);
+ }
+ catch
+ {
+ await DisposeLocksAsync(wheelLocks).ConfigureAwait(false);
+ throw;
+ }
+ finally
+ {
+ if (rootLock is not null)
+ await rootLock.DisposeAsync().ConfigureAwait(false);
+ }
+
+ await Task.Delay(s_provisioningLockRetry, ct).ConfigureAwait(false);
}
+ }
- progress?.Report(1.0);
+ private Task AcquireMaintenanceLocksAsync(
+ string operation,
+ CancellationToken ct
+ ) =>
+ AcquireMaintenanceLocksAsync(
+ _maintenanceLockPath,
+ _wheelLockDirectory,
+ operation,
+ ct
+ );
+
+ private async Task AcquireMaintenanceLocksAsync(
+ string maintenanceLockPath,
+ string wheelLockDirectory,
+ string operation,
+ CancellationToken ct
+ )
+ {
+ EnsureExternalLockDirectory(wheelLockDirectory);
+ using var timeoutCts = new CancellationTokenSource(MaintenanceLockTimeoutForTests);
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
+ ct,
+ timeoutCts.Token
+ );
+ var acquired = new List();
+
+ try
+ {
+ acquired.Add(
+ await InterProcessFileLock
+ .AcquireAsync(maintenanceLockPath, linkedCts.Token)
+ .ConfigureAwait(false)
+ );
+
+ // Root is held before this enumeration, so no provisioner can add a new
+ // wheel sentinel after the snapshot. Include the known wheel set plus
+ // existing sentinels, for forward compatibility with bundle/package changes.
+ var wheelLockPaths = s_onnxRuntimeWheels
+ .Select(wheel => WheelLockPath(wheel, wheelLockDirectory))
+ .Concat(Directory.EnumerateFiles(wheelLockDirectory, "*.lock"))
+ .Distinct(StringComparer.Ordinal)
+ .Order(StringComparer.Ordinal);
+ foreach (var lockPath in wheelLockPaths)
+ {
+ acquired.Add(
+ await InterProcessFileLock
+ .AcquireAsync(lockPath, linkedCts.Token)
+ .ConfigureAwait(false)
+ );
+ }
+
+ return new ExternalLockLease(acquired);
+ }
+ catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
+ {
+ await DisposeLocksAsync(acquired).ConfigureAwait(false);
+ throw new TimeoutException(
+ $"Timed out waiting for another CUDA cache operation before {operation}.",
+ ex
+ );
+ }
+ catch
+ {
+ await DisposeLocksAsync(acquired).ConfigureAwait(false);
+ throw;
+ }
+ }
+
+ private string WheelLockPath(CudaWheel wheel) =>
+ WheelLockPath(wheel, _wheelLockDirectory);
+
+ private static string WheelLockPath(CudaWheel wheel, string wheelLockDirectory) =>
+ Path.Join(wheelLockDirectory, wheel.Package + ".lock");
+
+ private void EnsureExternalLockDirectory() =>
+ EnsureExternalLockDirectory(_wheelLockDirectory);
+
+ private static void EnsureExternalLockDirectory(string wheelLockDirectory) =>
+ Directory.CreateDirectory(wheelLockDirectory);
+
+ private static CachePaths ResolveCachePaths(string cacheRoot, string parameterName)
+ {
+ var cacheRootDirectory = Directory.GetParent(Path.Join(cacheRoot, BundleVersion))
+ ?? throw new ArgumentException(
+ "The CUDA cache root must have a parent directory.",
+ parameterName
+ );
+ var cacheParent = cacheRootDirectory.Parent
+ ?? throw new ArgumentException(
+ "The CUDA cache root must not be a filesystem root.",
+ parameterName
+ );
+ return new CachePaths(
+ cacheRootDirectory.FullName,
+ Path.Join(
+ cacheParent.FullName,
+ cacheRootDirectory.Name + ".maintenance.lock"
+ ),
+ Path.Join(
+ cacheParent.FullName,
+ cacheRootDirectory.Name + ".locks"
+ )
+ );
+ }
+
+ private static bool PathsEqual(string left, string right) =>
+ string.Equals(
+ Path.GetFullPath(left).TrimEnd(
+ Path.DirectorySeparatorChar,
+ Path.AltDirectorySeparatorChar
+ ),
+ Path.GetFullPath(right).TrimEnd(
+ Path.DirectorySeparatorChar,
+ Path.AltDirectorySeparatorChar
+ ),
+ StringComparison.Ordinal
+ );
+
+ private static async ValueTask DisposeLocksAsync(List locks)
+ {
+ for (var i = locks.Count - 1; i >= 0; i--)
+ await locks[i].DisposeAsync().ConfigureAwait(false);
}
private async Task<(string Url, long Size, string Sha256)> ResolveWheelAsync(
@@ -329,6 +671,7 @@ CancellationToken ct
// The single linux x64 wheel: a manylinux build for x86_64. Excludes
// win_amd64 and aarch64. The exact glibc tag (2_17 vs 2_27) varies per
// package, so match on the platform family rather than a fixed tag.
+ // ReSharper disable once MoveLocalFunctionAfterJumpStatement -- local function kept near its point of use for readability.
static bool IsLinuxX64Wheel(JsonElement entry)
{
if (entry.TryGetProperty("packagetype", out var pkgType)
@@ -382,14 +725,10 @@ CancellationToken ct
)
{
// Per-package staging name so two wheels never share a .partial and a dropped
- // download resumes via Range. The shared cache means two provisioners (or two app
- // processes) can pick the same path, which the per-instance _gate doesn't cover —
- // so guard it with a cross-process lock (see InterProcessFileLock).
+ // download resumes via Range. AcquireProvisioningWheelLocksAsync already holds
+ // this package's stable EXTERNAL sentinel for the full provisioning batch.
var wheelPath = Path.Join(CacheDirectory, $"{wheel.Package}.whl");
- await using var stagingLock =
- await InterProcessFileLock.AcquireAsync(wheelPath + ".lock", ct).ConfigureAwait(false);
-
// A sibling that held the lock first may have just completed this wheel — re-check
// so we don't redundantly re-fetch a hundreds-of-MB wheel.
if (IsWheelSatisfied(wheel))
@@ -485,6 +824,7 @@ internal void ExtractSharedObjects(string wheelPath)
{
// Keep only the shared objects under nvidia//lib/, skipping
// directory entries, Python stubs, headers, and metadata.
+ // ReSharper disable once MoveLocalFunctionAfterJumpStatement -- local function kept near its point of use for readability.
static bool IsLibEntry(ZipArchiveEntry entry) =>
!entry.FullName.EndsWith('/')
&& entry.FullName.Contains("/lib/", StringComparison.Ordinal)
@@ -601,10 +941,11 @@ private bool IsWheelSatisfied(CudaWheel wheel)
// dev box with the CUDA toolkit installed would otherwise satisfy every wheel and skip
// the download/extract/marker path under test. Null = production behavior (real system
// dirs + ldconfig). Only consulted here; PreloadAll's path resolution is untouched.
- internal Func? SystemLibraryProbeForTests { get; set; }
+ internal Func? SystemLibraryProbeForTests { get; init; }
private bool IsResolvableOnSystem(string soname)
{
+ // ReSharper disable once InlineTemporaryVariable -- the pattern binding carries the non-null narrowing; inlining it reads worse.
if (SystemLibraryProbeForTests is { } probe)
return probe(soname);
@@ -651,7 +992,7 @@ private static bool LdConfigContains(string soname)
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
- CreateNoWindow = true
+ CreateNoWindow = true,
}
);
@@ -680,36 +1021,44 @@ private static bool LdConfigContains(string soname)
/// Deletes the entire shared CUDA cache root (the parent of
/// — every bundle version, not just the current
/// one) so the next re-downloads from scratch.
- /// Guarded by the same gate as provisioning so it can't race an in-flight
- /// download — and awaits the gate with so a cancel isn't
- /// stuck behind that download. A missing cache is a no-op (already clear); a
- /// delete failure is logged and rethrown so the caller can surface it rather than
- /// report a corrupt runtime as repaired. Note: libraries already dlopen'd this
- /// process are held until exit, so a restart is required for a fresh re-provision
- /// to take effect.
+ /// The per-instance gate is layered with a bounded, cross-process maintenance
+ /// lock outside the deleted tree. Maintenance owns root -> every external wheel
+ /// sentinel through the full delete window, so it cannot unlink a live sentinel
+ /// or race another provisioner. A missing cache is a no-op (already clear); a
+ /// timeout or delete failure is logged and rethrown so the caller can surface it
+ /// rather than report a corrupt runtime as repaired. Note: libraries already
+ /// dlopen'd this process are held until exit, so a restart is required for a fresh
+ /// re-provision to take effect.
///
public async Task ClearCacheAsync(CancellationToken ct)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
- var root = Directory.GetParent(CacheDirectory)?.FullName;
- if (root is null || !Directory.Exists(root))
- return;
-
- try
+ await using (
+ await AcquireMaintenanceLocksAsync(
+ "clearing the CUDA runtime cache",
+ ct
+ )
+ .ConfigureAwait(false)
+ )
{
- Directory.Delete(root, recursive: true);
- _log?.Invoke($"CUDA runtime: cleared cache at {root}.");
- }
- catch (Exception ex)
- {
- // Don't swallow: the caller reports "cleared" to the user only when the
- // cache is actually gone, so a corrupt runtime can't masquerade as repaired.
- _log?.Invoke($"CUDA runtime: failed to clear cache at {root}: {ex.Message}");
- throw;
+ if (!Directory.Exists(_cacheRoot))
+ return;
+
+ Directory.Delete(_cacheRoot, recursive: true);
+ _log?.Invoke($"CUDA runtime: cleared cache at {_cacheRoot}.");
}
}
+ catch (Exception ex)
+ {
+ // Don't swallow: the caller reports "cleared" to the user only when the
+ // cache is actually gone, so a corrupt runtime can't masquerade as repaired.
+ _log?.Invoke(
+ $"CUDA runtime: failed to clear cache at {_cacheRoot}: {ex.Message}"
+ );
+ throw;
+ }
finally
{
_gate.Release();
@@ -720,24 +1069,48 @@ public async Task ClearCacheAsync(CancellationToken ct)
// don't accumulate (cuDNN alone is ~1.7 GB unpacked).
// internal so a unit test can assert a different-version sibling dir is deleted while
// the current version's dir is kept.
- internal void PruneStaleBundles()
+ internal void PruneStaleBundles() =>
+ PruneStaleBundlesAsync(CancellationToken.None).GetAwaiter().GetResult();
+
+ private async Task PruneStaleBundlesAsync(CancellationToken ct)
{
try
{
- var parent = Directory.GetParent(CacheDirectory);
- if (parent is null || !parent.Exists)
- return;
-
- foreach (var dir in parent.EnumerateDirectories()
- .Where(dir => !string.Equals(dir.Name, BundleVersion, StringComparison.Ordinal)))
+ await using (
+ await AcquireMaintenanceLocksAsync(
+ "pruning stale CUDA runtime bundles",
+ ct
+ )
+ .ConfigureAwait(false)
+ )
{
- try { dir.Delete(recursive: true); }
- catch { /* best effort cleanup */ }
+ var parent = new DirectoryInfo(_cacheRoot);
+ if (!parent.Exists)
+ return;
+
+ foreach (var dir in parent.EnumerateDirectories()
+ .Where(dir =>
+ !string.Equals(dir.Name, BundleVersion, StringComparison.Ordinal)))
+ {
+ try { dir.Delete(recursive: true); }
+ catch { /* best effort cleanup */ }
+ }
}
}
- catch
+ catch (TimeoutException ex)
+ {
+ // Cleanup is best-effort; provisioning continues with an explicit reason.
+ _log?.Invoke($"CUDA runtime: skipped stale-bundle pruning: {ex.Message}");
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
- // Cleanup is best-effort; never block provisioning on it.
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _log?.Invoke(
+ $"CUDA runtime: skipped stale-bundle pruning because locking failed: {ex.Message}"
+ );
}
}
@@ -790,6 +1163,10 @@ internal static bool TryInitializeCudaDriver(out string? error)
}
}
+ // Kept as DllImport: this file is shared-compiled into several plugin projects, so
+ // LibraryImport's generated string marshalling would require AllowUnsafeBlocks in every
+ // consumer. CharSet.Ansi marshals as UTF-8 on Linux — correct for these libc/libcuda paths.
+#pragma warning disable SYSLIB1054, CA2101
[DllImport("libcuda.so.1", EntryPoint = "cuInit")]
private static extern int cuInit(uint flags);
@@ -798,10 +1175,22 @@ internal static bool TryInitializeCudaDriver(out string? error)
[DllImport("libdl.so.2")]
private static extern IntPtr dlerror();
+#pragma warning restore SYSLIB1054, CA2101
+
+ private sealed class ExternalLockLease(List locks) : IAsyncDisposable
+ {
+ public ValueTask DisposeAsync() => DisposeLocksAsync(locks);
+ }
private sealed record CudaWheel(
string Package,
string Version,
string[] RequiredSonames
);
+
+ private sealed record CachePaths(
+ string CacheRoot,
+ string MaintenanceLockPath,
+ string WheelLockDirectory
+ );
}
diff --git a/plugins/Shared/Net/InterProcessFileLock.cs b/plugins/Shared/Net/InterProcessFileLock.cs
index 9aab05ce8..f8b3cc340 100644
--- a/plugins/Shared/Net/InterProcessFileLock.cs
+++ b/plugins/Shared/Net/InterProcessFileLock.cs
@@ -9,7 +9,7 @@ namespace TypeWhisper.Plugins.Shared.Net;
/// A cross-process advisory lock built on an exclusively-opened sentinel file.
///
/// The on-demand GPU artifacts stage into stable paths in a SHARED
-/// cache so a dropped download can resume (see ).
+/// cache so a dropped download can resume (see ResilientDownloader).
/// A stable path means two writers can pick the same file, and the per-engine
/// SemaphoreSlim gates only serialize within one provisioner instance —
/// not the two file-linked copies of the CUDA provisioner in different plugin
@@ -20,7 +20,7 @@ namespace TypeWhisper.Plugins.Shared.Net;
///
///
/// Compiled into each plugin assembly via file-linking, so the type is
-/// internal (like ).
+/// internal (like ResilientDownloader).
///
///
internal static class InterProcessFileLock
diff --git a/plugins/Shared/Net/ResilientDownloader.cs b/plugins/Shared/Net/ResilientDownloader.cs
index 5c906fdf8..4b12e6e92 100644
--- a/plugins/Shared/Net/ResilientDownloader.cs
+++ b/plugins/Shared/Net/ResilientDownloader.cs
@@ -67,7 +67,8 @@ internal static class ResilientDownloader
///
///
/// Caller integrity check run on the completed partial before the
- /// atomic move; it must throw on mismatch. Required when resuming.
+ /// atomic move; it must throw on mismatch. Required when resuming, or when
+ /// the server omits an exact total (Content-Length/Content-Range).
///
/// Cancellation for the whole operation (user cancel).
public static async Task DownloadToFileAsync(
@@ -202,8 +203,14 @@ public static async Task DownloadToFileAsync(
}
}
- // Only a server-declared total gates here (approxTotalBytes never does); a
- // missing total falls through to verifyComplete rather than a false incomplete.
+ // approxTotalBytes never gates completeness. Without a declared total, a
+ // verifier is mandatory: clean EOF alone can't prove the object ended.
+ if (declaredTotal is null && verifyComplete is null)
+ throw new DownloadIncompleteException(
+ "Download cannot be verified: the server did not declare an exact "
+ + "total length and the caller supplied no completion verifier."
+ );
+
if (onDisk < declaredTotal)
throw new DownloadIncompleteException(
$"Download incomplete: wrote {onDisk} of {declaredTotal.Value} "
@@ -264,8 +271,8 @@ private static void TryDelete(string path)
internal sealed class DownloadStalledException(string message) : Exception(message);
///
-/// Thrown when the server declared a total length (Content-Length on a 200,
-/// Content-Range total on a 206) but the body ended before that many bytes
-/// arrived.
+/// Thrown when the body ended before a server-declared total length
+/// (Content-Length on a 200, Content-Range total on a 206), or when neither
+/// a total nor a caller verifier can establish completeness.
///
internal sealed class DownloadIncompleteException(string message) : Exception(message);
diff --git a/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiPlugin.cs b/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiPlugin.cs
index 050419bd7..d53f66229 100644
--- a/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiPlugin.cs
+++ b/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiPlugin.cs
@@ -1,4 +1,9 @@
-using System.Net.Http;
+// ReSharper disable MemberCanBePrivate.Global
+// ReSharper disable UnusedMember.Global
+// ReSharper disable UnusedType.Global
+// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces
+// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire.
+
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
@@ -7,16 +12,14 @@
namespace TypeWhisper.Plugin.AssemblyAi;
-public sealed partial class AssemblyAiPlugin : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware
+public sealed class AssemblyAiPlugin : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware
{
private const string BaseUrl = "https://api.assemblyai.com";
private readonly HttpClient _httpClient = new();
private IPluginHostServices? _host;
- private string? _apiKey;
- private string? _selectedModelId;
- private static readonly IReadOnlyList Models =
+ private static readonly IReadOnlyList s_models =
[
new("universal-3-pro", "Universal-3 Pro"),
new("universal-2", "Universal-2"),
@@ -29,8 +32,8 @@ public sealed partial class AssemblyAiPlugin : ITranscriptionEnginePlugin, IPlug
public async Task ActivateAsync(IPluginHostServices host)
{
_host = host;
- _apiKey = await host.LoadSecretAsync("api-key");
- _selectedModelId = host.GetSetting("selectedModel") ?? Models[0].Id;
+ ApiKey = await host.LoadSecretAsync("api-key");
+ SelectedModelId = host.GetSetting("selectedModel") ?? s_models[0].Id;
host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured})");
}
@@ -42,11 +45,11 @@ public Task DeactivateAsync()
public string ProviderId => "assemblyai";
public string ProviderDisplayName => "AssemblyAI";
- public bool IsConfigured => !string.IsNullOrEmpty(_apiKey);
+ public bool IsConfigured => !string.IsNullOrEmpty(ApiKey);
- public IReadOnlyList TranscriptionModels => Models;
+ public IReadOnlyList TranscriptionModels => s_models;
- public string? SelectedModelId => _selectedModelId;
+ public string? SelectedModelId { get; private set; }
public bool SupportsTranslation => false;
public bool SupportsStreaming => true;
@@ -55,14 +58,14 @@ public async Task StartStreamingAsync(string? language, Cance
{
if (!IsConfigured)
throw new InvalidOperationException(Loc.L("Settings.NotConfiguredApiKeyRequired"));
- return await AssemblyAiStreamingSession.ConnectAsync(_apiKey!, language, ct);
+ return await AssemblyAiStreamingSession.ConnectAsync(ApiKey!, language, ct);
}
public void SelectModel(string modelId)
{
- if (Models.All(m => m.Id != modelId))
+ if (s_models.All(m => m.Id != modelId))
throw new ArgumentException($"Unknown model: {modelId}");
- _selectedModelId = modelId;
+ SelectedModelId = modelId;
_host?.SetSetting("selectedModel", modelId);
}
@@ -74,7 +77,7 @@ public async Task TranscribeAsync(
CancellationToken ct
)
{
- if (!IsConfigured || _selectedModelId is null)
+ if (!IsConfigured || SelectedModelId is null)
throw new InvalidOperationException(
"Plugin not configured. API key and model required."
);
@@ -87,7 +90,7 @@ CancellationToken ct
private async Task UploadAudioAsync(byte[] wavAudio, CancellationToken ct)
{
using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v2/upload");
- request.Headers.Add("Authorization", _apiKey);
+ request.Headers.Add("Authorization", ApiKey);
request.Content = new ByteArrayContent(wavAudio);
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
@@ -113,7 +116,7 @@ CancellationToken ct
var body = new Dictionary
{
["audio_url"] = audioUrl,
- ["speech_models"] = new[] { _selectedModelId! },
+ ["speech_models"] = new[] { SelectedModelId! },
};
if (string.IsNullOrEmpty(language) || language == "auto")
@@ -122,7 +125,7 @@ CancellationToken ct
body["language_code"] = language;
using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v2/transcript");
- request.Headers.Add("Authorization", _apiKey);
+ request.Headers.Add("Authorization", ApiKey);
request.Content = new StringContent(
JsonSerializer.Serialize(body),
Encoding.UTF8,
@@ -156,7 +159,7 @@ CancellationToken ct
HttpMethod.Get,
$"{BaseUrl}/v2/transcript/{transcriptId}"
);
- request.Headers.Add("Authorization", _apiKey);
+ request.Headers.Add("Authorization", ApiKey);
var response = await _httpClient.SendAsync(request, ct);
var json = await response.Content.ReadAsStringAsync(ct);
@@ -170,6 +173,7 @@ CancellationToken ct
var root = doc.RootElement;
var status = root.GetProperty("status").GetString();
+ // ReSharper disable once ConvertIfStatementToSwitchStatement -- subjective control-flow style; the if-chain reads fine here.
if (status == "error")
{
var error = root.TryGetProperty("error", out var errEl)
@@ -178,13 +182,14 @@ CancellationToken ct
throw new InvalidOperationException($"AssemblyAI transcription failed: {error}");
}
+ // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is.
if (status == "completed")
{
var text = root.GetProperty("text").GetString() ?? "";
var duration = root.TryGetProperty("audio_duration", out var durEl)
? durEl.GetDouble()
: 0.0;
- string? detectedLanguage = root.TryGetProperty("language_code", out var langEl)
+ var detectedLanguage = root.TryGetProperty("language_code", out var langEl)
? langEl.GetString()
: null;
return new PluginTranscriptionResult(
@@ -199,7 +204,8 @@ CancellationToken ct
throw new TimeoutException("AssemblyAI transcription timed out after 5 minutes");
}
- internal string? ApiKey => _apiKey;
+ internal string? ApiKey { get; private set; }
+
private IPluginLocalization? _injectedLocalization;
public void SetLocalization(IPluginLocalization localization) =>
@@ -212,7 +218,7 @@ public void SetLocalization(IPluginLocalization localization) =>
internal async Task SetApiKeyAsync(string apiKey)
{
- _apiKey = string.IsNullOrWhiteSpace(apiKey) ? null : apiKey;
+ ApiKey = string.IsNullOrWhiteSpace(apiKey) ? null : apiKey;
if (_host is not null)
{
if (string.IsNullOrWhiteSpace(apiKey))
@@ -260,7 +266,7 @@ public IReadOnlyList GetSettingDefinitions() =>
"selectedModel",
Loc.L("Settings.TranscriptionModel"),
Description: Loc.L("Settings.ModelDescription"),
- Options: Models.Select(m => new PluginSettingOption(m.Id, m.DisplayName)).ToList()
+ Options: s_models.Select(m => new PluginSettingOption(m.Id, m.DisplayName)).ToList()
),
];
@@ -268,8 +274,8 @@ public IReadOnlyList GetSettingDefinitions() =>
Task.FromResult(
key switch
{
- "api-key" => _apiKey,
- "selectedModel" => _selectedModelId,
+ "api-key" => ApiKey,
+ "selectedModel" => SelectedModelId,
_ => null,
}
);
@@ -294,10 +300,10 @@ public async Task SetSettingValueAsync(
public async Task ValidateAsync(CancellationToken ct = default)
{
- if (string.IsNullOrWhiteSpace(_apiKey))
+ if (string.IsNullOrWhiteSpace(ApiKey))
return new PluginSettingsValidationResult(false, Loc.L("Settings.EnterApiKey"));
- var valid = await ValidateApiKeyAsync(_apiKey, ct);
+ var valid = await ValidateApiKeyAsync(ApiKey, ct);
return valid
? new PluginSettingsValidationResult(true, Loc.L("Settings.ApiKeyValid"))
: new PluginSettingsValidationResult(false, Loc.L("Settings.ApiKeyInvalid"));
diff --git a/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiStreamingSession.cs b/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiStreamingSession.cs
index 15e472bac..54ac6e32b 100644
--- a/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiStreamingSession.cs
+++ b/plugins/TypeWhisper.Plugin.AssemblyAi/AssemblyAiStreamingSession.cs
@@ -1,5 +1,6 @@
-using System.IO;
+using System.Diagnostics;
using System.Net.WebSockets;
+using System.Runtime.ExceptionServices;
using System.Text;
using System.Text.Json;
using TypeWhisper.PluginSDK;
@@ -8,14 +9,25 @@ namespace TypeWhisper.Plugin.AssemblyAi;
internal sealed class AssemblyAiStreamingSession : IStreamingSession
{
- private readonly ClientWebSocket _ws = new();
+ private readonly WebSocket _ws;
private readonly CancellationTokenSource _receiveCts = new();
- private Task? _receiveTask;
+ private readonly TaskCompletionSource _terminalCompletion =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private readonly MemoryStream _audioBuffer = new();
+ private Exception? _sessionFault;
+ private readonly Task? _receiveTask;
+ private int _terminationReceived;
+ private bool _disposed;
// AssemblyAI requires chunks between 50-1000ms (800-16000 samples at 16kHz = 1600-32000 bytes)
- private readonly MemoryStream _audioBuffer = new();
private const int MinChunkBytes = 1600; // 50ms at 16kHz, 16-bit
+ internal AssemblyAiStreamingSession(WebSocket ws)
+ {
+ _ws = ws;
+ _receiveTask = ReceiveLoopAsync(_receiveCts.Token);
+ }
+
public event Action? TranscriptReceived;
public static async Task ConnectAsync(
@@ -24,7 +36,7 @@ public static async Task ConnectAsync(
CancellationToken ct
)
{
- var session = new AssemblyAiStreamingSession();
+ var ws = new ClientWebSocket();
var url = "wss://streaming.assemblyai.com/v3/ws?sample_rate=16000&format_turns=true";
// The default streaming model is English-only; opt into the multilingual
@@ -32,36 +44,102 @@ CancellationToken ct
// so locale variants like "en-US" stay on the English model.
if (!string.IsNullOrEmpty(language)
&& !language.StartsWith("en", StringComparison.OrdinalIgnoreCase))
+ {
url += "&speech_model=universal-streaming-multilingual";
+ }
- session._ws.Options.SetRequestHeader("Authorization", apiKey);
- await session._ws.ConnectAsync(new Uri(url), ct);
- session._receiveTask = session.ReceiveLoopAsync(session._receiveCts.Token);
- return session;
+ ws.Options.SetRequestHeader("Authorization", apiKey);
+ try
+ {
+ await ws.ConnectAsync(new Uri(url), ct);
+ return new AssemblyAiStreamingSession(ws);
+ }
+ catch
+ {
+ ws.Dispose();
+ throw;
+ }
}
public async Task SendAudioAsync(ReadOnlyMemory pcm16Audio, CancellationToken ct)
{
+ if (_disposed)
+ return;
+
+ ThrowIfFaulted();
if (_ws.State != WebSocketState.Open)
+ {
+ ThrowIfClosedBeforeTermination();
return;
+ }
_audioBuffer.Write(pcm16Audio.Span);
+ // A residual smaller than MinChunkBytes is deliberately left unflushed
+ // here; flushing it is unchanged and out of scope for this change.
if (_audioBuffer.Length < MinChunkBytes)
return;
var chunk = _audioBuffer.ToArray();
_audioBuffer.SetLength(0);
- await _ws.SendAsync(chunk, WebSocketMessageType.Binary, true, ct);
+ try
+ {
+ await _ws.SendAsync(chunk, WebSocketMessageType.Binary, true, ct);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ CaptureFault(
+ new InvalidOperationException("AssemblyAI streaming audio send failed.", ex)
+ );
+ throw;
+ }
}
public async Task FinalizeAsync(CancellationToken ct)
{
- if (_ws.State != WebSocketState.Open)
+ if (_disposed)
return;
- var msg = Encoding.UTF8.GetBytes("""{"terminate_session":true}""");
- await _ws.SendAsync(msg, WebSocketMessageType.Text, true, ct);
+
+ ThrowIfFaulted();
+ if (Volatile.Read(ref _terminationReceived) == 0)
+ {
+ if (_ws.State != WebSocketState.Open)
+ {
+ ThrowIfClosedBeforeTermination();
+ return;
+ }
+
+ var msg = """{"type":"Terminate"}"""u8.ToArray();
+ try
+ {
+ await _ws.SendAsync(msg, WebSocketMessageType.Text, true, ct);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ CaptureFault(
+ new InvalidOperationException(
+ "AssemblyAI streaming termination send failed.",
+ ex
+ )
+ );
+ throw;
+ }
+ }
+
+ // The coordinator supplies the finalization deadline through ct. Do not
+ // turn that cancellation into success: it must remain able to select the
+ // complete-WAV batch fallback.
+ await _terminalCompletion.Task.WaitAsync(ct);
+ ThrowIfFaulted();
}
private async Task ReceiveLoopAsync(CancellationToken ct)
@@ -71,7 +149,7 @@ private async Task ReceiveLoopAsync(CancellationToken ct)
try
{
- while (!ct.IsCancellationRequested && _ws.State == WebSocketState.Open)
+ while (true)
{
messageBuffer.SetLength(0);
WebSocketReceiveResult result;
@@ -79,7 +157,11 @@ private async Task ReceiveLoopAsync(CancellationToken ct)
{
result = await _ws.ReceiveAsync(buffer, ct);
if (result.MessageType == WebSocketMessageType.Close)
+ {
+ CaptureFault(CreatePrematureCloseException(result));
return;
+ }
+
messageBuffer.Write(buffer, 0, result.Count);
} while (!result.EndOfMessage);
@@ -91,42 +173,204 @@ private async Task ReceiveLoopAsync(CancellationToken ct)
0,
(int)messageBuffer.Length
);
- ParseAndEmit(json);
+ ProcessMessage(json);
+
+ if (Volatile.Read(ref _terminationReceived) != 0)
+ return;
+ }
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ // DisposeAsync owns this token. Local teardown is not a stream fault.
+ }
+ catch (OperationCanceledException ex)
+ {
+ CaptureFault(
+ new InvalidOperationException("AssemblyAI streaming receive was canceled.", ex)
+ );
+ }
+ catch (WebSocketException ex)
+ {
+ CaptureFault(
+ new InvalidOperationException("AssemblyAI streaming transport failed.", ex)
+ );
+ }
+ catch (JsonException ex)
+ {
+ CaptureFault(
+ new InvalidOperationException("AssemblyAI sent malformed JSON.", ex)
+ );
+ }
+ catch (InvalidOperationException ex)
+ {
+ CaptureFault(ex);
+ }
+ catch (Exception ex)
+ {
+ CaptureFault(
+ new InvalidOperationException("AssemblyAI streaming receive failed.", ex)
+ );
+ }
+ finally
+ {
+ if (ct.IsCancellationRequested)
+ {
+ _terminalCompletion.TrySetResult();
+ }
+ else if (
+ Volatile.Read(ref _terminationReceived) == 0
+ && Volatile.Read(ref _sessionFault) is null
+ )
+ {
+ CaptureFault(
+ new InvalidOperationException(
+ "AssemblyAI streaming receive ended before Termination."
+ )
+ );
}
}
- catch (OperationCanceledException) { }
- catch (WebSocketException) { }
}
- private void ParseAndEmit(string json)
+ private void ProcessMessage(string json)
{
- try
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ if (
+ root.ValueKind != JsonValueKind.Object
+ || !root.TryGetProperty("type", out var typeEl)
+ || typeEl.ValueKind != JsonValueKind.String
+ )
{
- using var doc = JsonDocument.Parse(json);
- var root = doc.RootElement;
+ throw new InvalidOperationException(
+ "AssemblyAI sent a malformed streaming message."
+ );
+ }
- if (!root.TryGetProperty("type", out var typeEl) || typeEl.GetString() != "Turn")
- return;
+ switch (typeEl.GetString())
+ {
+ case "Turn":
+ ProcessTurn(root);
+ break;
+ case "Termination":
+ Volatile.Write(ref _terminationReceived, 1);
+ _terminalCompletion.TrySetResult();
+ break;
+ case "Error":
+ throw new InvalidOperationException(
+ $"AssemblyAI streaming provider error: {ExtractError(root)}"
+ );
+ }
+ }
- var transcript = root.TryGetProperty("transcript", out var textEl)
- ? textEl.GetString() ?? ""
- : "";
+ private void ProcessTurn(JsonElement root)
+ {
+ if (
+ !root.TryGetProperty("transcript", out var textEl)
+ || textEl.ValueKind != JsonValueKind.String
+ )
+ {
+ throw new InvalidOperationException("AssemblyAI sent a malformed Turn message.");
+ }
- if (string.IsNullOrWhiteSpace(transcript))
- return;
+ var transcript = textEl.GetString() ?? "";
+ if (string.IsNullOrWhiteSpace(transcript))
+ return;
- var isFinal = root.TryGetProperty("end_of_turn", out var eotEl) && eotEl.GetBoolean();
+ var isEndOfTurn =
+ root.TryGetProperty("end_of_turn", out var eotEl)
+ && eotEl.ValueKind is JsonValueKind.True or JsonValueKind.False
+ && eotEl.GetBoolean();
+ var isFormatted =
+ root.TryGetProperty("turn_is_formatted", out var formattedEl)
+ && formattedEl.ValueKind is JsonValueKind.True or JsonValueKind.False
+ && formattedEl.GetBoolean();
- TranscriptReceived?.Invoke(new StreamingTranscriptEvent(transcript, isFinal));
+ // With format_turns=true AssemblyAI sends an unformatted end-of-turn
+ // message followed by the formatted replacement. Expose the former only
+ // as interim text and commit the formatted terminal turn exactly once.
+ Emit(new StreamingTranscriptEvent(transcript, isEndOfTurn && isFormatted));
+ }
+
+ private void Emit(StreamingTranscriptEvent transcriptEvent)
+ {
+ try
+ {
+ TranscriptReceived?.Invoke(transcriptEvent);
}
- catch
- { /* malformed message, skip */
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"AssemblyAI streaming subscriber failed: {ex.Message}");
}
}
+ private static string ExtractError(JsonElement root)
+ {
+ foreach (var propertyName in new[] { "error", "message", "detail" })
+ {
+ if (
+ root.TryGetProperty(propertyName, out var property)
+ && property.ValueKind == JsonValueKind.String
+ && !string.IsNullOrWhiteSpace(property.GetString())
+ )
+ {
+ return property.GetString()!;
+ }
+ }
+
+ return "Unknown provider error.";
+ }
+
+ private static InvalidOperationException CreatePrematureCloseException(
+ WebSocketReceiveResult result
+ )
+ {
+ var status = result.CloseStatus is { } closeStatus
+ ? $"{(int)closeStatus} ({closeStatus})"
+ : "without a close status";
+ var reason = string.IsNullOrWhiteSpace(result.CloseStatusDescription)
+ ? ""
+ : $": {result.CloseStatusDescription}";
+ return new InvalidOperationException(
+ $"AssemblyAI streaming socket closed {status}{reason} before Termination."
+ );
+ }
+
+ private void ThrowIfClosedBeforeTermination()
+ {
+ ThrowIfFaulted();
+ if (Volatile.Read(ref _terminationReceived) != 0)
+ return;
+
+ CaptureFault(
+ new InvalidOperationException(
+ $"AssemblyAI streaming socket is {_ws.State} before Termination."
+ )
+ );
+ ThrowIfFaulted();
+ }
+
+ private void CaptureFault(Exception exception)
+ {
+ if (Interlocked.CompareExchange(ref _sessionFault, exception, null) is null)
+ _terminalCompletion.TrySetException(exception);
+ }
+
+ private void ThrowIfFaulted()
+ {
+ var exception = Volatile.Read(ref _sessionFault);
+ if (exception is not null)
+ ExceptionDispatchInfo.Capture(exception).Throw();
+ }
+
public async ValueTask DisposeAsync()
{
+ if (_disposed)
+ return;
+
+ _disposed = true;
+ // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here.
_receiveCts.Cancel();
+ _terminalCompletion.TrySetResult();
if (_ws.State == WebSocketState.Open)
{
@@ -163,6 +407,9 @@ await _ws.CloseAsync(
}
}
+ _ = _terminalCompletion.Task.Exception;
+ // ReSharper disable once MethodHasAsyncOverload -- MemoryStream has no async disposal work; DisposeAsync would only add overhead here.
+ _audioBuffer.Dispose();
_receiveCts.Dispose();
_ws.Dispose();
}
diff --git a/plugins/TypeWhisper.Plugin.AssemblyAi/TypeWhisper.Plugin.AssemblyAi.csproj b/plugins/TypeWhisper.Plugin.AssemblyAi/TypeWhisper.Plugin.AssemblyAi.csproj
index 2bc69c400..7c6646431 100644
--- a/plugins/TypeWhisper.Plugin.AssemblyAi/TypeWhisper.Plugin.AssemblyAi.csproj
+++ b/plugins/TypeWhisper.Plugin.AssemblyAi/TypeWhisper.Plugin.AssemblyAi.csproj
@@ -6,6 +6,9 @@
latest
TypeWhisper.Plugin.AssemblyAi
+
+
+
diff --git a/plugins/TypeWhisper.Plugin.AssemblyAi/manifest.json b/plugins/TypeWhisper.Plugin.AssemblyAi/manifest.json
index 5747b0857..ae22f8ef0 100644
--- a/plugins/TypeWhisper.Plugin.AssemblyAi/manifest.json
+++ b/plugins/TypeWhisper.Plugin.AssemblyAi/manifest.json
@@ -4,6 +4,8 @@
"version": "1.1.2",
"author": "TypeWhisper",
"description": "AssemblyAI Universal-2 transcription engine",
+ "networkAccess": "network",
+ "categories": ["transcription"],
"assemblyName": "TypeWhisper.Plugin.AssemblyAi.dll",
"pluginClass": "TypeWhisper.Plugin.AssemblyAi.AssemblyAiPlugin"
}
diff --git a/plugins/TypeWhisper.Plugin.Cerebras/CerebrasPlugin.cs b/plugins/TypeWhisper.Plugin.Cerebras/CerebrasPlugin.cs
index 3c7ceb762..b46ed458f 100644
--- a/plugins/TypeWhisper.Plugin.Cerebras/CerebrasPlugin.cs
+++ b/plugins/TypeWhisper.Plugin.Cerebras/CerebrasPlugin.cs
@@ -1,4 +1,8 @@
-using System.Net.Http;
+// ReSharper disable MemberCanBePrivate.Global
+// ReSharper disable UnusedMember.Global
+// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces
+// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire.
+
using System.Net.Http.Headers;
using TypeWhisper.PluginSDK;
using TypeWhisper.PluginSDK.Helpers;
@@ -6,13 +10,12 @@
namespace TypeWhisper.Plugin.Cerebras;
-public sealed partial class CerebrasPlugin : ILlmProviderPlugin, IPluginSettingsProvider, IPluginLocalizationAware
+public sealed class CerebrasPlugin : ILlmProviderPlugin, IPluginSettingsProvider, IPluginLocalizationAware
{
private const string BaseUrl = "https://api.cerebras.ai";
private readonly HttpClient _httpClient;
private IPluginHostServices? _host;
- private string? _apiKey;
private bool _streamResponses = true;
public CerebrasPlugin()
@@ -32,7 +35,7 @@ internal CerebrasPlugin(HttpClient httpClient)
public async Task ActivateAsync(IPluginHostServices host)
{
_host = host;
- _apiKey = await host.LoadSecretAsync("api-key");
+ ApiKey = await host.LoadSecretAsync("api-key");
_streamResponses = host.GetSetting(LlmStreamingSettings.StreamResponsesSettingKey) ?? true;
host.Log(PluginLogLevel.Info, $"Activated (configured={IsAvailable})");
}
@@ -44,10 +47,10 @@ public Task DeactivateAsync()
}
public string ProviderName => "Cerebras";
- public bool IsAvailable => !string.IsNullOrEmpty(_apiKey);
+ public bool IsAvailable => !string.IsNullOrEmpty(ApiKey);
public IReadOnlyList SupportedModels { get; } =
- [new PluginModelInfo("llama-4-scout-17b-16e-instruct", "Llama 4 Scout 17B")];
+ [new("llama-4-scout-17b-16e-instruct", "Llama 4 Scout 17B")];
public async Task ProcessAsync(
string systemPrompt,
@@ -62,7 +65,7 @@ CancellationToken ct
return await OpenAiChatHelper.SendChatCompletionAsync(
_httpClient,
BaseUrl,
- _apiKey!,
+ ApiKey!,
model,
systemPrompt,
userText,
@@ -89,18 +92,19 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct
var source = OpenAiChatHelper.SendChatCompletionStreamingAsync(
_httpClient,
BaseUrl,
- _apiKey!,
+ ApiKey!,
model,
systemPrompt,
userText,
ct
);
- await foreach (var delta in source.WithCancellation(ct))
+ await foreach (var delta in source)
yield return delta;
}
- internal string? ApiKey => _apiKey;
+ internal string? ApiKey { get; private set; }
+
private IPluginLocalization? _injectedLocalization;
public void SetLocalization(IPluginLocalization localization) =>
@@ -113,7 +117,7 @@ public void SetLocalization(IPluginLocalization localization) =>
internal async Task SetApiKeyAsync(string apiKey)
{
- _apiKey = string.IsNullOrWhiteSpace(apiKey) ? null : apiKey;
+ ApiKey = string.IsNullOrWhiteSpace(apiKey) ? null : apiKey;
if (_host is not null)
{
if (string.IsNullOrWhiteSpace(apiKey))
@@ -166,7 +170,7 @@ public IReadOnlyList GetSettingDefinitions() =>
Task.FromResult(
key switch
{
- "api-key" => _apiKey,
+ "api-key" => ApiKey,
LlmStreamingSettings.StreamResponsesSettingKey
=> _streamResponses ? "true" : "false",
_ => null,
@@ -201,10 +205,10 @@ private static bool ParseBool(string? value) =>
public async Task ValidateAsync(CancellationToken ct = default)
{
- if (string.IsNullOrWhiteSpace(_apiKey))
+ if (string.IsNullOrWhiteSpace(ApiKey))
return new PluginSettingsValidationResult(false, Loc.L("Settings.EnterApiKey"));
- var valid = await ValidateApiKeyAsync(_apiKey, ct);
+ var valid = await ValidateApiKeyAsync(ApiKey, ct);
return valid
? new PluginSettingsValidationResult(true, Loc.L("Settings.ApiKeyValid"))
: new PluginSettingsValidationResult(false, Loc.L("Settings.ApiKeyInvalid"));
diff --git a/plugins/TypeWhisper.Plugin.Cerebras/manifest.json b/plugins/TypeWhisper.Plugin.Cerebras/manifest.json
index a1de47c8c..008385061 100644
--- a/plugins/TypeWhisper.Plugin.Cerebras/manifest.json
+++ b/plugins/TypeWhisper.Plugin.Cerebras/manifest.json
@@ -4,6 +4,8 @@
"version": "1.0.0",
"author": "TypeWhisper",
"description": "Cerebras fast LLM inference",
+ "networkAccess": "network",
+ "categories": ["llm"],
"assemblyName": "TypeWhisper.Plugin.Cerebras.dll",
"pluginClass": "TypeWhisper.Plugin.Cerebras.CerebrasPlugin"
}
diff --git a/plugins/TypeWhisper.Plugin.Claude/ClaudePlugin.cs b/plugins/TypeWhisper.Plugin.Claude/ClaudePlugin.cs
index 2f2cc5a1a..a4f0a540c 100644
--- a/plugins/TypeWhisper.Plugin.Claude/ClaudePlugin.cs
+++ b/plugins/TypeWhisper.Plugin.Claude/ClaudePlugin.cs
@@ -1,4 +1,8 @@
-using System.Net.Http;
+// ReSharper disable MemberCanBePrivate.Global
+// ReSharper disable UnusedMember.Global
+// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces
+// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire.
+
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
@@ -7,7 +11,7 @@
namespace TypeWhisper.Plugin.Claude;
-public sealed partial class ClaudePlugin : ILlmProviderPlugin, IPluginSettingsProvider, IPluginLocalizationAware
+public sealed class ClaudePlugin : ILlmProviderPlugin, IPluginSettingsProvider, IPluginLocalizationAware
{
private const string BaseUrl = "https://api.anthropic.com";
@@ -15,9 +19,11 @@ public sealed partial class ClaudePlugin : ILlmProviderPlugin, IPluginSettingsPr
// the stable version that covers the Messages API used here.
private const string AnthropicVersion = "2023-06-01";
+ private static readonly JsonSerializerOptions s_jsonOptions =
+ new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
+
private readonly HttpClient _httpClient;
private IPluginHostServices? _host;
- private string? _apiKey;
private bool _streamResponses = true;
public ClaudePlugin()
@@ -37,7 +43,7 @@ internal ClaudePlugin(HttpClient httpClient)
public async Task ActivateAsync(IPluginHostServices host)
{
_host = host;
- _apiKey = await host.LoadSecretAsync("api-key");
+ ApiKey = await host.LoadSecretAsync("api-key");
_streamResponses = host.GetSetting(LlmStreamingSettings.StreamResponsesSettingKey) ?? true;
host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured})");
}
@@ -53,8 +59,8 @@ public Task DeactivateAsync()
public IReadOnlyList SupportedModels { get; } =
[
- new PluginModelInfo("claude-sonnet-4-20250514", "Claude Sonnet 4"),
- new PluginModelInfo("claude-haiku-4-5-20251001", "Claude Haiku 4.5"),
+ new("claude-sonnet-4-20250514", "Claude Sonnet 4"),
+ new("claude-haiku-4-5-20251001", "Claude Haiku 4.5"),
];
public async Task ProcessAsync(
@@ -75,14 +81,11 @@ CancellationToken ct
messages = new[] { new { role = "user", content = userText } },
};
- var json = JsonSerializer.Serialize(
- requestBody,
- new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }
- );
+ var json = JsonSerializer.Serialize(requestBody, s_jsonOptions);
using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/messages");
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
- request.Headers.Add("x-api-key", _apiKey);
+ request.Headers.Add("x-api-key", ApiKey);
request.Headers.Add("anthropic-version", AnthropicVersion);
var response = await _httpClient.SendAsync(request, ct);
@@ -133,14 +136,11 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct
messages = new[] { new { role = "user", content = userText } },
};
- var json = JsonSerializer.Serialize(
- requestBody,
- new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }
- );
+ var json = JsonSerializer.Serialize(requestBody, s_jsonOptions);
using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/messages");
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
- request.Headers.Add("x-api-key", _apiKey);
+ request.Headers.Add("x-api-key", ApiKey);
request.Headers.Add("anthropic-version", AnthropicVersion);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
@@ -168,8 +168,9 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct
using var reader = new StreamReader(stream);
// The Anthropic Messages stream has no [DONE] sentinel; it ends with a
- // message_stop frame and then EOF, so the loop runs until ReadLineAsync
- // returns null.
+ // message_stop frame and then EOF. Treat that frame as the semantic
+ // success marker so a truncated stream cannot commit its partial text.
+ var receivedMessageStop = false;
while (await reader.ReadLineAsync(ct) is { } rawLine)
{
var line = rawLine.Trim();
@@ -185,9 +186,34 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct
if (ParseStreamError(payload) is { } error)
throw new InvalidOperationException(error);
+ if (IsMessageStop(payload))
+ receivedMessageStop = true;
+
if (ParseStreamDelta(payload) is { Length: > 0 } delta)
yield return delta;
}
+
+ if (!receivedMessageStop)
+ {
+ throw new InvalidOperationException(
+ "Anthropic stream ended before a message_stop event was received.");
+ }
+ }
+
+ private static bool IsMessageStop(string dataPayload)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(dataPayload);
+ var root = doc.RootElement;
+ return root.TryGetProperty("type", out var type)
+ && type.ValueKind == JsonValueKind.String
+ && type.GetString() == "message_stop";
+ }
+ catch (JsonException)
+ {
+ return false;
+ }
}
///
@@ -270,8 +296,9 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct
}
}
- internal bool IsConfigured => !string.IsNullOrEmpty(_apiKey);
- internal string? ApiKey => _apiKey;
+ internal bool IsConfigured => !string.IsNullOrEmpty(ApiKey);
+ internal string? ApiKey { get; private set; }
+
private IPluginLocalization? _injectedLocalization;
public void SetLocalization(IPluginLocalization localization) =>
@@ -287,8 +314,8 @@ internal async Task SetApiKeyAsync(string apiKey)
// Trim defensively at the internal entry too: SetSettingValueAsync
// already trims, but a future direct caller could re-introduce
// trailing whitespace that breaks the x-api-key header.
- var trimmed = apiKey?.Trim();
- _apiKey = string.IsNullOrEmpty(trimmed) ? null : trimmed;
+ var trimmed = apiKey.Trim();
+ ApiKey = string.IsNullOrEmpty(trimmed) ? null : trimmed;
if (_host is not null)
{
if (string.IsNullOrEmpty(trimmed))
@@ -300,7 +327,7 @@ internal async Task SetApiKeyAsync(string apiKey)
}
}
- internal bool ValidateApiKeyFormat(string apiKey)
+ internal static bool ValidateApiKeyFormat(string apiKey)
{
return !string.IsNullOrWhiteSpace(apiKey) && apiKey.StartsWith("sk-ant-");
}
@@ -331,7 +358,7 @@ public IReadOnlyList GetSettingDefinitions() =>
Task.FromResult(
key switch
{
- "api-key" => _apiKey,
+ "api-key" => ApiKey,
LlmStreamingSettings.StreamResponsesSettingKey
=> _streamResponses ? "true" : "false",
_ => null,
@@ -368,12 +395,12 @@ private static bool ParseBool(string? value) =>
public Task ValidateAsync(CancellationToken ct = default)
{
- if (string.IsNullOrWhiteSpace(_apiKey))
+ if (string.IsNullOrWhiteSpace(ApiKey))
return Task.FromResult(
new PluginSettingsValidationResult(false, Loc.L("Settings.EnterApiKey"))
);
- var valid = ValidateApiKeyFormat(_apiKey);
+ var valid = ValidateApiKeyFormat(ApiKey);
return Task.FromResult(
valid
? new PluginSettingsValidationResult(true, Loc.L("Settings.ApiKeyFormatValid"))
diff --git a/plugins/TypeWhisper.Plugin.Claude/manifest.json b/plugins/TypeWhisper.Plugin.Claude/manifest.json
index de467c711..39ae45e4e 100644
--- a/plugins/TypeWhisper.Plugin.Claude/manifest.json
+++ b/plugins/TypeWhisper.Plugin.Claude/manifest.json
@@ -4,6 +4,8 @@
"version": "1.0.0",
"author": "TypeWhisper",
"description": "Anthropic Claude LLM for prompt processing",
+ "networkAccess": "network",
+ "categories": ["llm"],
"assemblyName": "TypeWhisper.Plugin.Claude.dll",
"pluginClass": "TypeWhisper.Plugin.Claude.ClaudePlugin"
}
diff --git a/plugins/TypeWhisper.Plugin.CloudflareAsr/CloudflareAsrPlugin.cs b/plugins/TypeWhisper.Plugin.CloudflareAsr/CloudflareAsrPlugin.cs
index e241c72f5..e0386f93b 100644
--- a/plugins/TypeWhisper.Plugin.CloudflareAsr/CloudflareAsrPlugin.cs
+++ b/plugins/TypeWhisper.Plugin.CloudflareAsr/CloudflareAsrPlugin.cs
@@ -1,4 +1,8 @@
-using System.Net.Http;
+// ReSharper disable MemberCanBePrivate.Global
+// ReSharper disable UnusedType.Global
+// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces
+// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire.
+
using System.Net.Http.Headers;
using System.Text.Json;
using TypeWhisper.PluginSDK;
@@ -6,18 +10,27 @@
namespace TypeWhisper.Plugin.CloudflareAsr;
-public sealed partial class CloudflareAsrPlugin
+public sealed class CloudflareAsrPlugin
: ITranscriptionEnginePlugin,
IPluginSettingsProvider,
IPluginLocalizationAware
{
- private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(120) };
+ private readonly HttpClient _httpClient;
private IPluginHostServices? _host;
private string? _apiToken;
private string? _accountId;
- private string? _selectedModelId;
- private static readonly IReadOnlyList Models =
+ public CloudflareAsrPlugin()
+ : this(new HttpClient { Timeout = TimeSpan.FromSeconds(120) })
+ {
+ }
+
+ internal CloudflareAsrPlugin(HttpClient httpClient)
+ {
+ _httpClient = httpClient;
+ }
+
+ private static readonly IReadOnlyList s_models =
[
new("whisper", "Whisper (Cloudflare)"),
];
@@ -36,7 +49,7 @@ public async Task ActivateAsync(IPluginHostServices host)
_apiToken = string.IsNullOrWhiteSpace(loadedToken) ? null : loadedToken.Trim();
var loadedAccount = await host.LoadSecretAsync("account-id");
_accountId = string.IsNullOrWhiteSpace(loadedAccount) ? null : loadedAccount.Trim();
- _selectedModelId = host.GetSetting("selectedModel") ?? Models[0].Id;
+ SelectedModelId = host.GetSetting("selectedModel") ?? s_models[0].Id;
host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured})");
}
@@ -51,17 +64,17 @@ public Task DeactivateAsync()
public bool IsConfigured =>
!string.IsNullOrEmpty(_apiToken) && !string.IsNullOrEmpty(_accountId);
- public IReadOnlyList TranscriptionModels => Models;
+ public IReadOnlyList TranscriptionModels => s_models;
- public string? SelectedModelId => _selectedModelId;
+ public string? SelectedModelId { get; private set; }
public bool SupportsTranslation => false;
public void SelectModel(string modelId)
{
- if (Models.All(m => m.Id != modelId))
+ if (s_models.All(m => m.Id != modelId))
throw new ArgumentException($"Unknown model: {modelId}");
- _selectedModelId = modelId;
+ SelectedModelId = modelId;
_host?.SetSetting("selectedModel", modelId);
}
@@ -73,11 +86,14 @@ public async Task TranscribeAsync(
CancellationToken ct
)
{
- if (!IsConfigured)
- throw new InvalidOperationException(
- "Plugin not configured. Account ID and API token required."
+ if (translate)
+ throw new NotSupportedException(
+ "Translation is not supported by the Cloudflare ASR plugin."
);
+ if (!IsConfigured)
+ throw new InvalidOperationException(Loc.L("Settings.EnterAccountIdAndApiToken"));
+
var url =
$"https://api.cloudflare.com/client/v4/accounts/{_accountId}/ai/run/@cf/openai/whisper";
@@ -107,16 +123,21 @@ CancellationToken ct
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
- var text = "";
if (
- root.TryGetProperty("result", out var result)
- && result.ValueKind == JsonValueKind.Object
- && result.TryGetProperty("text", out var textEl)
+ root.ValueKind != JsonValueKind.Object
+ || !root.TryGetProperty("result", out var result)
+ || result.ValueKind != JsonValueKind.Object
+ || !result.TryGetProperty("text", out var textEl)
+ || textEl.ValueKind != JsonValueKind.String
)
{
- text = textEl.GetString() ?? "";
+ throw new InvalidOperationException(
+ "Invalid Cloudflare transcription response: required field 'result.text' must be a string."
+ );
}
+ var text = textEl.GetString() ?? "";
+
// Language and duration are nested under result.language / result.duration;
// both fields are optional and absent when Cloudflare can't determine them.
string? detectedLanguage = null;
@@ -180,7 +201,7 @@ internal async Task SetAccountIdAsync(string accountId)
internal async Task SetApiTokenAsync(string apiToken)
{
- var trimmed = apiToken?.Trim();
+ var trimmed = apiToken.Trim();
_apiToken = string.IsNullOrEmpty(trimmed) ? null : trimmed;
if (_host is not null)
{
@@ -213,7 +234,7 @@ public IReadOnlyList GetSettingDefinitions() =>
"selectedModel",
Loc.L("Settings.TranscriptionModel"),
Description: Loc.L("Settings.ModelDescription"),
- Options: Models.Select(m => new PluginSettingOption(m.Id, m.DisplayName)).ToList()
+ Options: s_models.Select(m => new PluginSettingOption(m.Id, m.DisplayName)).ToList()
),
];
@@ -223,7 +244,7 @@ public IReadOnlyList GetSettingDefinitions() =>
{
"account-id" => _accountId,
"api-token" => _apiToken,
- "selectedModel" => _selectedModelId,
+ "selectedModel" => SelectedModelId,
_ => null,
}
);
diff --git a/plugins/TypeWhisper.Plugin.CloudflareAsr/TypeWhisper.Plugin.CloudflareAsr.csproj b/plugins/TypeWhisper.Plugin.CloudflareAsr/TypeWhisper.Plugin.CloudflareAsr.csproj
index 8b4ab707e..4b0f299fd 100644
--- a/plugins/TypeWhisper.Plugin.CloudflareAsr/TypeWhisper.Plugin.CloudflareAsr.csproj
+++ b/plugins/TypeWhisper.Plugin.CloudflareAsr/TypeWhisper.Plugin.CloudflareAsr.csproj
@@ -6,6 +6,9 @@
latest
TypeWhisper.Plugin.CloudflareAsr
+
+
+
diff --git a/plugins/TypeWhisper.Plugin.CloudflareAsr/manifest.json b/plugins/TypeWhisper.Plugin.CloudflareAsr/manifest.json
index 634c4d772..c4d6e2386 100644
--- a/plugins/TypeWhisper.Plugin.CloudflareAsr/manifest.json
+++ b/plugins/TypeWhisper.Plugin.CloudflareAsr/manifest.json
@@ -4,6 +4,8 @@
"version": "1.0.0",
"author": "TypeWhisper",
"description": "Cloudflare Workers AI Whisper transcription engine",
+ "networkAccess": "network",
+ "categories": ["transcription"],
"assemblyName": "TypeWhisper.Plugin.CloudflareAsr.dll",
"pluginClass": "TypeWhisper.Plugin.CloudflareAsr.CloudflareAsrPlugin"
}
diff --git a/plugins/TypeWhisper.Plugin.Cohere/CoherePlugin.cs b/plugins/TypeWhisper.Plugin.Cohere/CoherePlugin.cs
index d38d6cdd9..e2ab14d43 100644
--- a/plugins/TypeWhisper.Plugin.Cohere/CoherePlugin.cs
+++ b/plugins/TypeWhisper.Plugin.Cohere/CoherePlugin.cs
@@ -1,4 +1,8 @@
-using System.Net.Http;
+// ReSharper disable MemberCanBePrivate.Global
+// ReSharper disable UnusedMember.Global
+// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces
+// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire.
+
using System.Net.Http.Headers;
using TypeWhisper.PluginSDK;
using TypeWhisper.PluginSDK.Helpers;
@@ -6,7 +10,7 @@
namespace TypeWhisper.Plugin.Cohere;
-public sealed partial class CoherePlugin : ILlmProviderPlugin, IDisposable, IPluginSettingsProvider, IPluginLocalizationAware
+public sealed class CoherePlugin : ILlmProviderPlugin, IPluginSettingsProvider, IPluginLocalizationAware
{
private const string BaseUrl = "https://api.cohere.com/compatibility";
private readonly HttpClient _httpClient;
@@ -56,7 +60,7 @@ public void SetLocalization(IPluginLocalization localization) =>
internal IPluginLocalization? Loc => _host?.Localization ?? _injectedLocalization;
public IReadOnlyList SupportedModels { get; } =
- [new PluginModelInfo("command-a-03-2025", "Command A") { IsRecommended = true }];
+ [new("command-a-03-2025", "Command A") { IsRecommended = true }];
public async Task ProcessAsync(
string systemPrompt,
@@ -105,7 +109,7 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct
ct
);
- await foreach (var delta in source.WithCancellation(ct))
+ await foreach (var delta in source)
yield return delta;
}
diff --git a/plugins/TypeWhisper.Plugin.Cohere/manifest.json b/plugins/TypeWhisper.Plugin.Cohere/manifest.json
index c0adc6eb2..058a7248a 100644
--- a/plugins/TypeWhisper.Plugin.Cohere/manifest.json
+++ b/plugins/TypeWhisper.Plugin.Cohere/manifest.json
@@ -4,6 +4,8 @@
"version": "1.0.0",
"author": "TypeWhisper",
"description": "Cohere Command LLM for prompt processing",
+ "networkAccess": "network",
+ "categories": ["llm"],
"assemblyName": "TypeWhisper.Plugin.Cohere.dll",
"pluginClass": "TypeWhisper.Plugin.Cohere.CoherePlugin"
}
diff --git a/plugins/TypeWhisper.Plugin.Deepgram/DeepgramPlugin.cs b/plugins/TypeWhisper.Plugin.Deepgram/DeepgramPlugin.cs
index c648a4095..e43bb45f9 100644
--- a/plugins/TypeWhisper.Plugin.Deepgram/DeepgramPlugin.cs
+++ b/plugins/TypeWhisper.Plugin.Deepgram/DeepgramPlugin.cs
@@ -1,4 +1,9 @@
-using System.Net.Http;
+// ReSharper disable MemberCanBePrivate.Global
+// ReSharper disable UnusedMember.Global
+// ReSharper disable UnusedType.Global
+// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces
+// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire.
+
using System.Net.Http.Headers;
using System.Text.Json;
using TypeWhisper.PluginSDK;
@@ -6,16 +11,14 @@
namespace TypeWhisper.Plugin.Deepgram;
-public sealed partial class DeepgramPlugin : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware
+public sealed class DeepgramPlugin : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware
{
private const string BaseUrl = "https://api.deepgram.com";
private readonly HttpClient _httpClient = new();
private IPluginHostServices? _host;
- private string? _apiKey;
- private string? _selectedModelId;
- private static readonly IReadOnlyList Models =
+ private static readonly IReadOnlyList s_models =
[
new("nova-3", "Nova-3"),
new("nova-2", "Nova-2"),
@@ -28,8 +31,8 @@ public sealed partial class DeepgramPlugin : ITranscriptionEnginePlugin, IPlugin
public async Task ActivateAsync(IPluginHostServices host)
{
_host = host;
- _apiKey = await host.LoadSecretAsync("api-key");
- _selectedModelId = host.GetSetting("selectedModel") ?? Models[0].Id;
+ ApiKey = await host.LoadSecretAsync("api-key");
+ SelectedModelId = host.GetSetting("selectedModel") ?? s_models[0].Id;
host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured})");
}
@@ -41,24 +44,24 @@ public Task DeactivateAsync()
public string ProviderId => "deepgram";
public string ProviderDisplayName => "Deepgram";
- public bool IsConfigured => !string.IsNullOrEmpty(_apiKey);
+ public bool IsConfigured => !string.IsNullOrEmpty(ApiKey);
- public IReadOnlyList TranscriptionModels => Models;
+ public IReadOnlyList TranscriptionModels => s_models;
- public string? SelectedModelId => _selectedModelId;
+ public string? SelectedModelId { get; private set; }
public bool SupportsTranslation => false;
public bool SupportsStreaming => true;
public async Task StartStreamingAsync(string? language, CancellationToken ct)
{
- if (!IsConfigured || _selectedModelId is null)
+ if (!IsConfigured || SelectedModelId is null)
throw new InvalidOperationException(
"Plugin not configured. API key and model required."
);
return await DeepgramStreamingSession.ConnectAsync(
- _apiKey!,
- _selectedModelId,
+ ApiKey!,
+ SelectedModelId,
language,
ct
);
@@ -66,9 +69,9 @@ public async Task StartStreamingAsync(string? language, Cance
public void SelectModel(string modelId)
{
- if (Models.All(m => m.Id != modelId))
+ if (s_models.All(m => m.Id != modelId))
throw new ArgumentException($"Unknown model: {modelId}");
- _selectedModelId = modelId;
+ SelectedModelId = modelId;
_host?.SetSetting("selectedModel", modelId);
}
@@ -80,7 +83,7 @@ public async Task TranscribeAsync(
CancellationToken ct
)
{
- if (!IsConfigured || _selectedModelId is null)
+ if (!IsConfigured || SelectedModelId is null)
throw new InvalidOperationException(
"Plugin not configured. API key and model required."
);
@@ -90,10 +93,10 @@ CancellationToken ct
? "&detect_language=true"
: $"&language={Uri.EscapeDataString(language)}";
var url =
- $"{BaseUrl}/v1/listen?model={Uri.EscapeDataString(_selectedModelId)}&smart_format=true&punctuate=true{langParam}";
+ $"{BaseUrl}/v1/listen?model={Uri.EscapeDataString(SelectedModelId)}&smart_format=true&punctuate=true{langParam}";
using var request = new HttpRequestMessage(HttpMethod.Post, url);
- request.Headers.Authorization = new AuthenticationHeaderValue("Token", _apiKey);
+ request.Headers.Authorization = new AuthenticationHeaderValue("Token", ApiKey);
request.Content = new ByteArrayContent(wavAudio);
request.Content.Headers.ContentType = new MediaTypeHeaderValue("audio/wav");
@@ -134,7 +137,7 @@ CancellationToken ct
);
}
- internal string? ApiKey => _apiKey;
+ internal string? ApiKey { get; private set; }
private IPluginLocalization? _injectedLocalization;
@@ -148,7 +151,7 @@ public void SetLocalization(IPluginLocalization localization) =>
internal async Task SetApiKeyAsync(string apiKey)
{
- _apiKey = string.IsNullOrWhiteSpace(apiKey) ? null : apiKey;
+ ApiKey = string.IsNullOrWhiteSpace(apiKey) ? null : apiKey;
if (_host is not null)
{
if (string.IsNullOrWhiteSpace(apiKey))
@@ -193,7 +196,7 @@ public IReadOnlyList GetSettingDefinitions() =>
"selectedModel",
Loc.L("Settings.TranscriptionModel"),
Description: Loc.L("Settings.ModelDescription"),
- Options: Models.Select(m => new PluginSettingOption(m.Id, m.DisplayName)).ToList()
+ Options: s_models.Select(m => new PluginSettingOption(m.Id, m.DisplayName)).ToList()
),
];
@@ -201,8 +204,8 @@ public IReadOnlyList GetSettingDefinitions() =>
Task.FromResult(
key switch
{
- "api-key" => _apiKey,
- "selectedModel" => _selectedModelId,
+ "api-key" => ApiKey,
+ "selectedModel" => SelectedModelId,
_ => null,
}
);
@@ -227,10 +230,10 @@ public async Task SetSettingValueAsync(
public async Task ValidateAsync(CancellationToken ct = default)
{
- if (string.IsNullOrWhiteSpace(_apiKey))
+ if (string.IsNullOrWhiteSpace(ApiKey))
return new PluginSettingsValidationResult(false, Loc.L("Settings.EnterApiKey"));
- var valid = await ValidateApiKeyAsync(_apiKey, ct);
+ var valid = await ValidateApiKeyAsync(ApiKey, ct);
return valid
? new PluginSettingsValidationResult(true, Loc.L("Settings.ApiKeyValid"))
: new PluginSettingsValidationResult(false, Loc.L("Settings.ApiKeyInvalid"));
diff --git a/plugins/TypeWhisper.Plugin.Deepgram/DeepgramStreamingSession.cs b/plugins/TypeWhisper.Plugin.Deepgram/DeepgramStreamingSession.cs
index 983d4b59e..ccdbdcd3b 100644
--- a/plugins/TypeWhisper.Plugin.Deepgram/DeepgramStreamingSession.cs
+++ b/plugins/TypeWhisper.Plugin.Deepgram/DeepgramStreamingSession.cs
@@ -1,5 +1,6 @@
-using System.IO;
+using System.Diagnostics;
using System.Net.WebSockets;
+using System.Runtime.ExceptionServices;
using System.Text;
using System.Text.Json;
using TypeWhisper.PluginSDK;
@@ -8,9 +9,20 @@ namespace TypeWhisper.Plugin.Deepgram;
internal sealed class DeepgramStreamingSession : IStreamingSession
{
- private readonly ClientWebSocket _ws = new();
+ private readonly WebSocket _ws;
private readonly CancellationTokenSource _receiveCts = new();
- private Task? _receiveTask;
+ private readonly TaskCompletionSource _terminalCompletion =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private Exception? _sessionFault;
+ private readonly Task? _receiveTask;
+ private int _metadataReceived;
+ private bool _disposed;
+
+ internal DeepgramStreamingSession(WebSocket ws)
+ {
+ _ws = ws;
+ _receiveTask = ReceiveLoopAsync(_receiveCts.Token);
+ }
public event Action? TranscriptReceived;
@@ -21,7 +33,7 @@ public static async Task ConnectAsync(
CancellationToken ct
)
{
- var session = new DeepgramStreamingSession();
+ var ws = new ClientWebSocket();
// Deepgram's streaming WebSocket does not accept detect_language=true
// (it's batch-only). For an unspecified language Nova-3 supports
@@ -31,32 +43,92 @@ CancellationToken ct
string.IsNullOrEmpty(language)
|| string.Equals(language, "auto", StringComparison.OrdinalIgnoreCase);
var langParam = isUnspecified
- ? (model.StartsWith("nova-3", StringComparison.OrdinalIgnoreCase)
+ ? model.StartsWith("nova-3", StringComparison.OrdinalIgnoreCase)
? "&language=multi"
- : string.Empty)
+ : string.Empty
: $"&language={Uri.EscapeDataString(language!)}";
var url =
$"wss://api.deepgram.com/v1/listen?model={Uri.EscapeDataString(model)}&encoding=linear16&sample_rate=16000&interim_results=true&punctuate=true&smart_format=true{langParam}";
- session._ws.Options.SetRequestHeader("Authorization", $"Token {apiKey}");
- await session._ws.ConnectAsync(new Uri(url), ct);
- session._receiveTask = session.ReceiveLoopAsync(session._receiveCts.Token);
- return session;
+ ws.Options.SetRequestHeader("Authorization", $"Token {apiKey}");
+ try
+ {
+ await ws.ConnectAsync(new Uri(url), ct);
+ return new DeepgramStreamingSession(ws);
+ }
+ catch
+ {
+ ws.Dispose();
+ throw;
+ }
}
public async Task SendAudioAsync(ReadOnlyMemory pcm16Audio, CancellationToken ct)
{
+ if (_disposed)
+ return;
+
+ ThrowIfFaulted();
if (_ws.State != WebSocketState.Open)
+ {
+ ThrowIfClosedBeforeMetadata();
return;
- await _ws.SendAsync(pcm16Audio, WebSocketMessageType.Binary, true, ct);
+ }
+
+ try
+ {
+ await _ws.SendAsync(pcm16Audio, WebSocketMessageType.Binary, true, ct);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ CaptureFault(
+ new InvalidOperationException("Deepgram streaming audio send failed.", ex)
+ );
+ throw;
+ }
}
public async Task FinalizeAsync(CancellationToken ct)
{
- if (_ws.State != WebSocketState.Open)
+ if (_disposed)
return;
- var msg = Encoding.UTF8.GetBytes("""{"type":"CloseStream"}""");
- await _ws.SendAsync(msg, WebSocketMessageType.Text, true, ct);
+
+ ThrowIfFaulted();
+ if (Volatile.Read(ref _metadataReceived) == 0)
+ {
+ if (_ws.State != WebSocketState.Open)
+ {
+ ThrowIfClosedBeforeMetadata();
+ return;
+ }
+
+ var msg = """{"type":"CloseStream"}"""u8.ToArray();
+ try
+ {
+ await _ws.SendAsync(msg, WebSocketMessageType.Text, true, ct);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ CaptureFault(
+ new InvalidOperationException(
+ "Deepgram CloseStream send failed.",
+ ex
+ )
+ );
+ throw;
+ }
+ }
+
+ await _terminalCompletion.Task.WaitAsync(ct);
+ ThrowIfFaulted();
}
private async Task ReceiveLoopAsync(CancellationToken ct)
@@ -66,7 +138,7 @@ private async Task ReceiveLoopAsync(CancellationToken ct)
try
{
- while (!ct.IsCancellationRequested && _ws.State == WebSocketState.Open)
+ while (true)
{
messageBuffer.SetLength(0);
WebSocketReceiveResult result;
@@ -74,7 +146,11 @@ private async Task ReceiveLoopAsync(CancellationToken ct)
{
result = await _ws.ReceiveAsync(buffer, ct);
if (result.MessageType == WebSocketMessageType.Close)
+ {
+ CaptureFault(CreatePrematureCloseException(result));
return;
+ }
+
messageBuffer.Write(buffer, 0, result.Count);
} while (!result.EndOfMessage);
@@ -86,45 +162,200 @@ private async Task ReceiveLoopAsync(CancellationToken ct)
0,
(int)messageBuffer.Length
);
- ParseAndEmit(json);
+ ProcessMessage(json);
+
+ if (Volatile.Read(ref _metadataReceived) != 0)
+ return;
+ }
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ // DisposeAsync owns this token. Local teardown is not a stream fault.
+ }
+ catch (OperationCanceledException ex)
+ {
+ CaptureFault(
+ new InvalidOperationException("Deepgram streaming receive was canceled.", ex)
+ );
+ }
+ catch (WebSocketException ex)
+ {
+ CaptureFault(
+ new InvalidOperationException("Deepgram streaming transport failed.", ex)
+ );
+ }
+ catch (JsonException ex)
+ {
+ CaptureFault(new InvalidOperationException("Deepgram sent malformed JSON.", ex));
+ }
+ catch (InvalidOperationException ex)
+ {
+ CaptureFault(ex);
+ }
+ catch (Exception ex)
+ {
+ CaptureFault(
+ new InvalidOperationException("Deepgram streaming receive failed.", ex)
+ );
+ }
+ finally
+ {
+ if (ct.IsCancellationRequested)
+ {
+ _terminalCompletion.TrySetResult();
+ }
+ else if (
+ Volatile.Read(ref _metadataReceived) == 0
+ && Volatile.Read(ref _sessionFault) is null
+ )
+ {
+ CaptureFault(
+ new InvalidOperationException(
+ "Deepgram streaming receive ended before Metadata."
+ )
+ );
}
}
- catch (OperationCanceledException) { }
- catch (WebSocketException) { }
}
- private void ParseAndEmit(string json)
+ private void ProcessMessage(string json)
{
- try
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ if (
+ root.ValueKind != JsonValueKind.Object
+ || !root.TryGetProperty("type", out var typeEl)
+ || typeEl.ValueKind != JsonValueKind.String
+ )
{
- using var doc = JsonDocument.Parse(json);
- var root = doc.RootElement;
+ throw new InvalidOperationException(
+ "Deepgram sent a malformed streaming message."
+ );
+ }
- if (!root.TryGetProperty("type", out var typeEl) || typeEl.GetString() != "Results")
- return;
+ switch (typeEl.GetString())
+ {
+ case "Results":
+ ProcessResults(root);
+ break;
+ case "Metadata":
+ Volatile.Write(ref _metadataReceived, 1);
+ _terminalCompletion.TrySetResult();
+ break;
+ case "Error":
+ throw new InvalidOperationException(
+ $"Deepgram streaming provider error: {ExtractError(root)}"
+ );
+ }
+ }
- var transcript =
- root.GetProperty("channel")
- .GetProperty("alternatives")[0]
- .GetProperty("transcript")
- .GetString()
- ?? "";
+ private void ProcessResults(JsonElement root)
+ {
+ if (
+ !root.TryGetProperty("channel", out var channel)
+ || channel.ValueKind != JsonValueKind.Object
+ || !channel.TryGetProperty("alternatives", out var alternatives)
+ || alternatives.ValueKind != JsonValueKind.Array
+ || alternatives.GetArrayLength() == 0
+ || alternatives[0].ValueKind != JsonValueKind.Object
+ || !alternatives[0].TryGetProperty("transcript", out var transcriptEl)
+ || transcriptEl.ValueKind != JsonValueKind.String
+ )
+ {
+ throw new InvalidOperationException("Deepgram sent a malformed Results message.");
+ }
- if (string.IsNullOrWhiteSpace(transcript))
- return;
+ var transcript = transcriptEl.GetString() ?? "";
+ if (string.IsNullOrWhiteSpace(transcript))
+ return;
- var isFinal = root.TryGetProperty("is_final", out var finalEl) && finalEl.GetBoolean();
+ var isFinal =
+ root.TryGetProperty("is_final", out var finalEl)
+ && finalEl.ValueKind is JsonValueKind.True or JsonValueKind.False
+ && finalEl.GetBoolean();
+ Emit(new StreamingTranscriptEvent(transcript, isFinal));
+ }
- TranscriptReceived?.Invoke(new StreamingTranscriptEvent(transcript, isFinal));
+ private void Emit(StreamingTranscriptEvent transcriptEvent)
+ {
+ try
+ {
+ TranscriptReceived?.Invoke(transcriptEvent);
}
- catch
- { /* malformed message, skip */
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Deepgram streaming subscriber failed: {ex.Message}");
}
}
+ private static string ExtractError(JsonElement root)
+ {
+ foreach (var propertyName in new[] { "description", "message", "error" })
+ {
+ if (
+ root.TryGetProperty(propertyName, out var property)
+ && property.ValueKind == JsonValueKind.String
+ && !string.IsNullOrWhiteSpace(property.GetString())
+ )
+ {
+ return property.GetString()!;
+ }
+ }
+
+ return "Unknown provider error.";
+ }
+
+ private static InvalidOperationException CreatePrematureCloseException(
+ WebSocketReceiveResult result
+ )
+ {
+ var status = result.CloseStatus is { } closeStatus
+ ? $"{(int)closeStatus} ({closeStatus})"
+ : "without a close status";
+ var reason = string.IsNullOrWhiteSpace(result.CloseStatusDescription)
+ ? ""
+ : $": {result.CloseStatusDescription}";
+ return new InvalidOperationException(
+ $"Deepgram streaming socket closed {status}{reason} before Metadata."
+ );
+ }
+
+ private void ThrowIfClosedBeforeMetadata()
+ {
+ ThrowIfFaulted();
+ if (Volatile.Read(ref _metadataReceived) != 0)
+ return;
+
+ CaptureFault(
+ new InvalidOperationException(
+ $"Deepgram streaming socket is {_ws.State} before Metadata."
+ )
+ );
+ ThrowIfFaulted();
+ }
+
+ private void CaptureFault(Exception exception)
+ {
+ if (Interlocked.CompareExchange(ref _sessionFault, exception, null) is null)
+ _terminalCompletion.TrySetException(exception);
+ }
+
+ private void ThrowIfFaulted()
+ {
+ var exception = Volatile.Read(ref _sessionFault);
+ if (exception is not null)
+ ExceptionDispatchInfo.Capture(exception).Throw();
+ }
+
public async ValueTask DisposeAsync()
{
+ if (_disposed)
+ return;
+
+ _disposed = true;
+ // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here.
_receiveCts.Cancel();
+ _terminalCompletion.TrySetResult();
if (_ws.State == WebSocketState.Open)
{
@@ -157,6 +388,7 @@ await _ws.CloseAsync(
}
}
+ _ = _terminalCompletion.Task.Exception;
_receiveCts.Dispose();
_ws.Dispose();
}
diff --git a/plugins/TypeWhisper.Plugin.Deepgram/TypeWhisper.Plugin.Deepgram.csproj b/plugins/TypeWhisper.Plugin.Deepgram/TypeWhisper.Plugin.Deepgram.csproj
index f44c49cf9..6e779b41c 100644
--- a/plugins/TypeWhisper.Plugin.Deepgram/TypeWhisper.Plugin.Deepgram.csproj
+++ b/plugins/TypeWhisper.Plugin.Deepgram/TypeWhisper.Plugin.Deepgram.csproj
@@ -6,6 +6,9 @@
latest
TypeWhisper.Plugin.Deepgram
+
+
+
diff --git a/plugins/TypeWhisper.Plugin.Deepgram/manifest.json b/plugins/TypeWhisper.Plugin.Deepgram/manifest.json
index 0bf466fc0..13a700887 100644
--- a/plugins/TypeWhisper.Plugin.Deepgram/manifest.json
+++ b/plugins/TypeWhisper.Plugin.Deepgram/manifest.json
@@ -4,6 +4,8 @@
"version": "1.0.2",
"author": "TypeWhisper",
"description": "Deepgram Nova transcription engine",
+ "networkAccess": "network",
+ "categories": ["transcription"],
"assemblyName": "TypeWhisper.Plugin.Deepgram.dll",
"pluginClass": "TypeWhisper.Plugin.Deepgram.DeepgramPlugin"
}
diff --git a/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsPlugin.cs b/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsPlugin.cs
index 7914fde47..910c0fca9 100644
--- a/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsPlugin.cs
+++ b/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsPlugin.cs
@@ -1,3 +1,9 @@
+// ReSharper disable MemberCanBePrivate.Global
+// ReSharper disable UnusedMember.Global
+// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces
+// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire.
+
+using System.Buffers;
using System.Net.Http.Headers;
using System.Text.Json;
using TypeWhisper.PluginSDK;
@@ -12,14 +18,14 @@ public sealed class ElevenLabsPlugin : ITranscriptionEnginePlugin, IPluginSettin
private const string ApiKeySecretName = "api-key";
private const string SelectedModelSettingName = "selectedModel";
- private static readonly char[] InvalidKeytermCharacters = ['<', '>', '{', '}', '[', ']', '\\'];
+ private static readonly SearchValues s_invalidKeytermCharacters = SearchValues.Create("<>{}[]\\");
- private static readonly IReadOnlyList ModelEntries =
+ private static readonly IReadOnlyList s_modelEntries =
[
new(DefaultModelId, "Scribe v2", "scribe_v2", "scribe_v2_realtime"),
];
- private static readonly IReadOnlyList Languages =
+ private static readonly IReadOnlyList s_languages =
[
"af",
"am",
@@ -126,8 +132,6 @@ public sealed class ElevenLabsPlugin : ITranscriptionEnginePlugin, IPluginSettin
private readonly HttpClient _httpClient;
private IPluginHostServices? _host;
- private string? _apiKey;
- private string? _selectedModelId;
public ElevenLabsPlugin()
: this(CreateHttpClient()) { }
@@ -144,8 +148,8 @@ internal ElevenLabsPlugin(HttpClient httpClient)
public async Task ActivateAsync(IPluginHostServices host)
{
_host = host;
- _apiKey = await host.LoadSecretAsync(ApiKeySecretName);
- _selectedModelId = NormalizeModelId(host.GetSetting(SelectedModelSettingName));
+ ApiKey = await host.LoadSecretAsync(ApiKeySecretName);
+ SelectedModelId = NormalizeModelId(host.GetSetting(SelectedModelSettingName));
host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured})");
}
@@ -157,23 +161,23 @@ public Task DeactivateAsync()
public string ProviderId => "elevenlabs";
public string ProviderDisplayName => "ElevenLabs";
- public bool IsConfigured => !string.IsNullOrEmpty(_apiKey);
+ public bool IsConfigured => !string.IsNullOrEmpty(ApiKey);
public IReadOnlyList TranscriptionModels { get; } =
- ModelEntries
+ s_modelEntries
.Select(m => new PluginModelInfo(m.Id, m.DisplayName) { IsRecommended = true })
.ToList();
- public string? SelectedModelId => _selectedModelId;
+ public string? SelectedModelId { get; private set; }
public bool SupportsTranslation => false;
public bool SupportsStreaming => true;
- public IReadOnlyList SupportedLanguages => Languages;
+ public IReadOnlyList SupportedLanguages => s_languages;
public void SelectModel(string modelId)
{
var entry = ResolveModelEntry(modelId);
- _selectedModelId = entry.Id;
+ SelectedModelId = entry.Id;
_host?.SetSetting(SelectedModelSettingName, entry.Id);
}
@@ -185,14 +189,14 @@ public async Task TranscribeAsync(
CancellationToken ct
)
{
- if (!IsConfigured || _selectedModelId is null)
+ if (!IsConfigured || SelectedModelId is null)
throw new InvalidOperationException(
"Plugin not configured. API key and model required."
);
- var entry = ResolveModelEntry(_selectedModelId);
+ var entry = ResolveModelEntry(SelectedModelId);
using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/speech-to-text");
- request.Headers.TryAddWithoutValidation("xi-api-key", _apiKey);
+ request.Headers.TryAddWithoutValidation("xi-api-key", ApiKey);
using var form = new MultipartFormDataContent();
var audioContent = new ByteArrayContent(wavAudio);
@@ -221,21 +225,22 @@ CancellationToken ct
public async Task StartStreamingAsync(string? language, CancellationToken ct)
{
- if (!IsConfigured || _selectedModelId is null)
+ if (!IsConfigured || SelectedModelId is null)
throw new InvalidOperationException(
"Plugin not configured. API key and model required."
);
- var entry = ResolveModelEntry(_selectedModelId);
+ var entry = ResolveModelEntry(SelectedModelId);
return await ElevenLabsStreamingSession.ConnectAsync(
- _apiKey!,
+ ApiKey!,
entry.RealtimeModelId,
NormalizeLanguage(language),
ct
);
}
- internal string? ApiKey => _apiKey;
+ internal string? ApiKey { get; private set; }
+
private IPluginLocalization? _injectedLocalization;
public void SetLocalization(IPluginLocalization localization) =>
@@ -250,9 +255,9 @@ internal async Task SetApiKeyAsync(string apiKey)
{
var normalized = string.IsNullOrWhiteSpace(apiKey) ? null : apiKey.Trim();
var wasConfigured = IsConfigured;
- var changed = !string.Equals(_apiKey, normalized, StringComparison.Ordinal);
+ var changed = !string.Equals(ApiKey, normalized, StringComparison.Ordinal);
- _apiKey = normalized;
+ ApiKey = normalized;
if (_host is not null)
{
if (normalized is null)
@@ -298,6 +303,7 @@ internal static PluginTranscriptionResult ParseRestResponse(
var duration = 0.0;
var segments = new List();
+ // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is.
if (
root.TryGetProperty("words", out var wordsEl)
&& wordsEl.ValueKind == JsonValueKind.Array
@@ -364,7 +370,7 @@ var part in prompt.Split(
if (
term.Length == 0
|| term.Length >= 50
- || term.IndexOfAny(InvalidKeytermCharacters) >= 0
+ || term.AsSpan().IndexOfAny(s_invalidKeytermCharacters) >= 0
|| term.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length > 5
|| !seen.Add(term)
)
@@ -393,7 +399,7 @@ public IReadOnlyList GetSettingDefinitions() =>
"selectedModel",
Loc.L("Settings.TranscriptionModel"),
Description: Loc.L("Settings.ModelDescription"),
- Options: ModelEntries
+ Options: s_modelEntries
.Select(m => new PluginSettingOption(m.Id, m.DisplayName))
.ToList()
),
@@ -403,8 +409,8 @@ public IReadOnlyList GetSettingDefinitions() =>
Task.FromResult(
key switch
{
- "api-key" => _apiKey,
- "selectedModel" => _selectedModelId,
+ "api-key" => ApiKey,
+ "selectedModel" => SelectedModelId,
_ => null,
}
);
@@ -429,10 +435,10 @@ public async Task SetSettingValueAsync(
public async Task ValidateAsync(CancellationToken ct = default)
{
- if (string.IsNullOrWhiteSpace(_apiKey))
+ if (string.IsNullOrWhiteSpace(ApiKey))
return new PluginSettingsValidationResult(false, Loc.L("Settings.EnterApiKey"));
- var valid = await ValidateApiKeyAsync(_apiKey, ct);
+ var valid = await ValidateApiKeyAsync(ApiKey, ct);
return valid
? new PluginSettingsValidationResult(true, Loc.L("Settings.ApiKeyValid"))
: new PluginSettingsValidationResult(false, Loc.L("Settings.ApiKeyInvalid"));
@@ -450,10 +456,10 @@ public void Dispose()
: language;
private static string NormalizeModelId(string? modelId) =>
- ModelEntries.Any(m => m.Id == modelId) ? modelId! : DefaultModelId;
+ s_modelEntries.Any(m => m.Id == modelId) ? modelId! : DefaultModelId;
private static ElevenLabsModelEntry ResolveModelEntry(string modelId) =>
- ModelEntries.FirstOrDefault(m => m.Id == modelId)
+ s_modelEntries.FirstOrDefault(m => m.Id == modelId)
?? throw new ArgumentException($"Unknown model: {modelId}");
private static bool TryGetDouble(JsonElement element, string propertyName, out double value)
diff --git a/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsStreamingSession.cs b/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsStreamingSession.cs
index 15f893f26..10908a517 100644
--- a/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsStreamingSession.cs
+++ b/plugins/TypeWhisper.Plugin.ElevenLabs/ElevenLabsStreamingSession.cs
@@ -1,5 +1,10 @@
+// ReSharper disable MemberCanBePrivate.Global
+// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces
+// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire.
+
using System.Diagnostics;
using System.Net.WebSockets;
+using System.Runtime.ExceptionServices;
using System.Text;
using System.Text.Json;
using TypeWhisper.PluginSDK;
@@ -10,13 +15,25 @@ internal sealed class ElevenLabsStreamingSession : IStreamingSession
{
internal const int MinimumBufferedChunkBytes = 3200; // 100ms at 16kHz, 16-bit mono
- private readonly ClientWebSocket _ws = new();
+ private readonly WebSocket _ws;
private readonly CancellationTokenSource _receiveCts = new();
private readonly SemaphoreSlim _sendLock = new(1, 1);
private readonly MemoryStream _audioBuffer = new();
- private Task? _receiveTask;
+ private readonly TaskCompletionSource _terminalCompletion =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private Exception? _sessionFault;
+ private readonly Task? _receiveTask;
+ private int _finalCommitSent;
+ private int _finalCommitPending;
+ private int _terminalCommitReceived;
private bool _disposed;
+ internal ElevenLabsStreamingSession(WebSocket ws)
+ {
+ _ws = ws;
+ _receiveTask = ReceiveLoopAsync(_receiveCts.Token);
+ }
+
public event Action? TranscriptReceived;
public static async Task ConnectAsync(
@@ -26,16 +43,33 @@ public static async Task ConnectAsync(
CancellationToken ct
)
{
- var session = new ElevenLabsStreamingSession();
- session._ws.Options.SetRequestHeader("xi-api-key", apiKey);
- await session._ws.ConnectAsync(BuildRealtimeUri(realtimeModelId, language), ct);
- session._receiveTask = session.ReceiveLoopAsync(session._receiveCts.Token);
- return session;
+ var ws = new ClientWebSocket();
+ ws.Options.SetRequestHeader("xi-api-key", apiKey);
+ try
+ {
+ await ws.ConnectAsync(BuildRealtimeUri(realtimeModelId, language), ct);
+ return new ElevenLabsStreamingSession(ws);
+ }
+ catch
+ {
+ ws.Dispose();
+ throw;
+ }
}
public async Task SendAudioAsync(ReadOnlyMemory pcm16Audio, CancellationToken ct)
{
- if (_disposed || _ws.State != WebSocketState.Open || pcm16Audio.Length == 0)
+ if (_disposed)
+ return;
+
+ ThrowIfFaulted();
+ if (_ws.State != WebSocketState.Open)
+ {
+ ThrowIfClosedBeforeTerminalCommit();
+ return;
+ }
+
+ if (pcm16Audio.Length == 0)
return;
try
@@ -46,10 +80,18 @@ public async Task SendAudioAsync(ReadOnlyMemory pcm16Audio, CancellationTo
{
return;
}
+
try
{
- if (_disposed || _ws.State != WebSocketState.Open)
+ if (_disposed)
+ return;
+
+ ThrowIfFaulted();
+ if (_ws.State != WebSocketState.Open)
+ {
+ ThrowIfClosedBeforeTerminalCommit();
return;
+ }
_audioBuffer.Write(pcm16Audio.Span);
if (_audioBuffer.Length < MinimumBufferedChunkBytes)
@@ -57,7 +99,24 @@ public async Task SendAudioAsync(ReadOnlyMemory pcm16Audio, CancellationTo
var chunk = _audioBuffer.ToArray();
_audioBuffer.SetLength(0);
- await SendAudioPayloadAsync(chunk, commit: false, ct);
+ try
+ {
+ await SendAudioPayloadAsync(chunk, commit: false, ct);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ CaptureFault(
+ new InvalidOperationException(
+ "ElevenLabs streaming audio send failed.",
+ ex
+ )
+ );
+ throw;
+ }
}
finally
{
@@ -71,37 +130,79 @@ public async Task SendAudioAsync(ReadOnlyMemory pcm16Audio, CancellationTo
public async Task FinalizeAsync(CancellationToken ct)
{
- if (_disposed || _ws.State != WebSocketState.Open)
+ if (_disposed)
return;
- try
- {
- await _sendLock.WaitAsync(ct);
- }
- catch (ObjectDisposedException)
+ ThrowIfFaulted();
+ if (Volatile.Read(ref _finalCommitSent) == 0)
{
- return;
- }
- try
- {
- if (_disposed || _ws.State != WebSocketState.Open)
+ try
+ {
+ await _sendLock.WaitAsync(ct);
+ }
+ catch (ObjectDisposedException)
+ {
return;
+ }
- // Always send a terminal commit so the server knows the audio
- // stream is done, even when the buffer happens to be empty
- // because SendAudioAsync just flushed an exact-chunk boundary.
- var chunk = _audioBuffer.Length == 0 ? Array.Empty() : _audioBuffer.ToArray();
- _audioBuffer.SetLength(0);
- await SendAudioPayloadAsync(chunk, commit: true, ct);
- }
- finally
- {
try
{
- _sendLock.Release();
+ if (_disposed)
+ return;
+
+ ThrowIfFaulted();
+ if (Volatile.Read(ref _finalCommitSent) == 0)
+ {
+ if (_ws.State != WebSocketState.Open)
+ {
+ ThrowIfClosedBeforeTerminalCommit();
+ return;
+ }
+
+ // Arm the response waiter before sending so a fast provider
+ // response cannot race past it. Earlier VAD commits do not
+ // complete this source because it is armed only for the
+ // explicit final commit.
+ Volatile.Write(ref _finalCommitPending, 1);
+ Volatile.Write(ref _finalCommitSent, 1);
+
+ // Always send a terminal commit, including an empty chunk.
+ // An exact chunk-boundary flush still needs a committed
+ // response before the coordinator may accept the stream.
+ var chunk = _audioBuffer.Length == 0 ? [] : _audioBuffer.ToArray();
+ _audioBuffer.SetLength(0);
+ try
+ {
+ await SendAudioPayloadAsync(chunk, commit: true, ct);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ CaptureFault(
+ new InvalidOperationException(
+ "ElevenLabs final commit send failed.",
+ ex
+ )
+ );
+ throw;
+ }
+ }
+ }
+ finally
+ {
+ try
+ {
+ _sendLock.Release();
+ }
+ catch (ObjectDisposedException) { }
}
- catch (ObjectDisposedException) { }
}
+
+ await _terminalCompletion.Task.WaitAsync(ct);
+ ThrowIfFaulted();
}
internal static Uri BuildRealtimeUri(string realtimeModelId, string? language)
@@ -138,32 +239,65 @@ internal static bool TryParseTranscriptEvent(
string json,
out StreamingTranscriptEvent? transcriptEvent,
out string? error
+ ) =>
+ TryParseTranscriptEvent(
+ json,
+ out transcriptEvent,
+ out error,
+ out _
+ );
+
+ private static bool TryParseTranscriptEvent(
+ string json,
+ out StreamingTranscriptEvent? transcriptEvent,
+ out string? error,
+ out bool isCommittedTranscript
)
{
transcriptEvent = null;
error = null;
+ isCommittedTranscript = false;
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
- if (!root.TryGetProperty("message_type", out var messageTypeEl))
+ if (
+ root.ValueKind != JsonValueKind.Object
+ || !root.TryGetProperty("message_type", out var messageTypeEl)
+ || messageTypeEl.ValueKind != JsonValueKind.String
+ )
+ {
+ error = "ElevenLabs sent a malformed message without a message_type.";
return false;
+ }
var messageType = messageTypeEl.GetString();
- if (string.IsNullOrWhiteSpace(messageType) || messageType == "session_started")
+ if (string.IsNullOrWhiteSpace(messageType))
+ {
+ error = "ElevenLabs sent a malformed message with an empty message_type.";
+ return false;
+ }
+
+ if (messageType == "session_started")
return false;
- if (messageType.Contains("error", StringComparison.OrdinalIgnoreCase))
+ if (IsErrorMessageType(messageType))
{
error = ExtractErrorMessage(root) ?? json;
return false;
}
+ // ReSharper disable once ConvertIfStatementToSwitchStatement -- subjective control-flow style; the if-chain reads fine here.
if (messageType is "partial_transcript")
{
- var text = GetText(root);
+ if (!TryGetText(root, out var text))
+ {
+ error = "ElevenLabs sent a malformed partial transcript.";
+ return false;
+ }
+
if (string.IsNullOrWhiteSpace(text))
return false;
@@ -173,7 +307,15 @@ out string? error
if (messageType is "committed_transcript" or "committed_transcript_with_timestamps")
{
- var text = GetText(root);
+ isCommittedTranscript = true;
+ if (!TryGetText(root, out var text))
+ {
+ error = "ElevenLabs sent a malformed committed transcript.";
+ return false;
+ }
+
+ // An empty committed transcript is still the acknowledgement for
+ // an empty final commit and must unblock FinalizeAsync.
if (string.IsNullOrWhiteSpace(text))
return false;
@@ -183,12 +325,44 @@ out string? error
}
catch (JsonException ex)
{
- error = ex.Message;
+ error = $"ElevenLabs sent malformed JSON: {ex.Message}";
}
return false;
}
+ private static bool IsErrorMessageType(string messageType) =>
+ messageType switch
+ {
+ "auth_error"
+ or "quota_exceeded"
+ or "transcriber_error"
+ or "input_error"
+ or "error"
+ or "commit_throttled"
+ or "unaccepted_terms"
+ or "rate_limited"
+ or "queue_overflow"
+ or "resource_exhausted"
+ or "session_time_limit_exceeded"
+ or "chunk_size_exceeded"
+ or "insufficient_audio_activity"
+ or "scribe_auth_error"
+ or "scribe_quota_exceeded"
+ or "scribe_throttled"
+ or "scribe_unaccepted_terms"
+ or "scribe_rate_limited"
+ or "scribe_queue_overflow"
+ or "scribe_resource_exhausted"
+ or "scribe_session_time_limit_exceeded"
+ or "scribe_input_error"
+ or "scribe_chunk_size_exceeded"
+ or "scribe_insufficient_audio_activity"
+ or "scribe_transcriber_error"
+ or "scribe_error" => true,
+ _ => messageType.Contains("error", StringComparison.OrdinalIgnoreCase),
+ };
+
private async Task SendAudioPayloadAsync(byte[] chunk, bool commit, CancellationToken ct)
{
var payload = Encoding.UTF8.GetBytes(BuildAudioChunkPayload(chunk, commit));
@@ -202,7 +376,7 @@ private async Task ReceiveLoopAsync(CancellationToken ct)
try
{
- while (!ct.IsCancellationRequested && _ws.State == WebSocketState.Open)
+ while (true)
{
messageBuffer.SetLength(0);
WebSocketReceiveResult result;
@@ -210,7 +384,11 @@ private async Task ReceiveLoopAsync(CancellationToken ct)
{
result = await _ws.ReceiveAsync(buffer, ct);
if (result.MessageType == WebSocketMessageType.Close)
+ {
+ CaptureFault(CreatePrematureCloseException(result));
return;
+ }
+
messageBuffer.Write(buffer, 0, result.Count);
} while (!result.EndOfMessage);
@@ -222,35 +400,107 @@ private async Task ReceiveLoopAsync(CancellationToken ct)
0,
(int)messageBuffer.Length
);
- if (TryParseTranscriptEvent(json, out var transcriptEvent, out var error))
+ if (
+ TryParseTranscriptEvent(
+ json,
+ out var transcriptEvent,
+ out var error,
+ out var isCommittedTranscript
+ )
+ )
{
- // Isolate subscriber failures so a buggy handler can't
- // tear down the WebSocket receive loop.
- try
- {
- TranscriptReceived?.Invoke(transcriptEvent!);
- }
- catch (Exception ex)
- {
- Debug.WriteLine($"ElevenLabs realtime subscriber failed: {ex.Message}");
- }
+ Emit(transcriptEvent!);
}
- else if (!string.IsNullOrWhiteSpace(error))
+
+ if (!string.IsNullOrWhiteSpace(error))
+ throw new InvalidOperationException(
+ $"ElevenLabs streaming provider error: {error}"
+ );
+
+ // ReSharper disable once InvertIf -- the positive form states the terminal-commit case that ends the receive loop.
+ if (
+ isCommittedTranscript
+ && Volatile.Read(ref _finalCommitPending) != 0
+ )
{
- Debug.WriteLine($"ElevenLabs realtime error: {error}");
+ Volatile.Write(ref _terminalCommitReceived, 1);
+ _terminalCompletion.TrySetResult();
return;
}
}
}
- catch (OperationCanceledException) { }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ // DisposeAsync owns this token. Local teardown is not a stream fault.
+ }
+ catch (OperationCanceledException ex)
+ {
+ CaptureFault(
+ new InvalidOperationException("ElevenLabs streaming receive was canceled.", ex)
+ );
+ }
catch (WebSocketException ex)
{
- Debug.WriteLine($"ElevenLabs realtime WebSocket error: {ex.Message}");
+ CaptureFault(
+ new InvalidOperationException("ElevenLabs streaming transport failed.", ex)
+ );
+ }
+ catch (InvalidOperationException ex)
+ {
+ CaptureFault(ex);
+ }
+ catch (Exception ex)
+ {
+ CaptureFault(
+ new InvalidOperationException("ElevenLabs streaming receive failed.", ex)
+ );
+ }
+ finally
+ {
+ if (ct.IsCancellationRequested)
+ {
+ _terminalCompletion.TrySetResult();
+ }
+ else if (
+ Volatile.Read(ref _terminalCommitReceived) == 0
+ && Volatile.Read(ref _sessionFault) is null
+ )
+ {
+ CaptureFault(
+ new InvalidOperationException(
+ "ElevenLabs streaming receive ended before the final committed transcript."
+ )
+ );
+ }
+ }
+ }
+
+ private void Emit(StreamingTranscriptEvent transcriptEvent)
+ {
+ try
+ {
+ TranscriptReceived?.Invoke(transcriptEvent);
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"ElevenLabs realtime subscriber failed: {ex.Message}");
}
}
- private static string GetText(JsonElement root) =>
- root.TryGetProperty("text", out var textEl) ? textEl.GetString() ?? "" : "";
+ private static bool TryGetText(JsonElement root, out string text)
+ {
+ text = "";
+ if (
+ !root.TryGetProperty("text", out var textEl)
+ || textEl.ValueKind != JsonValueKind.String
+ )
+ {
+ return false;
+ }
+
+ text = textEl.GetString() ?? "";
+ return true;
+ }
private static string? ExtractErrorMessage(JsonElement root)
{
@@ -269,18 +519,61 @@ private static string GetText(JsonElement root) =>
return null;
}
+ private static InvalidOperationException CreatePrematureCloseException(
+ WebSocketReceiveResult result
+ )
+ {
+ var status = result.CloseStatus is { } closeStatus
+ ? $"{(int)closeStatus} ({closeStatus})"
+ : "without a close status";
+ var reason = string.IsNullOrWhiteSpace(result.CloseStatusDescription)
+ ? ""
+ : $": {result.CloseStatusDescription}";
+ return new InvalidOperationException(
+ $"ElevenLabs streaming socket closed {status}{reason} before the final committed transcript."
+ );
+ }
+
+ private void ThrowIfClosedBeforeTerminalCommit()
+ {
+ ThrowIfFaulted();
+ if (Volatile.Read(ref _terminalCommitReceived) != 0)
+ return;
+
+ CaptureFault(
+ new InvalidOperationException(
+ $"ElevenLabs streaming socket is {_ws.State} before the final committed transcript."
+ )
+ );
+ ThrowIfFaulted();
+ }
+
+ private void CaptureFault(Exception exception)
+ {
+ if (Interlocked.CompareExchange(ref _sessionFault, exception, null) is null)
+ _terminalCompletion.TrySetException(exception);
+ }
+
+ private void ThrowIfFaulted()
+ {
+ var exception = Volatile.Read(ref _sessionFault);
+ if (exception is not null)
+ ExceptionDispatchInfo.Capture(exception).Throw();
+ }
+
public async ValueTask DisposeAsync()
{
if (_disposed)
return;
_disposed = true;
+ // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here.
+ _receiveCts.Cancel();
+ _terminalCompletion.TrySetResult();
await _sendLock.WaitAsync(CancellationToken.None);
try
{
- _receiveCts.Cancel();
-
if (_ws.State == WebSocketState.Open)
{
// Bound the handshake: an unresponsive peer with CancellationToken.None
@@ -312,6 +605,7 @@ await _ws.CloseAsync(
}
}
+ // ReSharper disable once MethodHasAsyncOverload -- MemoryStream has no async disposal work; DisposeAsync would only add overhead here.
_audioBuffer.Dispose();
}
finally
@@ -321,5 +615,7 @@ await _ws.CloseAsync(
_receiveCts.Dispose();
_ws.Dispose();
}
+
+ _ = _terminalCompletion.Task.Exception;
}
}
diff --git a/plugins/TypeWhisper.Plugin.ElevenLabs/manifest.json b/plugins/TypeWhisper.Plugin.ElevenLabs/manifest.json
index 073c801bd..4d2a62ebe 100644
--- a/plugins/TypeWhisper.Plugin.ElevenLabs/manifest.json
+++ b/plugins/TypeWhisper.Plugin.ElevenLabs/manifest.json
@@ -4,8 +4,8 @@
"version": "1.0.0",
"author": "TypeWhisper",
"description": "Cloud transcription via ElevenLabs Scribe with real-time WebSocket streaming",
- "category": "transcription",
- "isLocal": false,
+ "networkAccess": "network",
+ "categories": ["transcription"],
"requiresApiKey": true,
"iconSystemName": "waveform.badge.mic",
"descriptions": {
diff --git a/plugins/TypeWhisper.Plugin.FileMemory/FileMemoryPlugin.cs b/plugins/TypeWhisper.Plugin.FileMemory/FileMemoryPlugin.cs
index b12b4d3b2..ec6367656 100644
--- a/plugins/TypeWhisper.Plugin.FileMemory/FileMemoryPlugin.cs
+++ b/plugins/TypeWhisper.Plugin.FileMemory/FileMemoryPlugin.cs
@@ -1,4 +1,3 @@
-using System.IO;
using System.Text.Json;
using TypeWhisper.PluginSDK;
using TypeWhisper.PluginSDK.Models;
@@ -7,7 +6,7 @@ namespace TypeWhisper.Plugin.FileMemory;
public sealed class FileMemoryPlugin : IMemoryStoragePlugin
{
- private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+ private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true };
private IPluginHostServices? _host;
private string? _filePath;
@@ -50,7 +49,7 @@ public async Task StoreAsync(string content, CancellationToken ct)
var next = new List(current)
{
- new(content, DateTime.UtcNow)
+ new(content, DateTime.UtcNow),
};
await SaveEntriesAsync(next, ct);
_entries = next;
@@ -188,7 +187,7 @@ private async Task> LoadEntriesAsync(CancellationToken ct)
try
{
_entries =
- JsonSerializer.Deserialize>(json, JsonOptions)
+ JsonSerializer.Deserialize>(json, s_jsonOptions)
?? throw new JsonException("The memory file contained null JSON.");
_loadFailed = false;
return _entries;
@@ -248,12 +247,33 @@ private async Task SaveEntriesAsync(List entries, CancellationToken
if (dir is not null && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
- var json = JsonSerializer.Serialize(entries, JsonOptions);
+ var json = JsonSerializer.Serialize(entries, s_jsonOptions);
+
+ if (!OperatingSystem.IsWindows())
+ {
+ // Owner-only on *every* write, including the first: neither the umask (0644 under
+ // a typical 022) nor an existing permissive mode may widen dictated memories.
+ // Set before the content is written so it is never briefly readable.
+ using (
+ new FileStream(
+ tempPath,
+ FileMode.CreateNew,
+ FileAccess.Write,
+ FileShare.None,
+ bufferSize: 1
+ )
+ )
+ {
+ File.SetUnixFileMode(tempPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ }
+ }
+
await File.WriteAllTextAsync(tempPath, json, ct);
- if (File.Exists(_filePath))
- File.Replace(tempPath, _filePath, destinationBackupFileName: null);
- else
- File.Move(tempPath, _filePath);
+
+ // One atomic rename for both the create and the replace case; sampling whether the
+ // destination existed first would only add races in each direction. Moves the temp
+ // file's inode and its 0600 into place, repairing a world-readable legacy file.
+ File.Move(tempPath, _filePath, overwrite: true);
}
catch (Exception ex)
{
diff --git a/plugins/TypeWhisper.Plugin.FileMemory/manifest.json b/plugins/TypeWhisper.Plugin.FileMemory/manifest.json
index a8b9cf05a..317158f34 100644
--- a/plugins/TypeWhisper.Plugin.FileMemory/manifest.json
+++ b/plugins/TypeWhisper.Plugin.FileMemory/manifest.json
@@ -4,6 +4,8 @@
"version": "1.0.0",
"author": "TypeWhisper",
"description": "File-based memory storage for extracted facts",
+ "networkAccess": "local",
+ "categories": ["memory"],
"assemblyName": "TypeWhisper.Plugin.FileMemory.dll",
"pluginClass": "TypeWhisper.Plugin.FileMemory.FileMemoryPlugin"
}
diff --git a/plugins/TypeWhisper.Plugin.Fireworks/FireworksPlugin.cs b/plugins/TypeWhisper.Plugin.Fireworks/FireworksPlugin.cs
index fdefe2e39..112cc4674 100644
--- a/plugins/TypeWhisper.Plugin.Fireworks/FireworksPlugin.cs
+++ b/plugins/TypeWhisper.Plugin.Fireworks/FireworksPlugin.cs
@@ -1,4 +1,8 @@
-using System.Net.Http;
+// ReSharper disable MemberCanBePrivate.Global
+// ReSharper disable UnusedMember.Global
+// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces
+// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire.
+
using System.Net.Http.Headers;
using TypeWhisper.PluginSDK;
using TypeWhisper.PluginSDK.Helpers;
@@ -6,9 +10,8 @@
namespace TypeWhisper.Plugin.Fireworks;
-public sealed partial class FireworksPlugin
+public sealed class FireworksPlugin
: ILlmProviderPlugin,
- IDisposable,
IPluginSettingsProvider,
IPluginLocalizationAware
{
@@ -61,7 +64,7 @@ public void SetLocalization(IPluginLocalization localization) =>
public IReadOnlyList SupportedModels { get; } =
[
- new PluginModelInfo(
+ new(
"accounts/fireworks/models/llama4-scout-instruct-basic",
"Llama 4 Scout"
)
@@ -117,7 +120,7 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct
ct
);
- await foreach (var delta in source.WithCancellation(ct))
+ await foreach (var delta in source)
yield return delta;
}
diff --git a/plugins/TypeWhisper.Plugin.Fireworks/manifest.json b/plugins/TypeWhisper.Plugin.Fireworks/manifest.json
index 9b264082f..616232461 100644
--- a/plugins/TypeWhisper.Plugin.Fireworks/manifest.json
+++ b/plugins/TypeWhisper.Plugin.Fireworks/manifest.json
@@ -4,6 +4,8 @@
"version": "1.0.0",
"author": "TypeWhisper",
"description": "Fireworks AI fast LLM inference",
+ "networkAccess": "network",
+ "categories": ["llm"],
"assemblyName": "TypeWhisper.Plugin.Fireworks.dll",
"pluginClass": "TypeWhisper.Plugin.Fireworks.FireworksPlugin"
}
diff --git a/plugins/TypeWhisper.Plugin.Gemini/GeminiPlugin.cs b/plugins/TypeWhisper.Plugin.Gemini/GeminiPlugin.cs
index 615895004..fead8ddec 100644
--- a/plugins/TypeWhisper.Plugin.Gemini/GeminiPlugin.cs
+++ b/plugins/TypeWhisper.Plugin.Gemini/GeminiPlugin.cs
@@ -1,4 +1,8 @@
-using System.Net.Http;
+// ReSharper disable MemberCanBePrivate.Global
+// ReSharper disable UnusedMember.Global
+// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces
+// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire.
+
using System.Net.Http.Headers;
using TypeWhisper.PluginSDK;
using TypeWhisper.PluginSDK.Helpers;
@@ -6,7 +10,7 @@
namespace TypeWhisper.Plugin.Gemini;
-public sealed partial class GeminiPlugin : ILlmProviderPlugin, IPluginSettingsProvider, IPluginLocalizationAware
+public sealed class GeminiPlugin : ILlmProviderPlugin, IPluginSettingsProvider, IPluginLocalizationAware
{
// Google's OpenAI-compatibility layer; endpoints are appended as /v1/...
private const string BaseUrl = "https://generativelanguage.googleapis.com/v1beta/openai";
@@ -14,7 +18,6 @@ public sealed partial class GeminiPlugin : ILlmProviderPlugin, IPluginSettingsPr
private readonly HttpClient _httpClient;
private IPluginHostServices? _host;
- private string? _apiKey;
private bool _streamResponses = true;
public GeminiPlugin()
@@ -38,7 +41,7 @@ public async Task ActivateAsync(IPluginHostServices host)
// otherwise reach the Bearer header with trailing whitespace and 401
// every request while IsAvailable still reports true.
var loaded = await host.LoadSecretAsync("api-key");
- _apiKey = string.IsNullOrWhiteSpace(loaded) ? null : loaded.Trim();
+ ApiKey = string.IsNullOrWhiteSpace(loaded) ? null : loaded.Trim();
_streamResponses = host.GetSetting(LlmStreamingSettings.StreamResponsesSettingKey) ?? true;
host.Log(PluginLogLevel.Info, $"Activated (configured={IsAvailable})");
}
@@ -50,16 +53,16 @@ public Task DeactivateAsync()
}
public string ProviderName => "Google Gemini";
- public bool IsAvailable => !string.IsNullOrEmpty(_apiKey);
+ public bool IsAvailable => !string.IsNullOrEmpty(ApiKey);
public IReadOnlyList SupportedModels { get; } =
[
- new PluginModelInfo(DefaultModel, "Gemini 2.5 Flash") { IsRecommended = true },
- new PluginModelInfo("gemini-2.5-pro", "Gemini 2.5 Pro"),
- new PluginModelInfo("gemini-2.5-flash-lite", "Gemini 2.5 Flash Lite"),
- new PluginModelInfo("gemma-4-27b-it", "Gemma 4 27B"),
- new PluginModelInfo("gemma-4-12b-it", "Gemma 4 12B"),
- new PluginModelInfo("gemma-4-4b-it", "Gemma 4 4B"),
+ new(DefaultModel, "Gemini 2.5 Flash") { IsRecommended = true },
+ new("gemini-2.5-pro", "Gemini 2.5 Pro"),
+ new("gemini-2.5-flash-lite", "Gemini 2.5 Flash Lite"),
+ new("gemma-4-27b-it", "Gemma 4 27B"),
+ new("gemma-4-12b-it", "Gemma 4 12B"),
+ new("gemma-4-4b-it", "Gemma 4 4B"),
];
public async Task ProcessAsync(
@@ -75,7 +78,7 @@ CancellationToken ct
return await OpenAiChatHelper.SendChatCompletionAsync(
_httpClient,
BaseUrl,
- _apiKey!,
+ ApiKey!,
model,
systemPrompt,
userText,
@@ -102,18 +105,19 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct
var source = OpenAiChatHelper.SendChatCompletionStreamingAsync(
_httpClient,
BaseUrl,
- _apiKey!,
+ ApiKey!,
model,
systemPrompt,
userText,
ct
);
- await foreach (var delta in source.WithCancellation(ct))
+ await foreach (var delta in source)
yield return delta;
}
- internal string? ApiKey => _apiKey;
+ internal string? ApiKey { get; private set; }
+
private IPluginLocalization? _injectedLocalization;
public void SetLocalization(IPluginLocalization localization) =>
@@ -126,17 +130,18 @@ public void SetLocalization(IPluginLocalization localization) =>
internal async Task SetApiKeyAsync(string apiKey)
{
- var trimmed = apiKey?.Trim();
- _apiKey = string.IsNullOrEmpty(trimmed) ? null : trimmed;
+ var trimmed = apiKey.Trim();
+ // Persist first: a failed write must not leave a key in memory that won't survive restart.
if (_host is not null)
{
if (string.IsNullOrEmpty(trimmed))
await _host.DeleteSecretAsync("api-key");
else
await _host.StoreSecretAsync("api-key", trimmed);
-
- _host.NotifyCapabilitiesChanged();
}
+
+ ApiKey = string.IsNullOrEmpty(trimmed) ? null : trimmed;
+ _host?.NotifyCapabilitiesChanged();
}
internal async Task ValidateApiKeyAsync(string apiKey, CancellationToken ct = default)
@@ -180,7 +185,7 @@ public IReadOnlyList GetSettingDefinitions() =>
Task.FromResult(
key switch
{
- "api-key" => _apiKey,
+ "api-key" => ApiKey,
LlmStreamingSettings.StreamResponsesSettingKey
=> _streamResponses ? "true" : "false",
_ => null,
@@ -215,10 +220,10 @@ private static bool ParseBool(string? value) =>
public async Task ValidateAsync(CancellationToken ct = default)
{
- if (string.IsNullOrWhiteSpace(_apiKey))
+ if (string.IsNullOrWhiteSpace(ApiKey))
return new PluginSettingsValidationResult(false, Loc.L("Settings.EnterApiKey"));
- var valid = await ValidateApiKeyAsync(_apiKey, ct);
+ var valid = await ValidateApiKeyAsync(ApiKey, ct);
return valid
? new PluginSettingsValidationResult(true, Loc.L("Settings.ApiKeyValid"))
: new PluginSettingsValidationResult(false, Loc.L("Settings.ApiKeyInvalid"));
diff --git a/plugins/TypeWhisper.Plugin.Gemini/manifest.json b/plugins/TypeWhisper.Plugin.Gemini/manifest.json
index db768fd1c..d7a251088 100644
--- a/plugins/TypeWhisper.Plugin.Gemini/manifest.json
+++ b/plugins/TypeWhisper.Plugin.Gemini/manifest.json
@@ -4,6 +4,8 @@
"version": "1.0.1",
"author": "TypeWhisper",
"description": "Google Gemini LLM provider for prompt actions and translation",
+ "networkAccess": "network",
+ "categories": ["llm"],
"assemblyName": "TypeWhisper.Plugin.Gemini.dll",
"pluginClass": "TypeWhisper.Plugin.Gemini.GeminiPlugin"
}
diff --git a/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs b/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs
index 07a7d67e0..ad8db0cc9 100644
--- a/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs
+++ b/plugins/TypeWhisper.Plugin.GemmaLocal/GemmaLocalPlugin.cs
@@ -1,6 +1,11 @@
+// ReSharper disable MemberCanBePrivate.Global
+// ReSharper disable UnusedMember.Global
+// ReSharper disable UnusedType.Global
+// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces
+// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire.
+
+using System.Collections.Immutable;
using System.Diagnostics;
-using System.IO;
-using System.Net.Http;
using LLama;
using LLama.Common;
using LLama.Sampling;
@@ -11,7 +16,7 @@ namespace TypeWhisper.Plugin.GemmaLocal;
public sealed class GemmaLocalPlugin : ILlmProviderPlugin, IPluginSettingsProvider, IPluginLocalizationAware
{
- private static readonly IReadOnlyList Models =
+ private static readonly IReadOnlyList s_models =
[
new(
"gemma4-e2b-it-q4",
@@ -44,11 +49,14 @@ public sealed class GemmaLocalPlugin : ILlmProviderPlugin, IPluginSettingsProvid
private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromHours(2) };
private readonly SemaphoreSlim _inferenceLock = new(1, 1);
+ private readonly Action _modelRoutingGuard;
+
+ // Guards SelectedModelId only: _inferenceLock is held across the multi-second native load, so
+ // it can't also serialize selection without freezing the settings UI. Never held across await.
+ private readonly Lock _selectionLock = new();
private IPluginHostServices? _host;
- private string? _selectedModelId;
private LLamaWeights? _weights;
private LLamaContext? _context;
- private string? _loadedModelId;
private bool _streamResponses = true;
private CancellationTokenSource? _startupCts;
private Task? _startupTask;
@@ -57,34 +65,48 @@ public sealed class GemmaLocalPlugin : ILlmProviderPlugin, IPluginSettingsProvid
public string PluginName => "Gemma 4 (Local)";
public string PluginVersion => "1.0.0";
+ public GemmaLocalPlugin()
+ : this(null, EnsureRequestedModelIsActive) { }
+
+ internal GemmaLocalPlugin(
+ string? loadedModelId,
+ Action modelRoutingGuard
+ )
+ {
+ LoadedModelId = loadedModelId;
+ _modelRoutingGuard =
+ modelRoutingGuard ?? throw new ArgumentNullException(nameof(modelRoutingGuard));
+ }
+
public Task ActivateAsync(IPluginHostServices host)
{
_host = host;
- _selectedModelId = host.GetSetting("selectedModel");
+ SelectedModelId = host.GetSetting("selectedModel");
_streamResponses = host.GetSetting(LlmStreamingSettings.StreamResponsesSettingKey) ?? true;
- host.Log(PluginLogLevel.Info, $"Activated (model={_selectedModelId})");
+ host.Log(PluginLogLevel.Info, $"Activated (model={SelectedModelId})");
- // A persisted ID may name a model that no longer exists in Models
+ // A persisted ID may name a model that no longer exists in s_models
// (e.g. after a release that drops a quant). IsModelDownloaded calls
// GetModelDefinition, which throws — that would surface as a plugin
// activation failure. Clear the stale setting instead.
- if (!string.IsNullOrEmpty(_selectedModelId)
- && Models.All(m => m.Id != _selectedModelId))
+ if (!string.IsNullOrEmpty(SelectedModelId)
+ && s_models.All(m => m.Id != SelectedModelId))
{
host.Log(
PluginLogLevel.Warning,
- $"Persisted model '{_selectedModelId}' is no longer available; clearing selection."
+ $"Persisted model '{SelectedModelId}' is no longer available; clearing selection."
);
- _selectedModelId = null;
+ SelectedModelId = null;
host.SetSetting("selectedModel", string.Empty);
}
// Auto-load previously selected model in background (don't block app startup).
// Track the task + CTS so DeactivateAsync can cancel and await it instead of
// letting it race back to life and recreate _weights/_context after teardown.
- if (!string.IsNullOrEmpty(_selectedModelId) && IsModelDownloaded(_selectedModelId))
+ // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is.
+ if (!string.IsNullOrEmpty(SelectedModelId) && IsModelDownloaded(SelectedModelId))
{
- var modelId = _selectedModelId;
+ var modelId = SelectedModelId;
_startupCts = new CancellationTokenSource();
var startupCt = _startupCts.Token;
_startupTask = Task.Run(async () =>
@@ -121,6 +143,7 @@ public async Task DeactivateAsync()
{
try
{
+ // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here.
startupCts.Cancel();
}
catch (ObjectDisposedException) { }
@@ -143,6 +166,7 @@ public async Task DeactivateAsync()
// Acquire _inferenceLock so we can't dispose _context/_weights while
// ProcessAsync is mid-inference. Mirrors the unload path in
// SetSettingValueAsync and LoadModelAsync.
+ // ReSharper disable once MethodSupportsCancellation -- short teardown/unload path; adding a cancellation point offers no real value.
await _inferenceLock.WaitAsync().ConfigureAwait(false);
try
{
@@ -161,7 +185,7 @@ public IReadOnlyList GetSettingDefinitions() =>
Key: "selectedModel",
Label: Loc.L("Settings.Model"),
Description: Loc.L("Settings.ModelDescription"),
- Options: Models
+ Options: s_models
.Select(m => new PluginSettingOption(
m.Id,
$"{m.DisplayName} ({m.SizeDescription})"
@@ -180,7 +204,7 @@ public IReadOnlyList GetSettingDefinitions() =>
Task.FromResult(
key switch
{
- "selectedModel" => _selectedModelId,
+ "selectedModel" => SelectedModelId,
LlmStreamingSettings.StreamResponsesSettingKey => _streamResponses ? "true" : "false",
_ => null,
}
@@ -211,7 +235,11 @@ public async Task SetSettingValueAsync(
await _inferenceLock.WaitAsync(ct).ConfigureAwait(false);
try
{
- _selectedModelId = null;
+ lock (_selectionLock)
+ {
+ SelectedModelId = null;
+ }
+
_host?.SetSetting("selectedModel", string.Empty);
UnloadModel();
}
@@ -229,13 +257,13 @@ public async Task SetSettingValueAsync(
public Task ValidateAsync(CancellationToken ct = default)
{
- if (string.IsNullOrWhiteSpace(_selectedModelId))
+ if (string.IsNullOrWhiteSpace(SelectedModelId))
return Task.FromResult(
new PluginSettingsValidationResult(false, Loc.L("Settings.SelectModel"))
);
return Task.FromResult(
- _loadedModelId == _selectedModelId
+ LoadedModelId == SelectedModelId
? new PluginSettingsValidationResult(true, Loc.L("Settings.ModelReady"))
: new PluginSettingsValidationResult(false, Loc.L("Settings.ModelSelectedNotLoaded"))
);
@@ -253,6 +281,7 @@ internal async Task EnsureModelReadyAsync(string modelId, CancellationToken ct)
var progress = new Progress(p =>
{
var pct = (int)(p * 100);
+ // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is.
if (pct != lastPct && pct % 5 == 0)
{
lastPct = pct;
@@ -267,17 +296,10 @@ internal async Task EnsureModelReadyAsync(string modelId, CancellationToken ct)
}
public string ProviderName => "Gemma 4 (Local)";
- public bool IsAvailable => _loadedModelId is not null;
+ public bool IsAvailable => LoadedModelId is not null;
- public IReadOnlyList SupportedModels { get; } =
- Models
- .Select(m => new PluginModelInfo(m.Id, m.DisplayName)
- {
- SizeDescription = m.SizeDescription,
- EstimatedSizeMB = m.EstimatedSizeMB,
- IsRecommended = m.IsRecommended,
- })
- .ToList();
+ public IReadOnlyList SupportedModels =>
+ GetSupportedModels(LoadedModelId);
public async Task ProcessAsync(
string systemPrompt,
@@ -289,6 +311,8 @@ CancellationToken ct
await _inferenceLock.WaitAsync(ct);
try
{
+ _modelRoutingGuard(model, LoadedModelId);
+
if (_context is null || _weights is null)
throw new InvalidOperationException(
"No model loaded. Download and load a model first."
@@ -334,6 +358,8 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct
await _inferenceLock.WaitAsync(ct);
try
{
+ _modelRoutingGuard(model, LoadedModelId);
+
if (_context is null || _weights is null)
throw new InvalidOperationException(
"No model loaded. Download and load a model first."
@@ -364,8 +390,10 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct
}
}
- internal string? SelectedModelId => _selectedModelId;
- internal string? LoadedModelId => _loadedModelId;
+ internal string? SelectedModelId { get; private set; }
+
+ internal string? LoadedModelId { get; private set; }
+
private IPluginLocalization? _injectedLocalization;
public void SetLocalization(IPluginLocalization localization) =>
@@ -375,12 +403,68 @@ public void SetLocalization(IPluginLocalization localization) =>
// injected at load so settings labels/validation resolve even when this
// plugin is disabled (never activated, so _host is null).
internal IPluginLocalization? Loc => _host?.Localization ?? _injectedLocalization;
- internal IReadOnlyList ModelDefinitions => Models;
+ // ReSharper disable once ConvertToAutoPropertyWhenPossible -- expression-bodied accessor returning the shared static list; not an auto-property candidate.
+ internal static IReadOnlyList ModelDefinitions => s_models;
+
+ internal static IReadOnlyList GetSupportedModels(string? loadedModelId)
+ {
+ if (loadedModelId is null)
+ return ImmutableArray.Empty;
+
+ var model = GetModelDefinition(loadedModelId);
+ // ReSharper disable once UseCollectionExpression -- a collection expression targets IReadOnlyList and lowers to a ReadOnlySingleElementList; ImmutableArray.Create keeps the concrete ImmutableArray return type both branches (and the tests) rely on.
+ return ImmutableArray.Create(
+ new PluginModelInfo(model.Id, model.DisplayName)
+ {
+ SizeDescription = model.SizeDescription,
+ EstimatedSizeMB = model.EstimatedSizeMB,
+ IsRecommended = model.IsRecommended,
+ }
+ );
+ }
+
+ internal static void EnsureRequestedModelIsActive(
+ string requestedModelId,
+ string? activeModelId
+ )
+ {
+ if (
+ s_models.All(m =>
+ !string.Equals(m.Id, requestedModelId, StringComparison.Ordinal)
+ )
+ )
+ {
+ throw new InvalidOperationException(
+ $"Requested Gemma model '{requestedModelId}' is unknown; "
+ + $"the active Gemma model is '{activeModelId ?? "(none)"}'."
+ );
+ }
+
+ if (activeModelId is null)
+ {
+ throw new InvalidOperationException(
+ $"Requested Gemma model '{requestedModelId}' cannot run because "
+ + "the active Gemma model is '(none)'."
+ );
+ }
+
+ if (!string.Equals(requestedModelId, activeModelId, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ $"Requested Gemma model '{requestedModelId}' does not match "
+ + $"the active Gemma model '{activeModelId}'."
+ );
+ }
+ }
internal void SelectModel(string modelId)
{
_ = GetModelDefinition(modelId);
- _selectedModelId = modelId;
+ lock (_selectionLock)
+ {
+ SelectedModelId = modelId;
+ }
+
_host?.SetSetting("selectedModel", modelId);
_host?.NotifyCapabilitiesChanged();
}
@@ -456,6 +540,7 @@ CancellationToken ct
bytesRead += read;
var now = DateTime.UtcNow;
+ // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is.
if ((now - lastReport).TotalMilliseconds > 250)
{
progress?.Report((double)bytesRead / totalBytes);
@@ -504,13 +589,13 @@ internal Task LoadModelAsync(string modelId, CancellationToken ct)
// The lock covers the full unload-then-load window so callers can't
// observe a torn state (e.g. _weights set but _context still old).
await _inferenceLock.WaitAsync(ct).ConfigureAwait(false);
- var loaded = false;
+ bool loaded;
try
{
// If the user has switched models OR cleared the selection while we
// were queued behind the lock, abort: a late finish here would
// overwrite the newer state and load a model the user no longer wants.
- if (_selectedModelId != modelId)
+ if (SelectedModelId != modelId)
return;
UnloadModel();
@@ -544,16 +629,18 @@ internal Task LoadModelAsync(string modelId, CancellationToken ct)
// so the user can switch selections while we're loading. If
// that happened, drop what we just loaded instead of letting
// the late finish silently roll back their newer choice.
- if (_selectedModelId != modelId)
+ lock (_selectionLock)
+ {
+ loaded = SelectedModelId == modelId;
+ if (loaded)
+ LoadedModelId = modelId;
+ }
+
+ if (!loaded)
{
UnloadModel();
return;
}
-
- _loadedModelId = modelId;
- _selectedModelId = modelId;
- _host?.SetSetting("selectedModel", modelId);
- loaded = true;
}
finally
{
@@ -576,12 +663,12 @@ internal void UnloadModel()
_context = null;
_weights?.Dispose();
_weights = null;
- _loadedModelId = null;
+ LoadedModelId = null;
}
// Helpers
- private static string FormatGemmaPrompt(string systemPrompt, string userText)
+ internal static string FormatGemmaPrompt(string systemPrompt, string userText)
{
// Gemma instruction-tuned chat format with proper system turn
var sb = new System.Text.StringBuilder();
@@ -591,7 +678,7 @@ private static string FormatGemmaPrompt(string systemPrompt, string userText)
sb.Append("system\n");
sb.Append(systemPrompt).Append('\n');
sb.Append(
- "IMPORTANT: Respond ONLY in the same language as the user's input. Output ONLY the requested result, nothing else. No explanations, no extra text."
+ "Output ONLY the requested result, nothing else. No explanations, no extra text."
);
sb.Append("\n");
}
@@ -610,7 +697,7 @@ private string GetModelFilePath(string modelId, string fileName) =>
Path.Join(GetModelDirectory(modelId), fileName);
private static GemmaModelDefinition GetModelDefinition(string modelId) =>
- Models.FirstOrDefault(m => m.Id == modelId)
+ s_models.FirstOrDefault(m => m.Id == modelId)
?? throw new ArgumentException($"Unknown model: {modelId}");
private void Log(PluginLogLevel level, string message)
@@ -644,6 +731,7 @@ public void Dispose()
// Mirror DeactivateAsync: serialize teardown with any in-flight
// ProcessAsync so we don't dispose _context/_weights mid-inference.
+ // ReSharper disable once MethodSupportsCancellation -- short teardown/unload path; adding a cancellation point offers no real value.
_inferenceLock.Wait();
try
{
@@ -663,6 +751,7 @@ internal sealed record GemmaModelDefinition(
string Id,
string DisplayName,
string SizeDescription,
+ // ReSharper disable once InconsistentNaming -- MB (megabyte) is the correct unit; the suggested Mb means megabit.
int EstimatedSizeMB,
bool IsRecommended,
string DownloadUrl,
diff --git a/plugins/TypeWhisper.Plugin.GemmaLocal/TypeWhisper.Plugin.GemmaLocal.csproj b/plugins/TypeWhisper.Plugin.GemmaLocal/TypeWhisper.Plugin.GemmaLocal.csproj
index 295056e15..be4e8e44a 100644
--- a/plugins/TypeWhisper.Plugin.GemmaLocal/TypeWhisper.Plugin.GemmaLocal.csproj
+++ b/plugins/TypeWhisper.Plugin.GemmaLocal/TypeWhisper.Plugin.GemmaLocal.csproj
@@ -8,6 +8,9 @@
true
+
+
+
diff --git a/plugins/TypeWhisper.Plugin.GemmaLocal/manifest.json b/plugins/TypeWhisper.Plugin.GemmaLocal/manifest.json
index e7f0cd6fe..489b00090 100644
--- a/plugins/TypeWhisper.Plugin.GemmaLocal/manifest.json
+++ b/plugins/TypeWhisper.Plugin.GemmaLocal/manifest.json
@@ -4,6 +4,8 @@
"version": "1.0.0",
"author": "TypeWhisper",
"description": "Run Google Gemma 4 locally via Ollama or any OpenAI-compatible server",
+ "networkAccess": "local",
+ "categories": ["llm"],
"assemblyName": "TypeWhisper.Plugin.GemmaLocal.dll",
"pluginClass": "TypeWhisper.Plugin.GemmaLocal.GemmaLocalPlugin"
}
diff --git a/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs b/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs
index 7bfee8268..71ba111dd 100644
--- a/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs
+++ b/plugins/TypeWhisper.Plugin.Gladia/GladiaPlugin.cs
@@ -1,21 +1,62 @@
-using System.Net.Http;
+// ReSharper disable MemberCanBePrivate.Global
+// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces
+// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire.
+
+using System.Diagnostics;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
using TypeWhisper.PluginSDK;
using TypeWhisper.PluginSDK.Models;
namespace TypeWhisper.Plugin.Gladia;
-public sealed partial class GladiaPlugin : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware
+public sealed class GladiaPlugin : ITranscriptionEnginePlugin, IPluginSettingsProvider, IPluginLocalizationAware
{
- private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(120) };
+ private const string BaseUrl = "https://api.gladia.io";
+
+ private static readonly TimeSpan s_defaultPollDelay = TimeSpan.FromSeconds(1);
+ private static readonly TimeSpan s_defaultPollWindow = TimeSpan.FromMinutes(30);
+
+ private readonly HttpClient _httpClient;
+ private readonly TimeSpan _pollDelay;
+ private readonly TimeSpan _pollWindow;
private IPluginHostServices? _host;
private string? _apiKey;
- private string? _selectedModelId;
- private static readonly IReadOnlyList Models =
+ private static readonly IReadOnlyList s_models =
[
new("default", "Gladia (Auto)"),
];
+ public GladiaPlugin()
+ : this(CreateHttpClient())
+ {
+ }
+
+ internal GladiaPlugin(
+ HttpClient httpClient,
+ TimeSpan? pollDelay = null,
+ TimeSpan? pollWindow = null
+ )
+ {
+ _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ _pollDelay = pollDelay ?? s_defaultPollDelay;
+ _pollWindow = pollWindow ?? s_defaultPollWindow;
+
+ if (_pollDelay < TimeSpan.Zero)
+ throw new ArgumentOutOfRangeException(
+ nameof(pollDelay),
+ "The poll delay cannot be negative."
+ );
+
+ if (_pollWindow < TimeSpan.Zero)
+ throw new ArgumentOutOfRangeException(
+ nameof(pollWindow),
+ "The polling window cannot be negative."
+ );
+ }
+
public string PluginId => "com.typewhisper.gladia";
public string PluginName => "Gladia";
public string PluginVersion => "1.0.0";
@@ -28,7 +69,7 @@ public async Task ActivateAsync(IPluginHostServices host)
// with trailing whitespace while IsConfigured still reports true.
var loaded = await host.LoadSecretAsync("api-key");
_apiKey = string.IsNullOrWhiteSpace(loaded) ? null : loaded.Trim();
- _selectedModelId = host.GetSetting("selectedModel") ?? Models[0].Id;
+ SelectedModelId = host.GetSetting("selectedModel") ?? s_models[0].Id;
host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured})");
}
@@ -42,9 +83,9 @@ public Task DeactivateAsync()
public string ProviderDisplayName => "Gladia";
public bool IsConfigured => !string.IsNullOrEmpty(_apiKey);
- public IReadOnlyList TranscriptionModels => Models;
+ public IReadOnlyList TranscriptionModels => s_models;
- public string? SelectedModelId => _selectedModelId;
+ public string? SelectedModelId { get; private set; }
public bool SupportsTranslation => false;
@@ -60,14 +101,13 @@ public async Task StartStreamingAsync(string? language, Cance
public void SelectModel(string modelId)
{
- if (Models.All(m => m.Id != modelId))
+ if (s_models.All(m => m.Id != modelId))
throw new ArgumentException($"Unknown model: {modelId}");
- _selectedModelId = modelId;
+ SelectedModelId = modelId;
_host?.SetSetting("selectedModel", modelId);
}
- // Batch intentionally throws until Gladia's upload/initiate/poll protocol is implemented.
- public Task TranscribeAsync(
+ public async Task TranscribeAsync(
byte[] wavAudio,
string? language,
bool translate,
@@ -75,10 +115,43 @@ public Task TranscribeAsync(
CancellationToken ct
)
{
- throw new NotSupportedException(
- "Gladia batch transcription is not supported in this build; use live streaming. "
- + "The batch API requires a multi-stage upload/poll protocol that is not yet implemented."
- );
+ if (translate)
+ throw new InvalidOperationException("Gladia does not support translation.");
+
+ // Gladia's pre-recorded request has no prompt equivalent.
+ _ = prompt;
+
+ // Snapshot the key so a concurrent settings change cannot alter a multi-request job.
+ var apiKey = _apiKey;
+ if (string.IsNullOrEmpty(apiKey))
+ throw new InvalidOperationException(Loc.L("Settings.NotConfiguredApiKeyRequired"));
+
+ var audioUrl = await UploadAudioAsync(wavAudio, apiKey, ct);
+ var job = await InitiateTranscriptionAsync(audioUrl, language, apiKey, ct);
+ var terminalJson = await PollUntilTerminalAsync(job, apiKey, ct);
+
+ try
+ {
+ using var terminalDocument = ParseProtocolJson(
+ terminalJson,
+ "polling"
+ );
+ var terminal = terminalDocument.RootElement;
+ var status = RequireString(terminal, "status", "polling");
+
+ if (string.Equals(status, "error", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException(
+ $"Gladia transcription failed: {ExtractProviderDetails(terminal)}"
+ );
+ }
+
+ return ParseCompletedResult(terminal, NormalizeLanguage(language));
+ }
+ finally
+ {
+ await DeleteJobBestEffortAsync(job.Id, apiKey);
+ }
}
public void Dispose()
@@ -86,6 +159,453 @@ public void Dispose()
_httpClient.Dispose();
}
+ private async Task UploadAudioAsync(
+ byte[] wavAudio,
+ string apiKey,
+ CancellationToken ct
+ )
+ {
+ using var multipart = new MultipartFormDataContent();
+ using var audioContent = new ByteArrayContent(wavAudio);
+ audioContent.Headers.ContentType = new MediaTypeHeaderValue("audio/wav");
+ multipart.Add(audioContent, "audio", "audio.wav");
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v2/upload");
+ AddApiKey(request, apiKey);
+ request.Content = multipart;
+
+ var json = await SendJsonAsync(request, "Gladia audio upload", ct);
+ using var document = ParseProtocolJson(json, "audio upload");
+ return RequireString(document.RootElement, "audio_url", "audio upload");
+ }
+
+ private async Task InitiateTranscriptionAsync(
+ string audioUrl,
+ string? language,
+ string apiKey,
+ CancellationToken ct
+ )
+ {
+ var payload = new Dictionary
+ {
+ ["audio_url"] = audioUrl,
+ };
+
+ if (NormalizeLanguage(language) is { } normalizedLanguage)
+ {
+ payload["language_config"] = new Dictionary
+ {
+ ["languages"] = new[] { normalizedLanguage },
+ };
+ }
+
+ using var request = new HttpRequestMessage(
+ HttpMethod.Post,
+ $"{BaseUrl}/v2/pre-recorded"
+ );
+ AddApiKey(request, apiKey);
+ request.Content = new StringContent(
+ JsonSerializer.Serialize(payload),
+ Encoding.UTF8,
+ "application/json"
+ );
+
+ var json = await SendJsonAsync(request, "Gladia transcription initiation", ct);
+ using var document = ParseProtocolJson(json, "transcription initiation");
+ var id = RequireString(document.RootElement, "id", "transcription initiation");
+ var resultUrl = RequireString(
+ document.RootElement,
+ "result_url",
+ "transcription initiation"
+ );
+
+ // Require HTTPS: polling sends x-gladia-key to this URL; non-HTTPS would leak it in plaintext.
+ if (!Uri.TryCreate(resultUrl, UriKind.Absolute, out var resultUri)
+ || resultUri.Scheme != Uri.UriSchemeHttps)
+ {
+ throw new InvalidOperationException(
+ "Gladia transcription initiation response contained an invalid result_url."
+ );
+ }
+
+ return new InitiatedJob(id, resultUri);
+ }
+
+ private async Task PollUntilTerminalAsync(
+ InitiatedJob job,
+ string apiKey,
+ CancellationToken ct
+ )
+ {
+ if (_pollWindow == TimeSpan.Zero)
+ throw PollTimeout(job.Id);
+
+ using var timeoutCts = new CancellationTokenSource(_pollWindow);
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
+ ct,
+ timeoutCts.Token
+ );
+
+ try
+ {
+ while (true)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, job.ResultUrl);
+ AddApiKey(request, apiKey);
+
+ var json = await SendJsonAsync(
+ request,
+ "Gladia transcription polling",
+ linkedCts.Token
+ );
+ using var document = ParseProtocolJson(json, "transcription polling");
+ var status = RequireString(
+ document.RootElement,
+ "status",
+ "transcription polling"
+ );
+
+ switch (status.ToLowerInvariant())
+ {
+ case "done":
+ case "error":
+ return json;
+ case "queued":
+ case "processing":
+ break;
+ default:
+ throw new InvalidOperationException(
+ $"Gladia transcription polling response contained unknown status '{status}'."
+ );
+ }
+
+ if (_pollDelay > TimeSpan.Zero)
+ await Task.Delay(_pollDelay, linkedCts.Token);
+ }
+ }
+ catch (OperationCanceledException)
+ when (!ct.IsCancellationRequested && timeoutCts.IsCancellationRequested)
+ {
+ throw PollTimeout(job.Id);
+ }
+ }
+
+ private async Task SendJsonAsync(
+ HttpRequestMessage request,
+ string operation,
+ CancellationToken ct
+ )
+ {
+ using var response = await _httpClient.SendAsync(request, ct);
+ var json = await response.Content.ReadAsStringAsync(ct);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new HttpRequestException(
+ $"{operation} error {(int)response.StatusCode}: {ExtractProviderDetails(json)}"
+ );
+ }
+
+ return json;
+ }
+
+ private async Task DeleteJobBestEffortAsync(string jobId, string apiKey)
+ {
+ // Cleanup is best-effort and awaited before returning; bound it short
+ // so a stalled DELETE can't hold up the finished result.
+ using var cleanupCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ using var request = new HttpRequestMessage(
+ HttpMethod.Delete,
+ $"{BaseUrl}/v2/pre-recorded/{Uri.EscapeDataString(jobId)}"
+ );
+ AddApiKey(request, apiKey);
+
+ try
+ {
+ using var response = await _httpClient.SendAsync(request, cleanupCts.Token);
+ if (!response.IsSuccessStatusCode)
+ {
+ var responseBody = await response.Content.ReadAsStringAsync(cleanupCts.Token);
+ Trace.TraceWarning(
+ "Gladia cleanup could not delete pre-recorded job "
+ + $"{jobId}: {(int)response.StatusCode} {ExtractProviderDetails(responseBody)}"
+ );
+ }
+ }
+ catch (Exception ex)
+ {
+ Trace.TraceWarning(
+ $"Gladia cleanup could not delete pre-recorded job {jobId}: {ex.Message}"
+ );
+ }
+ }
+
+ private static PluginTranscriptionResult ParseCompletedResult(
+ JsonElement root,
+ string? fallbackLanguage
+ )
+ {
+ if (!root.TryGetProperty("result", out var result)
+ || result.ValueKind != JsonValueKind.Object
+ || !result.TryGetProperty("transcription", out var transcription)
+ || transcription.ValueKind != JsonValueKind.Object)
+ {
+ throw new InvalidOperationException(
+ "Gladia done response did not include result.transcription."
+ );
+ }
+
+ if (!transcription.TryGetProperty("full_transcript", out var transcriptElement)
+ || transcriptElement.ValueKind != JsonValueKind.String
+ || transcriptElement.GetString() is not { } transcript)
+ {
+ throw new InvalidOperationException(
+ "Gladia done response did not include a string result.transcription.full_transcript."
+ );
+ }
+
+ var detectedLanguage = FirstLanguage(transcription);
+ var duration = ReadDuration(root, result);
+ var segments = ReadSegments(transcription, ref duration, ref detectedLanguage);
+ detectedLanguage ??= fallbackLanguage;
+
+ return new PluginTranscriptionResult(
+ transcript.Trim(),
+ detectedLanguage,
+ duration,
+ NoSpeechProbability: null
+ )
+ {
+ Segments = segments,
+ };
+ }
+
+ private static string? FirstLanguage(JsonElement transcription)
+ {
+ if (!transcription.TryGetProperty("languages", out var languages)
+ || languages.ValueKind != JsonValueKind.Array)
+ {
+ return null;
+ }
+
+ // ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator -- LINQ would box JsonElement's struct enumerator.
+ foreach (var language in languages.EnumerateArray())
+ {
+ if (language.ValueKind == JsonValueKind.String
+ && !string.IsNullOrWhiteSpace(language.GetString()))
+ {
+ return language.GetString();
+ }
+ }
+
+ return null;
+ }
+
+ private static double ReadDuration(JsonElement root, JsonElement result)
+ {
+ if (result.TryGetProperty("metadata", out var metadata)
+ && TryGetDouble(metadata, "audio_duration", out var resultDuration))
+ {
+ return resultDuration;
+ }
+
+ if (root.TryGetProperty("file", out var file)
+ && TryGetDouble(file, "audio_duration", out var fileDuration))
+ {
+ return fileDuration;
+ }
+
+ return 0;
+ }
+
+ private static List ReadSegments(
+ JsonElement transcription,
+ ref double duration,
+ ref string? detectedLanguage
+ )
+ {
+ var segments = new List();
+ if (!transcription.TryGetProperty("utterances", out var utterances)
+ || utterances.ValueKind != JsonValueKind.Array)
+ {
+ return segments;
+ }
+
+ foreach (var utterance in utterances.EnumerateArray())
+ {
+ if (utterance.ValueKind != JsonValueKind.Object
+ || !TryGetString(utterance, "text", out var text)
+ || !TryGetDouble(utterance, "start", out var start)
+ || !TryGetDouble(utterance, "end", out var end)
+ || end < start)
+ {
+ continue;
+ }
+
+ if (detectedLanguage is null
+ && TryGetString(utterance, "language", out var utteranceLanguage)
+ && !string.IsNullOrWhiteSpace(utteranceLanguage))
+ {
+ detectedLanguage = utteranceLanguage;
+ }
+
+ segments.Add(new PluginTranscriptionSegment(text, start, end));
+ duration = Math.Max(duration, end);
+ }
+
+ return segments;
+ }
+
+ private static JsonDocument ParseProtocolJson(string json, string operation)
+ {
+ try
+ {
+ return JsonDocument.Parse(json);
+ }
+ catch (JsonException ex)
+ {
+ throw new InvalidOperationException(
+ $"Gladia {operation} response contained invalid JSON.",
+ ex
+ );
+ }
+ }
+
+ private static string RequireString(
+ JsonElement root,
+ string propertyName,
+ string operation
+ )
+ {
+ if (root.TryGetProperty(propertyName, out var property)
+ && property.ValueKind == JsonValueKind.String
+ && !string.IsNullOrWhiteSpace(property.GetString()))
+ {
+ return property.GetString()!;
+ }
+
+ throw new InvalidOperationException(
+ $"Gladia {operation} response did not include a string {propertyName}."
+ );
+ }
+
+ private static bool TryGetString(
+ JsonElement root,
+ string propertyName,
+ out string value
+ )
+ {
+ if (root.TryGetProperty(propertyName, out var property)
+ && property.ValueKind == JsonValueKind.String
+ && property.GetString() is { } stringValue)
+ {
+ value = stringValue;
+ return true;
+ }
+
+ value = string.Empty;
+ return false;
+ }
+
+ private static bool TryGetDouble(
+ JsonElement root,
+ string propertyName,
+ out double value
+ )
+ {
+ if (root.ValueKind == JsonValueKind.Object
+ && root.TryGetProperty(propertyName, out var property)
+ && property.ValueKind == JsonValueKind.Number
+ && property.TryGetDouble(out value))
+ {
+ return true;
+ }
+
+ value = 0;
+ return false;
+ }
+
+ private static string ExtractProviderDetails(string json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return "empty response";
+
+ try
+ {
+ using var document = JsonDocument.Parse(json);
+ return ExtractProviderDetails(document.RootElement);
+ }
+ catch (JsonException)
+ {
+ return json.Trim();
+ }
+ }
+
+ private static string ExtractProviderDetails(JsonElement root)
+ {
+ // TryGetProperty throws on non-objects, so render those bodies directly.
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ return root.ValueKind == JsonValueKind.String
+ ? root.GetString() ?? string.Empty
+ : root.GetRawText();
+ }
+
+ var details = new List();
+ foreach (var propertyName in new[]
+ {
+ "status",
+ "error_code",
+ "error",
+ "error_type",
+ "error_message",
+ "message",
+ "request_id",
+ })
+ {
+ if (!root.TryGetProperty(propertyName, out var value)
+ || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
+ {
+ continue;
+ }
+
+ var rendered = value.ValueKind == JsonValueKind.String
+ ? value.GetString()
+ : value.GetRawText();
+ details.Add($"{propertyName}={rendered}");
+ }
+
+ return details.Count > 0 ? string.Join(", ", details) : root.GetRawText();
+ }
+
+ private static string? NormalizeLanguage(string? language)
+ {
+ var normalized = language?.Trim();
+ return string.IsNullOrEmpty(normalized)
+ || string.Equals(normalized, "auto", StringComparison.OrdinalIgnoreCase)
+ ? null
+ : normalized;
+ }
+
+ private static void AddApiKey(HttpRequestMessage request, string apiKey) =>
+ request.Headers.Add("x-gladia-key", apiKey);
+
+ private TimeoutException PollTimeout(string jobId) =>
+ new(
+ $"Gladia transcription {jobId} did not complete within "
+ + $"{_pollWindow.TotalSeconds:0.###} seconds."
+ );
+
+ private static HttpClient CreateHttpClient() =>
+ new()
+ {
+ Timeout = TimeSpan.FromSeconds(120),
+ };
+
+ private sealed record InitiatedJob(string Id, Uri ResultUrl);
+
private IPluginLocalization? _injectedLocalization;
public void SetLocalization(IPluginLocalization localization) =>
@@ -101,7 +621,7 @@ internal async Task SetApiKeyAsync(string apiKey)
// Trim defensively at the internal entry too: SetSettingValueAsync
// already trims, but a future direct caller could re-introduce
// trailing whitespace that breaks the x-gladia-key header.
- var trimmed = apiKey?.Trim();
+ var trimmed = apiKey.Trim();
_apiKey = string.IsNullOrEmpty(trimmed) ? null : trimmed;
if (_host is not null)
{
@@ -127,7 +647,7 @@ public IReadOnlyList GetSettingDefinitions() =>
"selectedModel",
Loc.L("Settings.TranscriptionModel"),
Description: Loc.L("Settings.ModelDescription"),
- Options: Models.Select(m => new PluginSettingOption(m.Id, m.DisplayName)).ToList()
+ Options: s_models.Select(m => new PluginSettingOption(m.Id, m.DisplayName)).ToList()
),
];
@@ -136,7 +656,7 @@ public IReadOnlyList GetSettingDefinitions() =>
key switch
{
"api-key" => _apiKey,
- "selectedModel" => _selectedModelId,
+ "selectedModel" => SelectedModelId,
_ => null,
}
);
diff --git a/plugins/TypeWhisper.Plugin.Gladia/GladiaStreamingSession.cs b/plugins/TypeWhisper.Plugin.Gladia/GladiaStreamingSession.cs
index f7a6577b2..360f160cc 100644
--- a/plugins/TypeWhisper.Plugin.Gladia/GladiaStreamingSession.cs
+++ b/plugins/TypeWhisper.Plugin.Gladia/GladiaStreamingSession.cs
@@ -1,6 +1,4 @@
using System.Diagnostics;
-using System.IO;
-using System.Net.Http;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
@@ -83,7 +81,7 @@ public async Task FinalizeAsync(CancellationToken ct)
{
try
{
- var stop = Encoding.UTF8.GetBytes("""{"type":"stop_recording"}""");
+ var stop = """{"type":"stop_recording"}"""u8.ToArray();
await _ws.SendAsync(stop, WebSocketMessageType.Text, true, ct);
}
catch (Exception ex) when (ex is WebSocketException or OperationCanceledException)
@@ -260,6 +258,7 @@ private void Emit(StreamingTranscriptEvent evt)
public async ValueTask DisposeAsync()
{
+ // ReSharper disable once MethodHasAsyncOverload -- Cancel() is fine in these teardown paths; CancelAsync() only defers callbacks, with no benefit here.
_receiveCts.Cancel();
if (_ws.State == WebSocketState.Open)
diff --git a/plugins/TypeWhisper.Plugin.Gladia/manifest.json b/plugins/TypeWhisper.Plugin.Gladia/manifest.json
index 70936171b..d5de6412e 100644
--- a/plugins/TypeWhisper.Plugin.Gladia/manifest.json
+++ b/plugins/TypeWhisper.Plugin.Gladia/manifest.json
@@ -4,6 +4,8 @@
"version": "1.0.0",
"author": "TypeWhisper",
"description": "Gladia speech-to-text transcription engine",
+ "networkAccess": "network",
+ "categories": ["transcription"],
"assemblyName": "TypeWhisper.Plugin.Gladia.dll",
"pluginClass": "TypeWhisper.Plugin.Gladia.GladiaPlugin"
}
diff --git a/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs b/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs
index 907fd047a..6e968c270 100644
--- a/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs
+++ b/plugins/TypeWhisper.Plugin.GoogleCloudStt/GoogleCloudSttPlugin.cs
@@ -1,5 +1,9 @@
+// ReSharper disable MemberCanBePrivate.Global
+// ReSharper disable UnusedMember.Global
+// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces
+// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire.
+
using System.Buffers.Binary;
-using System.Net.Http;
using System.Text;
using System.Text.Json;
using TypeWhisper.PluginSDK;
@@ -16,26 +20,37 @@ namespace TypeWhisper.Plugin.GoogleCloudStt;
// credential. That is a research spike, not a drop-in change, so it is parked:
// leaving the plugin as-is until that cost is justified or a streaming reference
// to follow exists.
-public sealed partial class GoogleCloudSttPlugin
+public sealed class GoogleCloudSttPlugin
: ITranscriptionEnginePlugin,
IPluginSettingsProvider,
IPluginLocalizationAware
{
private const string ApiEndpoint = "https://speech.googleapis.com/v1/speech:recognize";
- private const int MaxSyncSeconds = 60;
+ private const int SampleRateHertz = 16000;
+ private const int BytesPerSample = sizeof(short);
+ private const int BytesPerSecond = SampleRateHertz * BytesPerSample;
+ private const int MaxChunkSeconds = 55;
+ private const int MaxChunkBytes = MaxChunkSeconds * BytesPerSecond;
+ private const int BoundarySearchSeconds = 5;
+ private const int BoundarySearchBytes = BoundarySearchSeconds * BytesPerSecond;
+ private const int QuietWindowMilliseconds = 20;
+ private const int QuietWindowBytes =
+ SampleRateHertz * BytesPerSample * QuietWindowMilliseconds / 1000;
private readonly HttpClient _httpClient;
private IPluginHostServices? _host;
private string? _apiKey;
- private string? _selectedModelId;
public GoogleCloudSttPlugin()
: this(new HttpClientHandler()) { }
+ // Bounds each request round trip, not the total segmented transcription. A 55s chunk has
+ // ample headroom for its ~2.3 MB base64 upload; 120s matches the other cloud STT plugins here.
+ private static readonly TimeSpan s_requestTimeout = TimeSpan.FromSeconds(120);
+
// Test seam: lets a stub handler answer requests without hitting the network.
- // Timeout exceeds MaxSyncSeconds because it also covers upload and recognition time.
internal GoogleCloudSttPlugin(HttpMessageHandler handler) =>
- _httpClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(120) };
+ _httpClient = new HttpClient(handler) { Timeout = s_requestTimeout };
public string PluginId => "com.typewhisper.google-cloud-stt";
public string PluginName => "Google Cloud STT";
@@ -45,7 +60,7 @@ public async Task ActivateAsync(IPluginHostServices host)
{
_host = host;
_apiKey = await host.LoadSecretAsync("api-key");
- _selectedModelId = host.GetSetting("selectedModel") ?? "latest_long";
+ SelectedModelId = host.GetSetting("selectedModel") ?? "latest_long";
host.Log(PluginLogLevel.Info, $"Activated (configured={IsConfigured})");
}
@@ -60,16 +75,17 @@ public Task DeactivateAsync()
public bool IsConfigured => !string.IsNullOrEmpty(_apiKey);
public IReadOnlyList TranscriptionModels { get; } =
- [new PluginModelInfo("latest_long", "Google Cloud (Long)")];
+ [new("latest_long", "Google Cloud (Long)")];
+
+ public string? SelectedModelId { get; private set; }
- public string? SelectedModelId => _selectedModelId;
public bool SupportsTranslation => false;
public void SelectModel(string modelId)
{
if (modelId != "latest_long")
throw new ArgumentException($"Unknown model: {modelId}");
- _selectedModelId = modelId;
+ SelectedModelId = modelId;
_host?.SetSetting("selectedModel", modelId);
}
@@ -81,6 +97,17 @@ public async Task TranscribeAsync(
CancellationToken ct
)
{
+ var langCode = language?.Trim();
+ if (
+ string.IsNullOrWhiteSpace(langCode)
+ || string.Equals(langCode, "auto", StringComparison.OrdinalIgnoreCase)
+ )
+ {
+ throw new NotSupportedException(
+ "Google Cloud STT requires an explicit language; automatic language detection is not supported."
+ );
+ }
+
if (!IsConfigured)
throw new InvalidOperationException(Loc.L("Settings.NotConfiguredApiKeyRequired"));
@@ -89,31 +116,69 @@ CancellationToken ct
// locate the data chunk instead of stripping a fixed 44 bytes.
var (pcmOffset, pcmByteCount) = LocatePcmData(wavAudio);
- // Google's sync API caps audio at 60s (long-running API is a follow-up).
- // Ceiling the duration so a just-over-limit clip never displays as "60".
- var durationSeconds = pcmByteCount / 32000.0;
- if (durationSeconds > MaxSyncSeconds)
- {
- throw new NotSupportedException(
- $"Google Cloud STT (synchronous API) supports at most {MaxSyncSeconds} seconds of audio; "
- + $"this recording is {Math.Ceiling(durationSeconds)} seconds. Use a different engine for long recordings."
+ if (pcmByteCount % BytesPerSample != 0)
+ throw new InvalidOperationException(
+ "Google Cloud STT requires sample-aligned 16-bit PCM audio."
);
- }
-
- var audioBase64 = Convert.ToBase64String(wavAudio, pcmOffset, pcmByteCount);
- var langCode = !string.IsNullOrEmpty(language) && language != "auto" ? language : "en-US";
// Google requires BCP-47; the rest of the app uses ISO-639-1 ("en"),
// so expand 2-letter codes to a regional variant before sending.
if (langCode.Length == 2)
langCode = MapToGoogleLanguageCode(langCode);
+ var transcripts = new List();
+ string? detectedLanguage = null;
+ double totalDuration = 0;
+ var chunkOffset = pcmOffset;
+ var pcmEnd = checked(pcmOffset + pcmByteCount);
+
+ // Preserve the existing behavior for an empty payload: it still makes one request.
+ do
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var remaining = pcmEnd - chunkOffset;
+ var chunkByteCount =
+ remaining <= MaxChunkBytes
+ ? remaining
+ : FindQuietBoundary(wavAudio, chunkOffset);
+ var chunkResult = await TranscribeChunkAsync(
+ wavAudio,
+ chunkOffset,
+ chunkByteCount,
+ langCode,
+ ct
+ );
+
+ if (!string.IsNullOrEmpty(chunkResult.Text))
+ transcripts.Add(chunkResult.Text);
+ detectedLanguage ??= chunkResult.DetectedLanguage;
+ totalDuration += chunkResult.DurationSeconds;
+ chunkOffset += chunkByteCount;
+ } while (chunkOffset < pcmEnd);
+
+ return new PluginTranscriptionResult(
+ string.Join(' ', transcripts),
+ detectedLanguage ?? langCode,
+ totalDuration
+ );
+ }
+
+ private async Task TranscribeChunkAsync(
+ byte[] wavAudio,
+ int pcmOffset,
+ int pcmByteCount,
+ string langCode,
+ CancellationToken ct
+ )
+ {
+ var audioBase64 = Convert.ToBase64String(wavAudio, pcmOffset, pcmByteCount);
var requestBody = new
{
config = new
{
encoding = "LINEAR16",
- sampleRateHertz = 16000,
+ sampleRateHertz = SampleRateHertz,
languageCode = langCode,
model = "latest_long",
},
@@ -121,14 +186,58 @@ CancellationToken ct
};
var json = JsonSerializer.Serialize(requestBody);
+ using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, $"{ApiEndpoint}?key={_apiKey}");
- request.Content = new StringContent(json, Encoding.UTF8, "application/json");
+ request.Content = content;
+ // Default ResponseContentRead keeps HttpClient.Timeout covering the response-body read;
+ // ResponseHeadersRead would end the timeout at the headers and let a stalled body hang
+ // when the caller passes CancellationToken.None.
using var response = await _httpClient.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync(ct);
- return ParseResponse(responseJson, langCode);
+ return ParseResponse(responseJson);
+ }
+
+ private static int FindQuietBoundary(byte[] wavAudio, int chunkOffset)
+ {
+ var nominalEnd = chunkOffset + MaxChunkBytes;
+ var searchStart = nominalEnd - BoundarySearchBytes;
+ var quietestWindowStart = nominalEnd - QuietWindowBytes;
+ var quietestScore = long.MaxValue;
+
+ for (
+ var windowStart = searchStart;
+ windowStart + QuietWindowBytes <= nominalEnd;
+ windowStart += QuietWindowBytes
+ )
+ {
+ long score = 0;
+ for (
+ var sampleOffset = windowStart;
+ sampleOffset < windowStart + QuietWindowBytes;
+ sampleOffset += BytesPerSample
+ )
+ {
+ var sample = BinaryPrimitives.ReadInt16LittleEndian(
+ wavAudio.AsSpan(sampleOffset, BytesPerSample)
+ );
+ score += Math.Abs((int)sample);
+ }
+
+ // Prefer the later window when scores tie so uniformly quiet audio stays
+ // as close as possible to the nominal 55-second boundary.
+ // ReSharper disable once InvertIf -- the positive form states the tie-breaking rule described in the comment above.
+ if (score <= quietestScore)
+ {
+ quietestScore = score;
+ quietestWindowStart = windowStart;
+ }
+ }
+
+ // Splitting at the center leaves 10 ms of the quiet window on both chunks.
+ return quietestWindowStart - chunkOffset + QuietWindowBytes / 2;
}
// ffmpeg's piped WAV output writes 0xffffffff placeholder chunk sizes (it
@@ -154,7 +263,7 @@ private static (int Offset, int Length) LocatePcmData(byte[] wavAudio)
}
// Chunks are word-aligned: an odd body is followed by a pad byte.
- var advance = (long)bodyOffset + chunkSize + (chunkSize & 1);
+ var advance = bodyOffset + chunkSize + (chunkSize & 1);
if (advance <= offset || advance > data.Length)
break;
offset = (int)advance;
@@ -166,34 +275,42 @@ private static (int Offset, int Length) LocatePcmData(byte[] wavAudio)
totalLength > 44 ? (44, totalLength - 44) : (0, totalLength);
}
- private static PluginTranscriptionResult ParseResponse(string json, string requestedLanguage)
+ private static ChunkTranscriptionResult ParseResponse(string json)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object)
+ throw InvalidResponse("the root value must be an object");
var sb = new StringBuilder();
- if (
- root.TryGetProperty("results", out var results)
- && results.ValueKind == JsonValueKind.Array
- )
+ if (root.TryGetProperty("results", out var results))
{
+ if (results.ValueKind != JsonValueKind.Array)
+ throw InvalidResponse("'results' must be an array");
+
foreach (var result in results.EnumerateArray())
{
- if (
- result.TryGetProperty("alternatives", out var alternatives)
- && alternatives.ValueKind == JsonValueKind.Array
- )
+ if (result.ValueKind != JsonValueKind.Object)
+ throw InvalidResponse("each result must be an object");
+ if (!result.TryGetProperty("alternatives", out var alternatives))
+ throw InvalidResponse("each result must contain 'alternatives'");
+ if (alternatives.ValueKind != JsonValueKind.Array)
+ throw InvalidResponse("'alternatives' must be an array");
+
+ foreach (var alt in alternatives.EnumerateArray())
{
- foreach (var alt in alternatives.EnumerateArray())
- {
- if (alt.TryGetProperty("transcript", out var transcript))
- {
- if (sb.Length > 0)
- sb.Append(' ');
- sb.Append(transcript.GetString());
- }
- }
+ if (alt.ValueKind != JsonValueKind.Object)
+ throw InvalidResponse("each alternative must be an object");
+ if (
+ !alt.TryGetProperty("transcript", out var transcript)
+ || transcript.ValueKind != JsonValueKind.String
+ )
+ throw InvalidResponse("each alternative must contain a string transcript");
+
+ if (sb.Length > 0)
+ sb.Append(' ');
+ sb.Append(transcript.GetString());
}
}
}
@@ -203,9 +320,12 @@ private static PluginTranscriptionResult ParseResponse(string json, string reque
double duration = 0;
if (root.TryGetProperty("totalBilledTime", out var billedTime))
{
- var billedStr = billedTime.GetString() ?? "";
+ if (billedTime.ValueKind != JsonValueKind.String)
+ throw InvalidResponse("'totalBilledTime' must be a duration string");
+
+ var billedStr = billedTime.GetString() ?? string.Empty;
if (
- billedStr.EndsWith("s")
+ billedStr.EndsWith('s')
&& double.TryParse(
billedStr[..^1],
System.Globalization.NumberStyles.Float,
@@ -216,9 +336,14 @@ out var secs
{
duration = secs;
}
+ else
+ {
+ throw InvalidResponse("'totalBilledTime' must be a duration string");
+ }
}
string? detectedLang = null;
+ // ReSharper disable once InvertIf -- inverting would duplicate the multi-argument return below; kept nested for clarity.
if (
root.TryGetProperty("results", out var resultsForLang)
&& resultsForLang.ValueKind == JsonValueKind.Array
@@ -226,17 +351,27 @@ out var secs
)
{
var first = resultsForLang[0];
+ // ReSharper disable once InvertIf -- the positive TryGetProperty form reads better than an inverted skip.
if (first.TryGetProperty("languageCode", out var lc))
+ {
+ if (lc.ValueKind != JsonValueKind.String)
+ throw InvalidResponse("'languageCode' must be a string");
detectedLang = lc.GetString();
+ }
}
- return new PluginTranscriptionResult(
- sb.ToString().Trim(),
- detectedLang ?? requestedLanguage,
- duration
- );
+ return new ChunkTranscriptionResult(sb.ToString().Trim(), detectedLang, duration);
+
+ static InvalidOperationException InvalidResponse(string detail) =>
+ new($"Invalid Google Cloud STT response: {detail}.");
}
+ private sealed record ChunkTranscriptionResult(
+ string Text,
+ string? DetectedLanguage,
+ double DurationSeconds
+ );
+
private static string MapToGoogleLanguageCode(string iso) =>
iso.ToLowerInvariant() switch
{
@@ -312,7 +447,7 @@ public IReadOnlyList GetSettingDefinitions() =>
key switch
{
"api-key" => _apiKey,
- "selectedModel" => _selectedModelId,
+ "selectedModel" => SelectedModelId,
_ => null,
}
);
diff --git a/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/de.json b/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/de.json
index c4e097b48..6764999e3 100644
--- a/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/de.json
+++ b/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/de.json
@@ -5,5 +5,5 @@
"Settings.ModelDescription": "Wählen Sie das Google Cloud STT-Modell.",
"Settings.NotConfiguredApiKeyRequired": "Plugin nicht konfiguriert. API-Schlüssel erforderlich.",
"Manifest.Name": "Google Cloud STT",
- "Manifest.Description": "Google Cloud Speech-to-Text v2 Transkription"
+ "Manifest.Description": "Transkription mit Google Cloud Speech-to-Text"
}
diff --git a/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/en.json b/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/en.json
index b941221be..57925d7d4 100644
--- a/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/en.json
+++ b/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/en.json
@@ -5,5 +5,5 @@
"Settings.ModelDescription": "Choose the Google Cloud STT model.",
"Settings.NotConfiguredApiKeyRequired": "Plugin not configured. API key required.",
"Manifest.Name": "Google Cloud STT",
- "Manifest.Description": "Google Cloud Speech-to-Text v2 transcription"
+ "Manifest.Description": "Transcription with Google Cloud Speech-to-Text"
}
diff --git a/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/es.json b/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/es.json
index 755fb8619..871b40dfc 100644
--- a/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/es.json
+++ b/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/es.json
@@ -5,5 +5,5 @@
"Settings.ModelDescription": "Elige el modelo de Google Cloud STT.",
"Settings.NotConfiguredApiKeyRequired": "Plugin no configurado. Se requiere la clave de API.",
"Manifest.Name": "Google Cloud STT",
- "Manifest.Description": "Transcripción con Google Cloud Speech-to-Text v2"
+ "Manifest.Description": "Transcripción con Google Cloud Speech-to-Text"
}
diff --git a/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/ru.json b/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/ru.json
index 4eb61bed9..03970fa8c 100644
--- a/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/ru.json
+++ b/plugins/TypeWhisper.Plugin.GoogleCloudStt/Localization/ru.json
@@ -5,5 +5,5 @@
"Settings.ModelDescription": "Выберите модель Google Cloud STT.",
"Settings.NotConfiguredApiKeyRequired": "Плагин не настроен. Требуется API-ключ.",
"Manifest.Name": "Google Cloud STT",
- "Manifest.Description": "Транскрипция Google Cloud Speech-to-Text v2"
+ "Manifest.Description": "Транскрипция с помощью Google Cloud Speech-to-Text"
}
diff --git a/plugins/TypeWhisper.Plugin.GoogleCloudStt/manifest.json b/plugins/TypeWhisper.Plugin.GoogleCloudStt/manifest.json
index 191ed356a..744a1b6be 100644
--- a/plugins/TypeWhisper.Plugin.GoogleCloudStt/manifest.json
+++ b/plugins/TypeWhisper.Plugin.GoogleCloudStt/manifest.json
@@ -3,7 +3,9 @@
"name": "Google Cloud STT",
"version": "1.0.0",
"author": "TypeWhisper",
- "description": "Google Cloud Speech-to-Text v2 transcription",
+ "description": "Transcription with Google Cloud Speech-to-Text",
+ "networkAccess": "network",
+ "categories": ["transcription"],
"assemblyName": "TypeWhisper.Plugin.GoogleCloudStt.dll",
"pluginClass": "TypeWhisper.Plugin.GoogleCloudStt.GoogleCloudSttPlugin"
}
diff --git a/plugins/TypeWhisper.Plugin.Groq/GroqPlugin.cs b/plugins/TypeWhisper.Plugin.Groq/GroqPlugin.cs
index d77a134f7..e459b6c3a 100644
--- a/plugins/TypeWhisper.Plugin.Groq/GroqPlugin.cs
+++ b/plugins/TypeWhisper.Plugin.Groq/GroqPlugin.cs
@@ -1,4 +1,9 @@
-using System.Net.Http;
+// ReSharper disable MemberCanBePrivate.Global
+// ReSharper disable NotAccessedPositionalProperty.Global
+// ReSharper disable UnusedMember.Global
+// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces
+// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire.
+
using System.Net.Http.Headers;
using System.Text.Json;
using TypeWhisper.PluginSDK;
@@ -7,7 +12,7 @@
namespace TypeWhisper.Plugin.Groq;
-public sealed partial class GroqPlugin
+public sealed class GroqPlugin
: ITranscriptionEnginePlugin,
ILlmProviderPlugin,
IPluginSettingsProvider,
@@ -16,14 +21,11 @@ public sealed partial class GroqPlugin
private const string BaseUrl = "https://api.groq.com/openai";
private readonly HttpClient _httpClient;
private IPluginHostServices? _host;
- private string? _apiKey;
- private string? _selectedModelId;
private string? _selectedApiModelName;
- private string? _selectedLlmModelId;
private List _fetchedLlmModels = [];
private bool _streamResponses = true;
- private static readonly IReadOnlyList TranscriptionModelEntries =
+ private static readonly IReadOnlyList s_transcriptionModelEntries =
[
new("whisper-large-v3", "Whisper Large V3", "whisper-large-v3", SupportsTranslation: true),
new(
@@ -34,7 +36,7 @@ public sealed partial class GroqPlugin
),
];
- private static readonly IReadOnlyList FallbackLlmModels =
+ private static readonly IReadOnlyList s_fallbackLlmModels =
[
new("llama-3.3-70b-versatile", "Llama 3.3 70B"),
new("llama-3.1-8b-instant", "Llama 3.1 8B"),
@@ -58,19 +60,19 @@ internal GroqPlugin(HttpClient httpClient)
public async Task ActivateAsync(IPluginHostServices host)
{
_host = host;
- _apiKey = await host.LoadSecretAsync("api-key");
- _selectedModelId =
- host.GetSetting("selectedModel") ?? TranscriptionModelEntries[0].Id;
- _selectedLlmModelId = host.GetSetting("selectedLlmModel");
+ ApiKey = await host.LoadSecretAsync("api-key");
+ SelectedModelId =
+ host.GetSetting("selectedModel") ?? s_transcriptionModelEntries[0].Id;
+ SelectedLlmModelId = host.GetSetting("selectedLlmModel");
_fetchedLlmModels = NormalizeFetchedLlmModels(
host.GetSetting>("fetchedLlmModels") ?? []
);
_streamResponses = host.GetSetting(LlmStreamingSettings.StreamResponsesSettingKey) ?? true;
var selectedTranscription =
- TranscriptionModelEntries.FirstOrDefault(m => m.Id == _selectedModelId)
- ?? TranscriptionModelEntries[0];
- _selectedModelId = selectedTranscription.Id;
+ s_transcriptionModelEntries.FirstOrDefault(m => m.Id == SelectedModelId)
+ ?? s_transcriptionModelEntries[0];
+ SelectedModelId = selectedTranscription.Id;
_selectedApiModelName = selectedTranscription.ApiModelName;
NormalizeSelectedLlmModel();
@@ -85,20 +87,20 @@ public Task DeactivateAsync()
public string ProviderId => "groq";
public string ProviderDisplayName => "Groq";
- public bool IsConfigured => !string.IsNullOrEmpty(_apiKey);
+ public bool IsConfigured => !string.IsNullOrEmpty(ApiKey);
public IReadOnlyList TranscriptionModels { get; } =
- TranscriptionModelEntries.Select(m => new PluginModelInfo(m.Id, m.DisplayName)).ToList();
+ s_transcriptionModelEntries.Select(m => new PluginModelInfo(m.Id, m.DisplayName)).ToList();
- public string? SelectedModelId => _selectedModelId;
+ public string? SelectedModelId { get; private set; }
public bool SupportsTranslation
{
get
{
- if (!IsConfigured || _selectedModelId is null)
+ if (!IsConfigured || SelectedModelId is null)
return false;
- var entry = TranscriptionModelEntries.FirstOrDefault(m => m.Id == _selectedModelId);
+ var entry = s_transcriptionModelEntries.FirstOrDefault(m => m.Id == SelectedModelId);
return entry?.SupportsTranslation ?? false;
}
}
@@ -106,9 +108,9 @@ public bool SupportsTranslation
public void SelectModel(string modelId)
{
var entry =
- TranscriptionModelEntries.FirstOrDefault(m => m.Id == modelId)
+ s_transcriptionModelEntries.FirstOrDefault(m => m.Id == modelId)
?? throw new ArgumentException($"Unknown model: {modelId}");
- _selectedModelId = modelId;
+ SelectedModelId = modelId;
_selectedApiModelName = entry.ApiModelName;
_host?.SetSetting("selectedModel", modelId);
}
@@ -129,7 +131,7 @@ CancellationToken ct
return await OpenAiTranscriptionHelper.TranscribeAsync(
_httpClient,
BaseUrl,
- _apiKey!,
+ ApiKey!,
_selectedApiModelName,
wavAudio,
language,
@@ -146,7 +148,7 @@ CancellationToken ct
public IReadOnlyList SupportedModels =>
_fetchedLlmModels.Count > 0
? _fetchedLlmModels.Select(m => new PluginModelInfo(m.Id, m.Id)).ToList()
- : FallbackLlmModels;
+ : s_fallbackLlmModels;
public async Task ProcessAsync(
string systemPrompt,
@@ -162,7 +164,7 @@ CancellationToken ct
return await OpenAiChatHelper.SendChatCompletionAsync(
_httpClient,
BaseUrl,
- _apiKey!,
+ ApiKey!,
modelId,
systemPrompt,
userText,
@@ -190,18 +192,19 @@ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct
var source = OpenAiChatHelper.SendChatCompletionStreamingAsync(
_httpClient,
BaseUrl,
- _apiKey!,
+ ApiKey!,
modelId,
systemPrompt,
userText,
ct
);
- await foreach (var delta in source.WithCancellation(ct))
+ await foreach (var delta in source)
yield return delta;
}
- internal string? ApiKey => _apiKey;
+ internal string? ApiKey { get; private set; }
+
private IPluginLocalization? _injectedLocalization;
public void SetLocalization(IPluginLocalization localization) =>
@@ -211,16 +214,17 @@ public void SetLocalization(IPluginLocalization localization) =>
// injected at load so settings labels/validation resolve even when this
// plugin is disabled (never activated, so _host is null).
internal IPluginLocalization? Loc => _host?.Localization ?? _injectedLocalization;
- internal string? SelectedLlmModelId => _selectedLlmModelId;
+ internal string? SelectedLlmModelId { get; private set; }
+
internal IReadOnlyList FetchedLlmModels => _fetchedLlmModels;
internal async Task SetApiKeyAsync(string apiKey)
{
var normalizedApiKey = string.IsNullOrWhiteSpace(apiKey) ? null : apiKey.Trim();
var wasConfigured = IsConfigured;
- var changed = !string.Equals(_apiKey, normalizedApiKey, StringComparison.Ordinal);
+ var changed = !string.Equals(ApiKey, normalizedApiKey, StringComparison.Ordinal);
- _apiKey = normalizedApiKey;
+ ApiKey = normalizedApiKey;
if (_host is not null)
{
if (normalizedApiKey is null)
@@ -231,9 +235,9 @@ internal async Task SetApiKeyAsync(string apiKey)
if (changed)
{
_fetchedLlmModels = [];
- _selectedLlmModelId = null;
+ SelectedLlmModelId = null;
_host.SetSetting("fetchedLlmModels", _fetchedLlmModels);
- _host.SetSetting("selectedLlmModel", _selectedLlmModelId);
+ _host.SetSetting("selectedLlmModel", SelectedLlmModelId);
NormalizeSelectedLlmModel();
if (wasConfigured != IsConfigured)
@@ -244,7 +248,7 @@ internal async Task SetApiKeyAsync(string apiKey)
internal void SelectLlmModel(string modelId)
{
- _selectedLlmModelId = modelId;
+ SelectedLlmModelId = modelId;
_host?.SetSetting("selectedLlmModel", modelId);
}
@@ -260,8 +264,8 @@ internal void SetFetchedLlmModels(List models)
internal async Task?> FetchLlmModelsAsync(CancellationToken ct = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/v1/models");
- if (!string.IsNullOrEmpty(_apiKey))
- request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
+ if (!string.IsNullOrEmpty(ApiKey))
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ApiKey);
try
{
@@ -338,7 +342,7 @@ internal static bool IsLlmModel(string id)
internal string ResolveLlmModelId(string? requestedModel) =>
!string.IsNullOrWhiteSpace(requestedModel)
? requestedModel
- : _selectedLlmModelId ?? SupportedModels[0].Id;
+ : SelectedLlmModelId ?? SupportedModels[0].Id;
private void NormalizeSelectedLlmModel()
{
@@ -346,12 +350,12 @@ private void NormalizeSelectedLlmModel()
SupportedModels.Select(m => m.Id),
StringComparer.OrdinalIgnoreCase
);
- if (_selectedLlmModelId is not null && availableIds.Contains(_selectedLlmModelId))
+ if (SelectedLlmModelId is not null && availableIds.Contains(SelectedLlmModelId))
return;
- _selectedLlmModelId = (SupportedModels.Count > 0 ? SupportedModels[0] : null)?.Id;
- if (_selectedLlmModelId is not null)
- _host?.SetSetting("selectedLlmModel", _selectedLlmModelId);
+ SelectedLlmModelId = (SupportedModels.Count > 0 ? SupportedModels[0] : null)?.Id;
+ if (SelectedLlmModelId is not null)
+ _host?.SetSetting("selectedLlmModel", SelectedLlmModelId);
}
private static List NormalizeFetchedLlmModels(
@@ -383,7 +387,7 @@ public IReadOnlyList GetSettingDefinitions() =>
Key: "selectedModel",
Label: Loc.L("Settings.TranscriptionModel"),
Description: Loc.L("Settings.TranscriptionModelDescription"),
- Options: TranscriptionModelEntries
+ Options: s_transcriptionModelEntries
.Select(m => new PluginSettingOption(m.Id, m.DisplayName))
.ToList()
),
@@ -409,9 +413,9 @@ public IReadOnlyList GetSettingDefinitions() =>
Task.FromResult(
key switch
{
- "api-key" => _apiKey,
- "selectedModel" => _selectedModelId,
- "selectedLlmModel" => _selectedLlmModelId,
+ "api-key" => ApiKey,
+ "selectedModel" => SelectedModelId,
+ "selectedLlmModel" => SelectedLlmModelId,
LlmStreamingSettings.StreamResponsesSettingKey
=> _streamResponses ? "true" : "false",
_ => null,
@@ -454,14 +458,15 @@ private static bool ParseBool(string? value) =>
public async Task ValidateAsync(CancellationToken ct = default)
{
- if (string.IsNullOrWhiteSpace(_apiKey))
+ if (string.IsNullOrWhiteSpace(ApiKey))
return new PluginSettingsValidationResult(false, Loc.L("Settings.EnterApiKey"));
- var valid = await ValidateApiKeyAsync(_apiKey, ct);
+ var valid = await ValidateApiKeyAsync(ApiKey, ct);
if (!valid)
return new PluginSettingsValidationResult(false, Loc.L("Settings.ApiKeyInvalid"));
var models = await FetchLlmModelsAsync(ct);
+ // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is.
if (models is not null)
{
SetFetchedLlmModels(models);
diff --git a/plugins/TypeWhisper.Plugin.Groq/manifest.json b/plugins/TypeWhisper.Plugin.Groq/manifest.json
index fdbc4e6ca..77cfea6c6 100644
--- a/plugins/TypeWhisper.Plugin.Groq/manifest.json
+++ b/plugins/TypeWhisper.Plugin.Groq/manifest.json
@@ -4,6 +4,8 @@
"version": "1.0.3",
"author": "TypeWhisper",
"description": "Groq Whisper transcription and Llama translation",
+ "networkAccess": "network",
+ "categories": ["transcription", "llm"],
"assemblyName": "TypeWhisper.Plugin.Groq.dll",
"pluginClass": "TypeWhisper.Plugin.Groq.GroqPlugin"
}
diff --git a/plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs b/plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs
index fef980866..4bd69b674 100644
--- a/plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs
+++ b/plugins/TypeWhisper.Plugin.Linear/LinearPlugin.cs
@@ -1,4 +1,9 @@
-using System.Net.Http;
+// ReSharper disable MemberCanBePrivate.Global
+// ReSharper disable UnusedMember.Global
+// ReSharper disable UnusedType.Global
+// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces
+// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire.
+
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
@@ -9,7 +14,7 @@
namespace TypeWhisper.Plugin.Linear;
-public sealed partial class LinearPlugin : IActionPlugin, IPluginSettingsProvider, IPluginLocalizationAware
+public sealed class LinearPlugin : IActionPlugin, IPluginSettingsProvider, IPluginLocalizationAware
{
private static readonly JsonSerializerOptions s_jsonOptions = new()
{
@@ -17,22 +22,30 @@ public sealed partial class LinearPlugin : IActionPlugin, IPluginSettingsProvide
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
- private readonly HttpClient _httpClient = new();
- private IPluginHostServices? _host;
- private string? _apiKey;
- private string? _defaultTeamId;
- private string? _defaultProjectId;
+ private readonly HttpClient _httpClient;
private List _cachedTeams = [];
+ public LinearPlugin()
+ : this(new HttpClient())
+ {
+ }
+
+ internal LinearPlugin(HttpClient httpClient)
+ {
+ _httpClient = httpClient;
+ }
+
public string PluginId => "com.typewhisper.linear";
public string PluginName => "Linear";
public string PluginVersion => "1.0.0";
public string ActionId => "create-linear-issue";
public string ActionName => "Create Linear Issue";
+ // ReSharper disable once ReturnTypeCanBeNotNullable -- matches the interface contract, which declares this member nullable.
public string? ActionIcon => "\U0001F4CB";
- public IPluginHostServices? Host => _host;
+ public IPluginHostServices? Host { get; private set; }
+
private IPluginLocalization? _injectedLocalization;
public void SetLocalization(IPluginLocalization localization) =>
@@ -41,17 +54,19 @@ public void SetLocalization(IPluginLocalization localization) =>
// Prefer the host's localization once activated; fall back to the catalog
// injected at load so settings labels/validation resolve even when this
// plugin is disabled (never activated, so _host is null).
- internal IPluginLocalization? Loc => _host?.Localization ?? _injectedLocalization;
- public string? ApiKey => _apiKey;
- public string? DefaultTeamId => _defaultTeamId;
- public string? DefaultProjectId => _defaultProjectId;
+ internal IPluginLocalization? Loc => Host?.Localization ?? _injectedLocalization;
+ public string? ApiKey { get; private set; }
+
+ public string? DefaultTeamId { get; private set; }
+
+ public string? DefaultProjectId { get; private set; }
public async Task ActivateAsync(IPluginHostServices host)
{
- _host = host;
- _apiKey = await host.LoadSecretAsync("api-key");
- _defaultTeamId = host.GetSetting("default-team-id");
- _defaultProjectId = host.GetSetting("default-project-id");
+ Host = host;
+ ApiKey = await host.LoadSecretAsync("api-key");
+ DefaultTeamId = host.GetSetting("default-team-id");
+ DefaultProjectId = host.GetSetting("default-project-id");
var cachedTeamsJson = host.GetSetting("cached-teams");
if (!string.IsNullOrWhiteSpace(cachedTeamsJson))
{
@@ -81,7 +96,7 @@ public async Task ActivateAsync(IPluginHostServices host)
public Task DeactivateAsync()
{
- _host?.Log(PluginLogLevel.Info, "Linear plugin deactivated");
+ Host?.Log(PluginLogLevel.Info, "Linear plugin deactivated");
return Task.CompletedTask;
}
@@ -91,24 +106,23 @@ public async Task ExecuteAsync(
CancellationToken ct
)
{
- if (string.IsNullOrWhiteSpace(_apiKey))
+ if (string.IsNullOrWhiteSpace(ApiKey))
return new ActionResult(
false,
Loc.L("Settings.ApiKeyNotConfigured")
);
- if (string.IsNullOrWhiteSpace(_defaultTeamId))
+ if (string.IsNullOrWhiteSpace(DefaultTeamId))
return new ActionResult(
false,
Loc.L("Settings.DefaultTeamNotConfigured")
);
var title = ExtractTitle(input);
- var description = input;
try
{
- var issueUrl = await CreateIssueAsync(title, description, ct);
+ var issueUrl = await CreateIssueAsync(title, input, ct);
if (issueUrl is not null)
return new ActionResult(
@@ -129,41 +143,41 @@ CancellationToken ct
}
catch (Exception ex)
{
- _host?.Log(PluginLogLevel.Error, $"Failed to create Linear issue: {ex.Message}");
+ Host?.Log(PluginLogLevel.Error, $"Failed to create Linear issue: {ex.Message}");
return new ActionResult(false, Loc.L("Settings.IssueCreateError", ex.Message));
}
}
public async Task SaveApiKeyAsync(string apiKey)
{
- if (_host is null)
+ if (Host is null)
return;
- _apiKey = string.IsNullOrWhiteSpace(apiKey) ? null : apiKey.Trim();
+ ApiKey = string.IsNullOrWhiteSpace(apiKey) ? null : apiKey.Trim();
if (string.IsNullOrWhiteSpace(apiKey))
- await _host.DeleteSecretAsync("api-key");
+ await Host.DeleteSecretAsync("api-key");
else
- await _host.StoreSecretAsync("api-key", apiKey.Trim());
+ await Host.StoreSecretAsync("api-key", apiKey.Trim());
- _host.NotifyCapabilitiesChanged();
- _host.Log(PluginLogLevel.Info, "Linear API key saved");
+ Host.NotifyCapabilitiesChanged();
+ Host.Log(PluginLogLevel.Info, "Linear API key saved");
}
public void SaveDefaultTeamId(string teamId)
{
- _defaultTeamId = string.IsNullOrWhiteSpace(teamId) ? null : teamId.Trim();
- _host?.SetSetting("default-team-id", _defaultTeamId ?? "");
+ DefaultTeamId = string.IsNullOrWhiteSpace(teamId) ? null : teamId.Trim();
+ Host?.SetSetting("default-team-id", DefaultTeamId ?? "");
}
public void SaveDefaultProjectId(string projectId)
{
- _defaultProjectId = string.IsNullOrWhiteSpace(projectId) ? null : projectId.Trim();
- _host?.SetSetting("default-project-id", _defaultProjectId ?? "");
+ DefaultProjectId = string.IsNullOrWhiteSpace(projectId) ? null : projectId.Trim();
+ Host?.SetSetting("default-project-id", DefaultProjectId ?? "");
}
public async Task> FetchTeamsAsync(CancellationToken ct = default)
{
- if (string.IsNullOrWhiteSpace(_apiKey))
+ if (string.IsNullOrWhiteSpace(ApiKey))
return [];
const string query = """
@@ -187,6 +201,7 @@ public async Task> FetchTeamsAsync(CancellationToken ct = defau
var data = response.Value.GetProperty("data").GetProperty("teams").GetProperty("nodes");
var teams = new List();
+ // ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator -- explicit loop kept; the LINQ form switches enumerators.
foreach (var node in data.EnumerateArray())
{
teams.Add(
@@ -202,7 +217,7 @@ public async Task> FetchTeamsAsync(CancellationToken ct = defau
_cachedTeams = teams;
try
{
- _host?.SetSetting("cached-teams", JsonSerializer.Serialize(teams, s_jsonOptions));
+ Host?.SetSetting("cached-teams", JsonSerializer.Serialize(teams, s_jsonOptions));
}
catch
{
@@ -213,7 +228,7 @@ public async Task> FetchTeamsAsync(CancellationToken ct = defau
}
catch (Exception ex)
{
- _host?.Log(PluginLogLevel.Warning, $"Failed to parse teams response: {ex.Message}");
+ Host?.Log(PluginLogLevel.Warning, $"Failed to parse teams response: {ex.Message}");
return [];
}
}
@@ -228,11 +243,11 @@ CancellationToken ct
{
["title"] = title,
["description"] = description,
- ["teamId"] = _defaultTeamId,
+ ["teamId"] = DefaultTeamId,
};
- if (!string.IsNullOrWhiteSpace(_defaultProjectId))
- variables["projectId"] = _defaultProjectId;
+ if (!string.IsNullOrWhiteSpace(DefaultProjectId))
+ variables["projectId"] = DefaultProjectId;
const string mutation = """
mutation IssueCreate($title: String!, $description: String, $teamId: String!, $projectId: String) {
@@ -263,7 +278,7 @@ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $p
if (!success)
{
- _host?.Log(
+ Host?.Log(
PluginLogLevel.Warning,
"Linear API returned success=false for issueCreate"
);
@@ -274,12 +289,12 @@ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $p
var url = issue.GetProperty("url").GetString();
var identifier = issue.GetProperty("identifier").GetString();
- _host?.Log(PluginLogLevel.Info, $"Created Linear issue {identifier}");
+ Host?.Log(PluginLogLevel.Info, $"Created Linear issue {identifier}");
return url;
}
catch (Exception ex)
{
- _host?.Log(
+ Host?.Log(
PluginLogLevel.Warning,
$"Failed to parse issue creation response: {ex.Message}"
);
@@ -305,7 +320,7 @@ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $p
"https://api.linear.app/graphql"
);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
- request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ApiKey);
HttpResponseMessage response;
try
@@ -324,7 +339,7 @@ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $p
// HttpClient.Timeout (not caller cancellation) surfaces as
// TaskCanceledException — treat as transport failure.
var fingerprint = ShortFingerprint(ex.ToString());
- _host?.Log(
+ Host?.Log(
PluginLogLevel.Error,
$"Linear API request timed out (sha256:{fingerprint})"
);
@@ -333,7 +348,7 @@ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $p
catch (HttpRequestException ex)
{
var fingerprint = ShortFingerprint(ex.ToString());
- _host?.Log(
+ Host?.Log(
PluginLogLevel.Error,
$"Linear API transport error: {ex.Message} (sha256:{fingerprint})"
);
@@ -357,17 +372,18 @@ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $p
{
throw;
}
+ // ReSharper disable once MergeIntoLogicalPattern -- subjective style; kept as-is.
catch (Exception ex) when (ex is HttpRequestException || ex is OperationCanceledException)
{
var fp = ShortFingerprint(ex.ToString());
- _host?.Log(
+ Host?.Log(
PluginLogLevel.Error,
$"Linear API error {(int)response.StatusCode}; could not read body: {ex.Message} (sha256:{fp})"
);
return null;
}
var fingerprint = ShortFingerprint(errorBody);
- _host?.Log(
+ Host?.Log(
PluginLogLevel.Error,
$"Linear API error {(int)response.StatusCode} (body length={errorBody.Length}, sha256:{fingerprint})"
);
@@ -383,10 +399,11 @@ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $p
{
throw;
}
+ // ReSharper disable once MergeIntoLogicalPattern -- subjective style; kept as-is.
catch (Exception ex) when (ex is HttpRequestException || ex is OperationCanceledException)
{
var fingerprint = ShortFingerprint(ex.ToString());
- _host?.Log(
+ Host?.Log(
PluginLogLevel.Error,
$"Linear API response read failed: {ex.Message} (sha256:{fingerprint})"
);
@@ -397,6 +414,7 @@ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $p
{
using var doc = JsonDocument.Parse(responseJson);
+ // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is.
if (doc.RootElement.TryGetProperty("errors", out var errors))
{
// GraphQL error arrays should contain { "message": "..." } objects, but
@@ -421,14 +439,14 @@ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $p
// reports without spilling user content into traces.
var raw = errors.GetRawText();
var fingerprint = ShortFingerprint(raw);
- _host?.Log(
+ Host?.Log(
PluginLogLevel.Error,
$"Linear GraphQL error: {{redacted:length={raw.Length}, sha256:{fingerprint}}}"
);
}
else
{
- _host?.Log(PluginLogLevel.Error, $"Linear GraphQL error: {errorMsg}");
+ Host?.Log(PluginLogLevel.Error, $"Linear GraphQL error: {errorMsg}");
}
return null;
@@ -443,7 +461,7 @@ mutation IssueCreate($title: String!, $description: String, $teamId: String!, $p
// log the parse failure plus a short fingerprint (not the raw body —
// it may echo user content), then return null so callers recover.
var fingerprint = ShortFingerprint(responseJson);
- _host?.Log(
+ Host?.Log(
PluginLogLevel.Error,
$"Linear API returned non-JSON body ({ex.Message}). Body length={responseJson.Length}, sha256:{fingerprint}"
);
@@ -507,9 +525,9 @@ public IReadOnlyList GetSettingDefinitions() =>
Task.FromResult(
key switch
{
- "api-key" => _apiKey,
- "default-team-id" => _defaultTeamId,
- "default-project-id" => _defaultProjectId,
+ "api-key" => ApiKey,
+ "default-team-id" => DefaultTeamId,
+ "default-project-id" => DefaultProjectId,
_ => null,
}
);
@@ -536,7 +554,7 @@ public async Task SetSettingValueAsync(
public async Task ValidateAsync(CancellationToken ct = default)
{
- if (string.IsNullOrWhiteSpace(_apiKey))
+ if (string.IsNullOrWhiteSpace(ApiKey))
return new PluginSettingsValidationResult(false, Loc.L("Settings.EnterApiKey"));
var teams = await FetchTeamsAsync(ct);
diff --git a/plugins/TypeWhisper.Plugin.Linear/TypeWhisper.Plugin.Linear.csproj b/plugins/TypeWhisper.Plugin.Linear/TypeWhisper.Plugin.Linear.csproj
index 612e2a453..ed4b04ffa 100644
--- a/plugins/TypeWhisper.Plugin.Linear/TypeWhisper.Plugin.Linear.csproj
+++ b/plugins/TypeWhisper.Plugin.Linear/TypeWhisper.Plugin.Linear.csproj
@@ -6,6 +6,9 @@
latest
TypeWhisper.Plugin.Linear
+
+
+
diff --git a/plugins/TypeWhisper.Plugin.Linear/manifest.json b/plugins/TypeWhisper.Plugin.Linear/manifest.json
index 5f3207bd2..058ab9393 100644
--- a/plugins/TypeWhisper.Plugin.Linear/manifest.json
+++ b/plugins/TypeWhisper.Plugin.Linear/manifest.json
@@ -4,6 +4,8 @@
"version": "1.0.0",
"author": "TypeWhisper",
"description": "Create Linear issues from transcriptions",
+ "networkAccess": "network",
+ "categories": ["action"],
"assemblyName": "TypeWhisper.Plugin.Linear.dll",
"pluginClass": "TypeWhisper.Plugin.Linear.LinearPlugin"
}
diff --git a/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs b/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs
index 76a52519c..837fd27ab 100644
--- a/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs
+++ b/plugins/TypeWhisper.Plugin.Obsidian/ObsidianPlugin.cs
@@ -1,25 +1,48 @@
-using System.IO;
+// ReSharper disable MemberCanBePrivate.Global
+// ReSharper disable NotAccessedPositionalProperty.Global
+// ReSharper disable UnusedMember.Global
+// ReSharper disable UnusedType.Global
+// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces
+// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire.
+
+using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using TypeWhisper.PluginSDK;
using TypeWhisper.PluginSDK.Models;
+using TypeWhisper.Plugins.Shared.Net;
namespace TypeWhisper.Plugin.Obsidian;
-public sealed partial class ObsidianPlugin : IActionPlugin, IPluginSettingsProvider, IPluginLocalizationAware
+public sealed class ObsidianPlugin : IActionPlugin, IPluginSettingsProvider, IPluginLocalizationAware
{
- private IPluginHostServices? _host;
+ private const int MaxIndividualNotePathAttempts = 10_000;
+ private const int UnixFileExistsError = 17;
+ private const int WindowsFileExistsError = 80;
+ private const int WindowsAlreadyExistsError = 183;
+
+ private readonly Func> _detectVaults;
private List _detectedVaults = [];
+ public ObsidianPlugin() : this(DetectVaults) { }
+
+ internal ObsidianPlugin(Func> detectVaults)
+ {
+ ArgumentNullException.ThrowIfNull(detectVaults);
+ _detectVaults = detectVaults;
+ }
+
public string PluginId => "com.typewhisper.obsidian";
public string PluginName => "Obsidian";
public string PluginVersion => "1.0.0";
public string ActionId => "save-to-obsidian";
public string ActionName => "Save to Obsidian";
+ // ReSharper disable once ReturnTypeCanBeNotNullable -- matches the interface contract, which declares this member nullable.
public string? ActionIcon => "\ud83d\udcdd";
- internal IPluginHostServices? Host => _host;
+ internal IPluginHostServices? Host { get; private set; }
+
private IPluginLocalization? _injectedLocalization;
public void SetLocalization(IPluginLocalization localization) =>
@@ -28,12 +51,12 @@ public void SetLocalization(IPluginLocalization localization) =>
// Prefer the host's localization once activated; fall back to the catalog
// injected at load so settings labels/validation resolve even when this
// plugin is disabled (never activated, so _host is null).
- internal IPluginLocalization? Loc => _host?.Localization ?? _injectedLocalization;
+ internal IPluginLocalization? Loc => Host?.Localization ?? _injectedLocalization;
public Task ActivateAsync(IPluginHostServices host)
{
- _host = host;
- _detectedVaults = DetectVaults();
+ Host = host;
+ _detectedVaults = _detectVaults();
return Task.CompletedTask;
}
@@ -45,10 +68,10 @@ public async Task ExecuteAsync(
CancellationToken ct
)
{
- if (_host is null)
+ if (Host is null)
return new ActionResult(false, Loc.L("Settings.PluginNotActivatedShort"));
- var vaultPath = _host.GetSetting("vault-path");
+ var vaultPath = Host.GetSetting("vault-path");
if (string.IsNullOrWhiteSpace(vaultPath))
return new ActionResult(
false,
@@ -58,9 +81,9 @@ CancellationToken ct
if (!Directory.Exists(vaultPath))
return new ActionResult(false, Loc.L("Settings.VaultPathNotFound", vaultPath));
- var subfolder = _host.GetSetting("subfolder") ?? "TypeWhisper";
- var dailyNoteMode = _host.GetSetting("daily-note-mode");
- var filenameTemplate = _host.GetSetting("filename-template");
+ var subfolder = Host.GetSetting("subfolder") ?? "TypeWhisper";
+ var dailyNoteMode = Host.GetSetting("daily-note-mode");
+ var filenameTemplate = Host.GetSetting("filename-template");
if (string.IsNullOrWhiteSpace(filenameTemplate))
filenameTemplate = "{{date}} {{time}} Transcription";
@@ -70,7 +93,6 @@ CancellationToken ct
string filePath;
string filename;
- string content;
if (dailyNoteMode)
{
@@ -78,29 +100,21 @@ CancellationToken ct
filePath = Path.Join(targetDir, filename);
var entry = BuildDailyNoteEntry(input, context, now);
-
- if (File.Exists(filePath))
- {
- await File.AppendAllTextAsync(filePath, entry, Encoding.UTF8, ct);
- }
- else
- {
- var header = $"# {now:yyyy-MM-dd}\n\n";
- await File.WriteAllTextAsync(filePath, header + entry, Encoding.UTF8, ct);
- }
+ var header = $"# {now:yyyy-MM-dd}\n\n";
+ var lockPath = GetDailyNoteLockPath(Host.PluginDataDirectory, filePath);
+ await WriteDailyNoteAsync(filePath, lockPath, header, entry, ct);
}
else
{
filename = BuildFilename(filenameTemplate, context, now) + ".md";
filePath = Path.Join(targetDir, filename);
- filePath = EnsureUniqueFilePath(filePath);
- filename = Path.GetFileName(filePath);
- content = BuildNoteContent(input, context, now);
- await File.WriteAllTextAsync(filePath, content, Encoding.UTF8, ct);
+ var content = BuildNoteContent(input, context, now);
+ filePath = await WriteIndividualNoteAsync(filePath, content, ct);
+ filename = Path.GetFileName(filePath);
}
- _host.Log(PluginLogLevel.Info, $"Saved transcription to {filePath}");
+ Host.Log(PluginLogLevel.Info, $"Saved transcription to {filePath}");
return new ActionResult(true, Loc.L("Settings.SavedTo", filename));
}
@@ -164,6 +178,7 @@ private static string SanitizeFilename(string filename)
foreach (var c in filename)
{
+ // ReSharper disable once ConvertIfStatementToConditionalTernaryExpression -- subjective style; kept as an explicit if.
if (Array.IndexOf(invalid, c) >= 0)
sanitized.Append('_');
else
@@ -176,26 +191,205 @@ private static string SanitizeFilename(string filename)
return string.IsNullOrWhiteSpace(result) ? "Transcription" : result;
}
- private static string EnsureUniqueFilePath(string filePath)
- {
- if (!File.Exists(filePath))
- return filePath;
+ private static Task WriteIndividualNoteAsync(
+ string filePath,
+ string content,
+ CancellationToken ct
+ ) =>
+ WriteIndividualNoteAsync(filePath, content, WriteUtf8TextAsync, ct);
+ internal static async Task WriteIndividualNoteAsync(
+ string filePath,
+ string content,
+ Func writeAsync,
+ CancellationToken ct
+ )
+ {
var dir = Path.GetDirectoryName(filePath)!;
var nameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
var ext = Path.GetExtension(filePath);
- var counter = 2;
- string candidate;
- do
+ for (var attempt = 0; attempt < MaxIndividualNotePathAttempts; attempt++)
{
- candidate = Path.Join(dir, $"{nameWithoutExt} {counter}{ext}");
- counter++;
- } while (File.Exists(candidate));
+ var candidate = attempt == 0
+ ? filePath
+ : Path.Join(dir, $"{nameWithoutExt} {attempt + 1}{ext}");
+ FileStream claimedStream;
- return candidate;
+ try
+ {
+ claimedStream = new FileStream(
+ candidate,
+ FileMode.CreateNew,
+ FileAccess.Write,
+ FileShare.None,
+ bufferSize: 4096,
+ FileOptions.Asynchronous
+ );
+ }
+ catch (IOException ex) when (IsCreateNewCollision(ex))
+ {
+ continue;
+ }
+
+ try
+ {
+ await using (claimedStream)
+ {
+ await writeAsync(claimedStream, content, ct);
+ await claimedStream.FlushAsync(ct);
+ }
+
+ return candidate;
+ }
+ catch
+ {
+ TryDeleteOwnedFile(candidate);
+ throw;
+ }
+ }
+
+ throw new IOException(
+ $"Could not create a unique Obsidian note after {MaxIndividualNotePathAttempts} attempts."
+ );
+ }
+
+ internal static string GetDailyNoteLockPath(string pluginDataDirectory, string notePath)
+ {
+ var normalizedNotePath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(notePath));
+ if (OperatingSystem.IsWindows())
+ normalizedNotePath = normalizedNotePath.ToUpperInvariant();
+
+ var hash = Convert.ToHexString(
+ SHA256.HashData(Encoding.UTF8.GetBytes(normalizedNotePath))
+ );
+ return Path.Join(pluginDataDirectory, "locks", $"{hash}.lock");
+ }
+
+ private static async Task WriteDailyNoteAsync(
+ string filePath,
+ string lockPath,
+ string header,
+ string entry,
+ CancellationToken ct
+ )
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(lockPath)!);
+
+ await using (await InterProcessFileLock.AcquireAsync(lockPath, ct))
+ {
+ if (File.Exists(filePath))
+ {
+ // The sentinel already serializes TypeWhisper writers, so allow
+ // read sharing: an editor/sync client holding the note open for
+ // reading must not turn this append into a sharing violation.
+ var originalLength = new FileInfo(filePath).Length;
+ try
+ {
+ await using var appendStream = new FileStream(
+ filePath,
+ FileMode.Append,
+ FileAccess.Write,
+ FileShare.Read,
+ bufferSize: 4096,
+ FileOptions.Asynchronous
+ );
+ await WriteUtf8TextAsync(appendStream, entry, ct);
+ await appendStream.FlushAsync(ct);
+ }
+ catch
+ {
+ // Roll the note back to its pre-append length so a failed or
+ // cancelled write leaves no partial entry behind.
+ TryTruncateFile(filePath, originalLength);
+ throw;
+ }
+
+ return;
+ }
+
+ FileStream claimedStream = new(
+ filePath,
+ FileMode.CreateNew,
+ FileAccess.Write,
+ FileShare.None,
+ bufferSize: 4096,
+ FileOptions.Asynchronous
+ );
+
+ try
+ {
+ await using (claimedStream)
+ {
+ await WriteUtf8TextAsync(claimedStream, header + entry, ct);
+ await claimedStream.FlushAsync(ct);
+ }
+ }
+ catch
+ {
+ TryDeleteOwnedFile(filePath);
+ throw;
+ }
+ }
+ }
+
+ private static async Task WriteUtf8TextAsync(
+ FileStream stream,
+ string content,
+ CancellationToken ct
+ )
+ {
+ await using var writer = new StreamWriter(
+ stream,
+ Encoding.UTF8,
+ bufferSize: 1024,
+ leaveOpen: true
+ );
+ await writer.WriteAsync(content.AsMemory(), ct);
+ await writer.FlushAsync(ct);
+ }
+
+ private static bool IsCreateNewCollision(IOException exception)
+ {
+ var errorCode = exception.HResult & 0xFFFF;
+ return errorCode is
+ UnixFileExistsError
+ or WindowsFileExistsError
+ or WindowsAlreadyExistsError;
+ }
+
+ private static void TryDeleteOwnedFile(string path)
+ {
+ try
+ {
+ File.Delete(path);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Preserve the original cancellation/write failure if best-effort cleanup fails.
+ }
+ }
+
+ private static void TryTruncateFile(string path, long length)
+ {
+ try
+ {
+ using var stream = new FileStream(
+ path,
+ FileMode.Open,
+ FileAccess.Write,
+ FileShare.None
+ );
+ if (stream.Length > length)
+ stream.SetLength(length);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Preserve the original cancellation/write failure if best-effort cleanup fails.
+ }
}
+ // ReSharper disable once UseVerbatimString -- the mixed backslash/quote escapes read no better as a verbatim string.
private static string EscapeYaml(string value) =>
value.Replace("\\", "\\\\").Replace("\"", "\\\"");
@@ -223,13 +417,21 @@ internal static List DetectVaults()
foreach (var vault in vaultsElement.EnumerateObject())
{
+ // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is.
if (vault.Value.TryGetProperty("path", out var pathElement))
{
var path = pathElement.GetString();
+ // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is.
if (!string.IsNullOrEmpty(path) && Directory.Exists(path))
{
- var name = Path.GetFileName(path);
- vaults.Add(new ObsidianVaultInfo(name ?? vault.Name, path));
+ // Path.GetFileName yields "" for a trailing-separator path; trim
+ // separators first, then fall back to the vault key so the picker
+ // never shows a blank display name.
+ var name = Path.GetFileName(
+ path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
+ if (string.IsNullOrEmpty(name))
+ name = vault.Name;
+ vaults.Add(new ObsidianVaultInfo(name, path));
}
}
}
@@ -251,6 +453,7 @@ private static string GetObsidianConfigPath()
}
var configHome = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME");
+ // ReSharper disable once InvertIf -- subjective nesting-style suggestion; kept as-is.
if (string.IsNullOrWhiteSpace(configHome))
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
@@ -298,16 +501,16 @@ public IReadOnlyList GetSettingDefinitions() =>
public Task GetSettingValueAsync(string key, CancellationToken ct = default)
{
- if (_host is null)
+ if (Host is null)
return Task.FromResult(null);
return Task.FromResult(
key switch
{
- "vault-path" => _host.GetSetting("vault-path"),
- "subfolder" => _host.GetSetting("subfolder") ?? "TypeWhisper",
- "daily-note-mode" => _host.GetSetting("daily-note-mode") ? "true" : "false",
- "filename-template" => _host.GetSetting("filename-template")
+ "vault-path" => Host.GetSetting("vault-path"),
+ "subfolder" => Host.GetSetting("subfolder") ?? "TypeWhisper",
+ "daily-note-mode" => Host.GetSetting("daily-note-mode") ? "true" : "false",
+ "filename-template" => Host.GetSetting("filename-template")
?? "{{date}} {{time}} Transcription",
_ => null,
}
@@ -316,28 +519,28 @@ public IReadOnlyList GetSettingDefinitions() =>
public Task SetSettingValueAsync(string key, string? value, CancellationToken ct = default)
{
- if (_host is null)
+ if (Host is null)
return Task.CompletedTask;
switch (key)
{
case "vault-path":
- _host.SetSetting("vault-path", value?.Trim() ?? string.Empty);
+ Host.SetSetting("vault-path", value?.Trim() ?? string.Empty);
break;
case "subfolder":
- _host.SetSetting(
+ Host.SetSetting(
"subfolder",
string.IsNullOrWhiteSpace(value) ? "TypeWhisper" : value.Trim()
);
break;
case "daily-note-mode":
- _host.SetSetting(
+ Host.SetSetting(
"daily-note-mode",
string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)
);
break;
case "filename-template":
- _host.SetSetting(
+ Host.SetSetting(
"filename-template",
string.IsNullOrWhiteSpace(value)
? "{{date}} {{time}} Transcription"
@@ -351,12 +554,12 @@ public Task SetSettingValueAsync(string key, string? value, CancellationToken ct
public Task ValidateAsync(CancellationToken ct = default)
{
- if (_host is null)
+ if (Host is null)
return Task.FromResult(
new PluginSettingsValidationResult(false, Loc.L("Settings.PluginNotActivated"))
);
- var vaultPath = _host.GetSetting("vault-path");
+ var vaultPath = Host.GetSetting("vault-path");
if (string.IsNullOrWhiteSpace(vaultPath))
return Task.FromResult(
new PluginSettingsValidationResult(false, Loc.L("Settings.EnterVaultPath"))
diff --git a/plugins/TypeWhisper.Plugin.Obsidian/TypeWhisper.Plugin.Obsidian.csproj b/plugins/TypeWhisper.Plugin.Obsidian/TypeWhisper.Plugin.Obsidian.csproj
index fb9679588..3037d7794 100644
--- a/plugins/TypeWhisper.Plugin.Obsidian/TypeWhisper.Plugin.Obsidian.csproj
+++ b/plugins/TypeWhisper.Plugin.Obsidian/TypeWhisper.Plugin.Obsidian.csproj
@@ -6,9 +6,16 @@
latest
TypeWhisper.Plugin.Obsidian
+
+
+
+
+
+
PreserveNewest
diff --git a/plugins/TypeWhisper.Plugin.Obsidian/manifest.json b/plugins/TypeWhisper.Plugin.Obsidian/manifest.json
index b3557751f..38e1772be 100644
--- a/plugins/TypeWhisper.Plugin.Obsidian/manifest.json
+++ b/plugins/TypeWhisper.Plugin.Obsidian/manifest.json
@@ -4,6 +4,8 @@
"version": "1.0.0",
"author": "TypeWhisper",
"description": "Save transcriptions as notes in Obsidian",
+ "networkAccess": "local",
+ "categories": ["action"],
"assemblyName": "TypeWhisper.Plugin.Obsidian.dll",
"pluginClass": "TypeWhisper.Plugin.Obsidian.ObsidianPlugin"
}
diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiChatGptClient.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiChatGptClient.cs
index 5ebef51e2..308019f87 100644
--- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiChatGptClient.cs
+++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiChatGptClient.cs
@@ -1,4 +1,7 @@
-using System.Net.Http;
+// ReSharper disable MemberCanBePrivate.Global
+// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces
+// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire.
+
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
@@ -66,9 +69,9 @@ internal static Dictionary CreateRequestBody(
role = "user",
content = new[]
{
- new { type = "input_text", text = userText }
- }
- }
+ new { type = "input_text", text = userText },
+ },
+ },
}),
["store"] = OpenAiJson.Element(false),
["stream"] = OpenAiJson.Element(true),
@@ -80,13 +83,18 @@ internal static Dictionary CreateRequestBody(
return body;
}
- internal static string? ParseResponseText(string body) =>
- ParseJsonResponseText(body) ?? ParseEventStreamResponseText(body);
+ internal static string? ParseResponseText(string body)
+ {
+ return TryParseJsonResponseText(body, out var responseText)
+ ? responseText
+ : ParseEventStreamResponseText(body);
+ }
private static string? ParseEventStreamResponseText(string body)
{
var deltaBuffer = new StringBuilder();
var completedParts = new List();
+ var receivedDone = false;
foreach (var rawLine in body.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries))
{
@@ -96,7 +104,10 @@ internal static Dictionary CreateRequestBody(
var payload = line[6..];
if (payload == "[DONE]")
- continue;
+ {
+ receivedDone = true;
+ break;
+ }
JsonDocument doc;
try
@@ -112,10 +123,13 @@ internal static Dictionary CreateRequestBody(
using (doc)
{
var root = doc.RootElement;
- if (!root.TryGetProperty("type", out var typeEl))
+ if (GetString(root, "type") is not { } type)
continue;
- switch (typeEl.GetString())
+ if (GetSseFailure(root, type) is { } failure)
+ throw new InvalidOperationException(failure);
+
+ switch (type)
{
case "response.output_text.delta":
if (GetString(root, "delta") is { } delta)
@@ -136,6 +150,12 @@ internal static Dictionary CreateRequestBody(
}
}
+ if (!receivedDone)
+ {
+ throw new InvalidOperationException(
+ "ChatGPT SSE stream ended before [DONE] was received.");
+ }
+
if (deltaBuffer.Length > 0)
return deltaBuffer.ToString().Trim();
@@ -143,7 +163,42 @@ internal static Dictionary CreateRequestBody(
return string.IsNullOrEmpty(completed) ? null : completed;
}
- private static string? ParseJsonResponseText(string json)
+ private static string? GetSseFailure(JsonElement root, string type)
+ {
+ var status = root.TryGetProperty("response", out var response)
+ && response.ValueKind == JsonValueKind.Object
+ ? GetString(response, "status")
+ : GetString(root, "status");
+
+ if (type == "response.completed")
+ {
+ // The event type is itself the success signal, so only an explicitly
+ // contradictory status is a failure — a missing one must not be.
+ if (status is not null
+ && !string.Equals(status, "completed", StringComparison.OrdinalIgnoreCase))
+ {
+ return $"ChatGPT SSE event 'response.completed' had non-completed status "
+ + $"'{status}'.";
+ }
+
+ return null;
+ }
+
+ if (type is not ("error"
+ or "response.failed"
+ or "response.incomplete"
+ or "response.cancelled"
+ or "response.canceled"))
+ {
+ return null;
+ }
+
+ return status is null
+ ? $"ChatGPT SSE event '{type}' indicated failure."
+ : $"ChatGPT SSE event '{type}' indicated terminal status '{status}'.";
+ }
+
+ private static bool TryParseJsonResponseText(string json, out string? responseText)
{
try
{
@@ -151,7 +206,10 @@ internal static Dictionary CreateRequestBody(
var root = doc.RootElement;
if (GetString(root, "output_text") is { Length: > 0 } outputText)
- return outputText.Trim();
+ {
+ responseText = outputText.Trim();
+ return true;
+ }
if (root.TryGetProperty("choices", out var choices)
&& choices.ValueKind == JsonValueKind.Array
@@ -159,7 +217,8 @@ internal static Dictionary CreateRequestBody(
&& choices[0].TryGetProperty("message", out var message)
&& GetString(message, "content") is { Length: > 0 } messageContent)
{
- return messageContent.Trim();
+ responseText = messageContent.Trim();
+ return true;
}
if (root.TryGetProperty("output", out var output)
@@ -181,15 +240,20 @@ internal static Dictionary CreateRequestBody(
var joined = string.Join("\n", parts).Trim();
if (!string.IsNullOrEmpty(joined))
- return joined;
+ {
+ responseText = joined;
+ return true;
+ }
}
+
+ responseText = null;
+ return true;
}
catch (JsonException)
{
- return null;
+ responseText = null;
+ return false;
}
-
- return null;
}
private static string ParseErrorMessage(string body, int statusCode)
diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiFetchedModel.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiFetchedModel.cs
index 5375446bd..88eeb6704 100644
--- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiFetchedModel.cs
+++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiFetchedModel.cs
@@ -1,3 +1,8 @@
+// ReSharper disable ClassNeverInstantiated.Global
+// ReSharper disable NotAccessedPositionalProperty.Global
+// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces
+// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire.
+
using System.Text.Json.Serialization;
namespace TypeWhisper.Plugin.OpenAi;
diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiJson.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiJson.cs
index 2134a1998..bd19d5503 100644
--- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiJson.cs
+++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiJson.cs
@@ -1,4 +1,3 @@
-using System.Net.Http;
using System.Text;
using System.Text.Json;
@@ -6,14 +5,14 @@ namespace TypeWhisper.Plugin.OpenAi;
internal static class OpenAiJson
{
- private static readonly JsonSerializerOptions JsonOptions = new()
+ private static readonly JsonSerializerOptions s_jsonOptions = new()
{
- PropertyNamingPolicy = null
+ PropertyNamingPolicy = null,
};
public static JsonElement Element(T value) =>
- JsonSerializer.SerializeToElement(value, JsonOptions).Clone();
+ JsonSerializer.SerializeToElement(value, s_jsonOptions).Clone();
public static StringContent CreateJsonContent(IReadOnlyDictionary body) =>
- new(JsonSerializer.Serialize(body, JsonOptions), Encoding.UTF8, "application/json");
+ new(JsonSerializer.Serialize(body, s_jsonOptions), Encoding.UTF8, "application/json");
}
diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs
index 9899d1176..170ed4f2b 100644
--- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs
+++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiOAuthSupport.cs
@@ -1,7 +1,9 @@
-using System.Diagnostics;
-using System.IO;
+// ReSharper disable ClassNeverInstantiated.Global
+// ReSharper disable MemberCanBePrivate.Global
+// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces
+// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire.
+
using System.Net;
-using System.Net.Http;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
@@ -13,7 +15,7 @@ namespace TypeWhisper.Plugin.OpenAi;
internal enum OpenAiAuthMode
{
ApiKey,
- ChatGpt
+ ChatGpt,
}
internal static class OpenAiAuthModeExtensions
@@ -50,6 +52,9 @@ internal static class OpenAiOAuthClient
private const string AuthorizeOriginator = "opencode";
+ private static readonly JsonSerializerOptions s_jsonReadOptions =
+ new() { PropertyNameCaseInsensitive = true };
+
public static OpenAiPkceCodes GeneratePkceCodes()
{
var verifier = RandomOAuthString(64);
@@ -85,17 +90,15 @@ public static async Task ExchangeAuthorizationCodeAsyn
OpenAiPkceCodes pkce,
CancellationToken ct)
{
- using var request = new HttpRequestMessage(HttpMethod.Post, $"{Issuer}/oauth/token")
+ using var request = new HttpRequestMessage(HttpMethod.Post, $"{Issuer}/oauth/token");
+ request.Content = new FormUrlEncodedContent(new Dictionary
{
- Content = new FormUrlEncodedContent(new Dictionary
- {
- ["grant_type"] = "authorization_code",
- ["code"] = code,
- ["redirect_uri"] = RedirectUri,
- ["client_id"] = ClientId,
- ["code_verifier"] = pkce.Verifier,
- })
- };
+ ["grant_type"] = "authorization_code",
+ ["code"] = code,
+ ["redirect_uri"] = RedirectUri,
+ ["client_id"] = ClientId,
+ ["code_verifier"] = pkce.Verifier,
+ });
return await SendTokenRequestAsync(httpClient, request, ct);
}
@@ -105,15 +108,13 @@ public static async Task RefreshTokenAsync(
string refreshToken,
CancellationToken ct)
{
- using var request = new HttpRequestMessage(HttpMethod.Post, $"{Issuer}/oauth/token")
+ using var request = new HttpRequestMessage(HttpMethod.Post, $"{Issuer}/oauth/token");
+ request.Content = new FormUrlEncodedContent(new Dictionary
{
- Content = new FormUrlEncodedContent(new Dictionary
- {
- ["grant_type"] = "refresh_token",
- ["refresh_token"] = refreshToken,
- ["client_id"] = ClientId,
- })
- };
+ ["grant_type"] = "refresh_token",
+ ["refresh_token"] = refreshToken,
+ ["client_id"] = ClientId,
+ });
return await SendTokenRequestAsync(httpClient, request, ct);
}
@@ -147,9 +148,7 @@ private static async Task SendTokenRequestAsync(
if (!response.IsSuccessStatusCode)
throw new InvalidOperationException($"OpenAI token request failed with status {(int)response.StatusCode}: {json}");
- return JsonSerializer.Deserialize(
- json,
- new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
+ return JsonSerializer.Deserialize(json, s_jsonReadOptions)
?? throw new InvalidOperationException("OpenAI token response could not be parsed.");
}
@@ -347,9 +346,11 @@ private async Task AcceptOnAnyListenerAsync(CancellationToken ct)
private void StopListeners()
{
try { _v4Listener?.Stop(); }
- catch { }
+ catch { /* Listener may already be stopped or disposed. */ }
+
try { _v6Listener?.Stop(); }
- catch { }
+ catch { /* Listener may already be stopped or disposed. */ }
+
_v4Listener = null;
_v6Listener = null;
}
@@ -409,14 +410,14 @@ private static async Task SendHtmlAsync(Stream stream, string html, Cancellation
""";
private static string ErrorHtml(string message) =>
- $$"""
+ $"""
TypeWhisper Login
Login failed
- {{message}}
+ {message}
diff --git a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs
index 495e60694..2c564a2f6 100644
--- a/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs
+++ b/plugins/TypeWhisper.Plugin.OpenAi/OpenAiPlugin.cs
@@ -1,8 +1,11 @@
+// ReSharper disable MemberCanBePrivate.Global
+// ReSharper disable UnusedMember.Global
+// Plugin types are instantiated by the host via reflection and invoked through plugin interfaces
+// and JSON settings binding; the analyzer cannot see those consumers, so these .Global inspections misfire.
+
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
-using System.IO;
-using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Sockets;
using System.Text.Json;
@@ -40,32 +43,23 @@ public sealed class OpenAiPlugin
private const string TemperatureModeProviderDefault = "providerDefault";
private const string TemperatureModeCustom = "custom";
+ private static readonly JsonSerializerOptions s_jsonReadOptions =
+ new() { PropertyNameCaseInsensitive = true };
+
private readonly HttpClient _httpClient;
private readonly Func _ttsPlaybackFactory;
private readonly Func _ttsPlaybackAvailableProbe;
private IPluginHostServices? _host;
- private string? _apiKey;
- private string? _selectedModelId;
private string? _selectedApiModelName;
private string _selectedResponseFormat = "verbose_json";
private string? _selectedVoiceId;
- private string _ttsInstructions = "";
- private string _reasoningEffort = "medium";
private List _fetchedLlmModels = [];
- private OpenAiAuthMode _authMode = OpenAiAuthMode.ApiKey;
- private string? _selectedLlmModelId;
- private string? _oauthAccessToken;
- private string? _oauthRefreshToken;
- private string? _oauthIdToken;
- private string? _oauthAccountId;
- private string? _oauthPlanType;
- private DateTimeOffset? _oauthExpiresAt;
+ private readonly SemaphoreSlim _oauthCredentialGate = new(1, 1);
+ private OAuthCredentialSnapshot _oauthCredentials = OAuthCredentialSnapshot.Empty;
private bool _forgetChatGptLogin;
- private string _temperatureMode = TemperatureModeProviderDefault;
- private double _temperatureValue = 0.3;
private bool _streamResponses = true;
- private static readonly IReadOnlyList TranscriptionModelEntries =
+ private static readonly IReadOnlyList s_transcriptionModelEntries =
[
new("whisper-1", "Whisper 1", "whisper-1", "verbose_json", SupportsTranslation: true),
new(
@@ -92,7 +86,7 @@ public sealed class OpenAiPlugin
),
];
- private static readonly IReadOnlyList FallbackLlmModels =
+ private static readonly IReadOnlyList s_fallbackLlmModels =
[
new("gpt-5.5", "GPT-5.5"),
new("gpt-4.1-nano", "GPT-4.1 Nano"),
@@ -103,7 +97,7 @@ public sealed class OpenAiPlugin
new("o4-mini", "o4-mini"),
];
- private static readonly IReadOnlyList ChatGptModels =
+ private static readonly IReadOnlyList s_chatGptModels =
[
new("gpt-5.5", "GPT-5.5"),
new("gpt-5.4", "GPT-5.4"),
@@ -144,25 +138,39 @@ internal OpenAiPlugin(
public async Task ActivateAsync(IPluginHostServices host)
{
_host = host;
- _apiKey = NormalizeApiKey(await host.LoadSecretAsync(ApiKeySecretName));
- _oauthAccessToken = NormalizeApiKey(await host.LoadSecretAsync(OAuthAccessTokenSecretName));
- _oauthRefreshToken = NormalizeApiKey(await host.LoadSecretAsync(OAuthRefreshTokenSecretName));
- _oauthIdToken = NormalizeApiKey(await host.LoadSecretAsync(OAuthIdTokenSecretName));
- _authMode = OpenAiAuthModeExtensions.Parse(host.GetSetting(AuthModeSettingName));
- _selectedLlmModelId = host.GetSetting(SelectedLlmModelSettingName);
+ ApiKey = NormalizeApiKey(await host.LoadSecretAsync(ApiKeySecretName));
+
+ await _oauthCredentialGate.WaitAsync();
+ try
+ {
+ Volatile.Write(
+ ref _oauthCredentials,
+ new OAuthCredentialSnapshot(
+ NormalizeApiKey(await host.LoadSecretAsync(OAuthAccessTokenSecretName)),
+ NormalizeApiKey(await host.LoadSecretAsync(OAuthRefreshTokenSecretName)),
+ NormalizeApiKey(await host.LoadSecretAsync(OAuthIdTokenSecretName)),
+ host.GetSetting(OAuthAccountIdSettingName),
+ host.GetSetting