91 lines
4.1 KiB
C#
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);
|
|
}
|