Files
VolcanicAgenticCore/tests/Volcanic.AgenticCore.Telegram.Tests/OpenAiTextToSpeechTests.cs
EugeneTes 87240238cc feat(telegram): voice tool sends spoken replies as Telegram voice notes
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.
2026-10-09 10:09:46 +00:00

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);
}
}