OpenAI gpt-4o-mini-tts (female voice nova by default; TTS_MODEL/TTS_VOICE) renders OGG/Opus, sent with sendVoice in order with rendered output. Up to 4500 characters per call, split into 1500-character parts at paragraph/sentence/word boundaries; all parts are synthesized before any is sent, and API errors come back to the agent as text with the key hidden. On whenever OPENAI_API_KEY is set.
94 lines
4.5 KiB
C#
94 lines
4.5 KiB
C#
using System.Net;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Volcanic.AgenticCore.Telegram.Speech;
|
|
|
|
namespace Volcanic.AgenticCore.Telegram.Tests;
|
|
|
|
public sealed class OpenAiTextToSpeechTests
|
|
{
|
|
private const string Key = "sk-test-secret-key-123";
|
|
|
|
private sealed class StubHandler(params (HttpStatusCode Status, byte[] Body)[] answers) : HttpMessageHandler
|
|
{
|
|
public List<(HttpRequestMessage Request, JsonElement Body)> Requests { get; } = [];
|
|
|
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
Requests.Add((request, JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)).RootElement.Clone()));
|
|
var (status, body) = answers[Math.Min(Requests.Count, answers.Length) - 1];
|
|
return new HttpResponseMessage(status) { Content = new ByteArrayContent(body) };
|
|
}
|
|
}
|
|
|
|
private static (OpenAiTextToSpeech Tts, StubHandler Handler) Create(SpeechOptions? options = null, params (HttpStatusCode, byte[])[] answers)
|
|
{
|
|
var handler = new StubHandler(answers.Length > 0 ? answers : [(HttpStatusCode.OK, [1, 2, 3])]);
|
|
options ??= new SpeechOptions();
|
|
options.ApiKey = Key;
|
|
options.BaseUrl = "https://tts.example/v1/";
|
|
options.RetryDelay = TimeSpan.Zero;
|
|
return (new OpenAiTextToSpeech(new HttpClient(handler), options), handler);
|
|
}
|
|
|
|
private static byte[] Utf8(string text) => Encoding.UTF8.GetBytes(text);
|
|
|
|
[Fact]
|
|
public async Task Posts_json_with_model_voice_input_and_opus_format()
|
|
{
|
|
var (tts, handler) = Create();
|
|
var audio = await tts.SynthesizeAsync("Привет", null, null, CancellationToken.None);
|
|
|
|
Assert.Equal(new byte[] { 1, 2, 3 }, audio);
|
|
var (request, body) = Assert.Single(handler.Requests);
|
|
Assert.Equal("https://tts.example/v1/audio/speech", request.RequestUri!.ToString());
|
|
Assert.Equal(("Bearer", Key), (request.Headers.Authorization!.Scheme, request.Headers.Authorization.Parameter));
|
|
Assert.Equal("gpt-4o-mini-tts", body.GetProperty("model").GetString());
|
|
Assert.Equal("nova", body.GetProperty("voice").GetString());
|
|
Assert.Equal("Привет", body.GetProperty("input").GetString());
|
|
Assert.Equal("opus", body.GetProperty("response_format").GetString());
|
|
Assert.False(body.TryGetProperty("instructions", out _));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Arguments_override_the_configured_voice_and_instructions()
|
|
{
|
|
var (tts, handler) = Create(new SpeechOptions { TtsVoice = "cedar", TtsInstructions = "calm" });
|
|
await tts.SynthesizeAsync("hi", "shimmer", "cheerful", CancellationToken.None);
|
|
var body = handler.Requests[0].Body;
|
|
Assert.Equal(("shimmer", "cheerful"), (body.GetProperty("voice").GetString(), body.GetProperty("instructions").GetString()));
|
|
|
|
await tts.SynthesizeAsync("hi", null, null, CancellationToken.None);
|
|
body = handler.Requests[1].Body;
|
|
Assert.Equal(("cedar", "calm"), (body.GetProperty("voice").GetString(), body.GetProperty("instructions").GetString()));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_api_error_is_reported_with_the_key_hidden_and_not_retried()
|
|
{
|
|
var (tts, handler) = Create(null, (HttpStatusCode.BadRequest, Utf8($"{{\"error\":\"bad voice for {Key}\"}}")));
|
|
var ex = await Assert.ThrowsAsync<TextToSpeechException>(() => tts.SynthesizeAsync("hi", "nobody", null, CancellationToken.None));
|
|
|
|
Assert.Single(handler.Requests);
|
|
Assert.StartsWith("OpenAI speech synthesis failed: HTTP 400", ex.Message, StringComparison.Ordinal);
|
|
Assert.DoesNotContain(Key, ex.Message, StringComparison.Ordinal);
|
|
Assert.Contains("[REDACTED]", ex.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Server_errors_are_retried()
|
|
{
|
|
var (tts, handler) = Create(null, (HttpStatusCode.InternalServerError, Utf8("oops")), (HttpStatusCode.OK, [7]));
|
|
Assert.Equal(new byte[] { 7 }, await tts.SynthesizeAsync("hi", null, null, CancellationToken.None));
|
|
Assert.Equal(2, handler.Requests.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_empty_response_is_an_error()
|
|
{
|
|
var (tts, _) = Create(null, (HttpStatusCode.OK, []));
|
|
var ex = await Assert.ThrowsAsync<TextToSpeechException>(() => tts.SynthesizeAsync("hi", null, null, CancellationToken.None));
|
|
Assert.Equal("OpenAI speech synthesis failed: the response is empty.", ex.Message);
|
|
}
|
|
}
|