Files

91 lines
4.1 KiB
C#

using System.Net.Http.Headers;
using System.Text.Json;
namespace Volcanic.AgenticCore.Telegram.Speech;
/// <summary>OpenAI's <c>/audio/transcriptions</c> (port of VolcanicSecretary's <c>stt/openai.ts</c>).</summary>
public sealed class OpenAiSpeechToText(HttpClient http, SpeechOptions options) : ISpeechToText
{
private const string Prefix = "OpenAI transcription failed: ";
private const int MaxExcerpt = 200;
public async Task<string> TranscribeAsync(ReadOnlyMemory<byte> audio, string fileName, string mimeType, CancellationToken cancellationToken)
{
for (var attempt = 1; ; attempt++)
{
try
{
return await TranscribeOnceAsync(audio, fileName, mimeType, cancellationToken).ConfigureAwait(false);
}
catch (SpeechToTextException ex) when (ex.IsTransient && attempt < SpeechOptions.MaxAttempts)
{
await Task.Delay(options.RetryDelay * (1 << (attempt - 1)), cancellationToken).ConfigureAwait(false);
}
}
}
// A request message can be sent only once, so every attempt builds its own.
private async Task<string> TranscribeOnceAsync(ReadOnlyMemory<byte> audio, string fileName, string mimeType, CancellationToken cancellationToken)
{
using var form = new MultipartFormDataContent();
var file = new ReadOnlyMemoryContent(audio);
file.Headers.ContentType = new MediaTypeHeaderValue(mimeType);
form.Add(file, "file", fileName);
form.Add(new StringContent(options.ResolvedModel), "model");
form.Add(new StringContent("json"), "response_format");
if (!string.IsNullOrWhiteSpace(options.Language)) form.Add(new StringContent(options.Language.Trim()), "language");
using var request = new HttpRequestMessage(HttpMethod.Post, options.BaseUrl.TrimEnd('/') + "/audio/transcriptions") { Content = form };
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiKey);
HttpResponseMessage response;
string body;
try
{
response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) // HttpClient.Timeout
{
throw new SpeechToTextException(Prefix + "the request timed out.", ex) { IsTransient = true };
}
catch (HttpRequestException ex)
{
throw new SpeechToTextException(Prefix + Hide(ex.Message), ex) { IsTransient = true };
}
using var _ = response;
try
{
body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
throw new SpeechToTextException(Prefix + "the request timed out.", ex) { IsTransient = true };
}
if (!response.IsSuccessStatusCode)
{
var hidden = Hide(body); // hide the key before cutting, so half a key can't leak
var excerpt = hidden.Length > MaxExcerpt ? hidden[..MaxExcerpt] : hidden;
var status = (int)response.StatusCode;
throw new SpeechToTextException($"{Prefix}HTTP {status} {excerpt}".TrimEnd())
{
IsTransient = status is 408 or 429 or >= 500,
};
}
try
{
using var json = JsonDocument.Parse(body);
if (json.RootElement.ValueKind == JsonValueKind.Object
&& json.RootElement.TryGetProperty("text", out var text) && text.ValueKind == JsonValueKind.String)
return text.GetString()!.Trim();
}
catch (JsonException ex)
{
throw new SpeechToTextException(Prefix + "the response is not JSON.", ex);
}
throw new SpeechToTextException(Prefix + "the response has no text.");
}
private string Hide(string text) =>
string.IsNullOrEmpty(options.ApiKey) ? text : text.Replace(options.ApiKey, "[REDACTED]", StringComparison.Ordinal);
}