diff --git a/dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs b/dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs
new file mode 100644
index 00000000..869f3959
--- /dev/null
+++ b/dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs
@@ -0,0 +1,150 @@
+using System.Text;
+using EcencyApi.Handlers;
+using Xunit;
+
+namespace EcencyApi.Tests;
+
+///
+/// The transcription route is the only private-api endpoint that forwards a file, so it
+/// is the only one building a multipart body by hand. Two things about that body have
+/// consequences beyond a failed request:
+///
+/// `us` decides whose Points upstream burns. It has to be the caller resolved from the
+/// signed code; taking it from the request would let anyone spend anyone else's balance.
+///
+/// The multipart boundary is generated, so a Content-Type set anywhere else silently
+/// makes the body unparseable upstream and surfaces only as a confusing 400.
+///
+public class AiTranscribeContentTests
+{
+ private static MultipartFormDataContent Build(
+ string username = "good-karma",
+ string durationMs = "30000",
+ string? idem = "abcd1234efgh",
+ string? fileName = "clip.m4a",
+ string? contentType = "audio/mp4")
+ {
+ var audio = new MemoryStream(Encoding.UTF8.GetBytes("fake audio bytes"));
+ return PrivateApi.BuildTranscribeContent(
+ username, durationMs, idem, audio, fileName, contentType);
+ }
+
+ private static async Task Render(MultipartFormDataContent content)
+ {
+ return await content.ReadAsStringAsync();
+ }
+
+ [Fact]
+ public async Task UsIsTheAuthenticatedCallerNotAClientValue()
+ {
+ using var content = Build(username: "good-karma");
+ var body = await Render(content);
+
+ Assert.Contains("name=us", body.Replace("\"", ""));
+ Assert.Contains("good-karma", body);
+ }
+
+ [Fact]
+ public async Task CarriesDurationAndIdempotencyKey()
+ {
+ using var content = Build(durationMs: "45000", idem: "abcd1234efgh");
+ var body = await Render(content);
+
+ Assert.Contains("45000", body);
+ Assert.Contains("abcd1234efgh", body);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public async Task OmitsAnEmptyIdempotencyKeyRatherThanSendingBlank(string? idem)
+ {
+ // Upstream validates the key against [A-Za-z0-9_-]{8,64}; a blank one is a 400,
+ // whereas an absent one is simply an un-deduplicated request.
+ using var content = Build(idem: idem);
+ var body = await Render(content);
+
+ Assert.DoesNotContain("idempotency_key", body);
+ }
+
+ [Fact]
+ public async Task SendsTheAudioPartWithItsFilename()
+ {
+ using var content = Build(fileName: "clip.m4a");
+ var body = await Render(content);
+
+ Assert.Contains("name=audio", body.Replace("\"", ""));
+ Assert.Contains("clip.m4a", body);
+ Assert.Contains("fake audio bytes", body);
+ }
+
+ [Fact]
+ public async Task FallsBackToAFilenameWhenTheClientSendsNone()
+ {
+ using var content = Build(fileName: null);
+ var body = await Render(content);
+
+ Assert.Contains("name=audio", body.Replace("\"", ""));
+ }
+
+ [Fact]
+ public void ContentTypeCarriesAGeneratedBoundary()
+ {
+ using var content = Build();
+
+ var mediaType = content.Headers.ContentType;
+ Assert.NotNull(mediaType);
+ Assert.Equal("multipart/form-data", mediaType!.MediaType);
+
+ var boundary = mediaType.Parameters
+ .FirstOrDefault(p => p.Name.Equals("boundary", StringComparison.OrdinalIgnoreCase));
+ Assert.NotNull(boundary);
+ Assert.False(string.IsNullOrWhiteSpace(boundary!.Value));
+ }
+
+ [Fact]
+ public async Task TolerantOfAMissingAudioContentType()
+ {
+ // expo-audio and MediaRecorder do not always label the part.
+ using var content = Build(contentType: null);
+ var body = await Render(content);
+
+ Assert.Contains("fake audio bytes", body);
+ }
+
+ [Theory]
+ // A bare token with no subtype, a trailing separator, and a broken parameter --
+ // all things a client can put in a multipart part header.
+ [InlineData("audio")]
+ [InlineData("audio/")]
+ [InlineData("audio/mp4;")]
+ [InlineData("audio/mp4; codecs=")]
+ [InlineData("not a media type at all")]
+ [InlineData("///")]
+ public async Task AMalformedContentTypeIsDroppedRatherThanThrowing(string contentType)
+ {
+ // MediaTypeHeaderValue.Parse throws FormatException on these, and the throw
+ // would escape the handler's form-reading catch and become a 500 from the
+ // global middleware. The upload itself is fine, so the label is dropped and
+ // the request proceeds.
+ var ex = Record.Exception(() =>
+ {
+ using var content = Build(contentType: contentType);
+ });
+ Assert.Null(ex);
+
+ using var built = Build(contentType: contentType);
+ var body = await Render(built);
+ Assert.Contains("fake audio bytes", body);
+ }
+
+ [Fact]
+ public async Task AValidContentTypeIsStillForwarded()
+ {
+ // The permissive path above must not have made this a no-op.
+ using var content = Build(contentType: "audio/mp4");
+ var body = await Render(content);
+
+ Assert.Contains("audio/mp4", body);
+ }
+}
diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs
index c1d711e4..66363e08 100644
--- a/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs
+++ b/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs
@@ -613,4 +613,120 @@ public static async Task AiAssist(HttpContext ctx)
// AI assist generation can take a long time; keep it long.
await Upstream.Pipe(ApiClient.ApiRequest("ai-assist", HttpMethod.Post, null, data, null, 120000), ctx);
}
+
+ public static async Task AiTranscribePrice(HttpContext ctx)
+ {
+ var body = await ctx.ReadBody();
+ var username = await ValidateCode(body);
+ if (username == null)
+ {
+ await ctx.SendText(401, "Unauthorized");
+ return;
+ }
+ await Upstream.Pipe(
+ ApiClient.ApiRequest($"ai-transcribe-price?us={username}", HttpMethod.Get), ctx);
+ }
+
+ ///
+ /// Dictation. Unlike every other private-api route this one carries a file, so the
+ /// request is multipart/form-data rather than JSON and the auth code arrives as a
+ /// form field instead of a JSON property.
+ ///
+ /// `us` is taken from the validated code and never from the client, matching
+ /// AiAssist: the upstream bills whoever `us` names, so accepting it from the body
+ /// would let a caller spend someone else's Points.
+ ///
+ public static async Task AiTranscribe(HttpContext ctx)
+ {
+ if (!ctx.Request.HasFormContentType)
+ {
+ await ctx.SendText(400, "Expected multipart/form-data");
+ return;
+ }
+
+ IFormCollection form;
+ try
+ {
+ form = await ctx.Request.ReadFormAsync();
+ }
+ catch (Exception e)
+ {
+ // Malformed multipart, or a body past Kestrel's limit.
+ Console.Error.WriteLine($"aiTranscribe(): unreadable form: {e.Message}");
+ await ctx.SendText(400, "Bad Request");
+ return;
+ }
+
+ // ValidateCode takes the JSON body shape, so lift the form field into it and
+ // reuse the one implementation rather than growing a second auth path.
+ var codeBody = new JsonObject { ["code"] = form["code"].ToString() };
+ var username = await ValidateCode(codeBody);
+ if (username == null)
+ {
+ await ctx.SendText(401, "Unauthorized");
+ return;
+ }
+
+ var audio = form.Files.GetFile("audio");
+ if (audio == null)
+ {
+ await ctx.SendText(400, "Missing audio");
+ return;
+ }
+
+ await using var audioStream = audio.OpenReadStream();
+ using var content = BuildTranscribeContent(
+ username,
+ form["duration_ms"].ToString(),
+ form["idempotency_key"].ToString(),
+ audioStream,
+ audio.FileName,
+ audio.ContentType);
+
+ // Transcription is a vendor round trip on top of the upload; keep it long,
+ // matching ai-assist and ai-image-generate.
+ await Upstream.Pipe(ApiClient.ApiMultipartRequest("ai-transcribe", content, 120000), ctx);
+ }
+
+ ///
+ /// Builds the upstream multipart body. Split out from the handler so the part it
+ /// gets wrong-once-and-badly is testable: `us` must be the caller resolved from the
+ /// signed code, never a value the client supplied, because upstream bills whoever
+ /// `us` names.
+ ///
+ public static MultipartFormDataContent BuildTranscribeContent(
+ string username,
+ string durationMs,
+ string? idempotencyKey,
+ Stream audio,
+ string? fileName,
+ string? contentType)
+ {
+ var content = new MultipartFormDataContent();
+ content.Add(new StringContent(username), "us");
+ content.Add(new StringContent(durationMs), "duration_ms");
+
+ // Omit rather than send empty: upstream treats an empty key as absent anyway,
+ // and sending "" would fail its [A-Za-z0-9_-]{8,64} validator with a 400.
+ if (!string.IsNullOrEmpty(idempotencyKey))
+ {
+ content.Add(new StringContent(idempotencyKey), "idempotency_key");
+ }
+
+ var fileContent = new StreamContent(audio);
+ // TryParse, not Parse. This value is client-controlled, and Parse throws
+ // FormatException on malformed input -- which escapes the handler's
+ // form-reading catch and surfaces as a 500 from the global middleware. A
+ // label we cannot parse is not worth failing an otherwise valid upload over,
+ // so it is dropped exactly like an absent one; upstream identifies the audio
+ // from its contents regardless.
+ if (!string.IsNullOrEmpty(contentType) &&
+ System.Net.Http.Headers.MediaTypeHeaderValue.TryParse(contentType, out var parsedType))
+ {
+ fileContent.Headers.ContentType = parsedType;
+ }
+ content.Add(fileContent, "audio", string.IsNullOrEmpty(fileName) ? "audio" : fileName);
+
+ return content;
+ }
}
diff --git a/dotnet/EcencyApi/Handlers/Routes.cs b/dotnet/EcencyApi/Handlers/Routes.cs
index 50648a0c..e4d40a51 100644
--- a/dotnet/EcencyApi/Handlers/Routes.cs
+++ b/dotnet/EcencyApi/Handlers/Routes.cs
@@ -157,6 +157,8 @@ public static void Map(WebApplication app)
app.MapPost("/private-api/ai-generate-image", PrivateApi.AiGenerateImage);
app.MapPost("/private-api/ai-assist-price", PrivateApi.AiAssistPrice);
app.MapPost("/private-api/ai-assist", PrivateApi.AiAssist);
+ app.MapPost("/private-api/ai-transcribe-price", PrivateApi.AiTranscribePrice);
+ app.MapPost("/private-api/ai-transcribe", PrivateApi.AiTranscribe);
app.MapPost("/private-api/usr-activity", PrivateApi.Activities);
app.MapPost("/private-api/get-game", PrivateApi.GameGet);
app.MapPost("/private-api/post-game", PrivateApi.GamePost);
diff --git a/dotnet/EcencyApi/Infrastructure/ApiClient.cs b/dotnet/EcencyApi/Infrastructure/ApiClient.cs
index 95d1fefc..a2cab631 100644
--- a/dotnet/EcencyApi/Infrastructure/ApiClient.cs
+++ b/dotnet/EcencyApi/Infrastructure/ApiClient.cs
@@ -68,6 +68,23 @@ public static Task ApiRequest(
JsonNode? payload = null,
IEnumerable>? query = null,
int timeoutMs = Upstream.DefaultTimeoutMs)
+ {
+ var headers = RequiredAuthHeaders();
+ if (extraHeaders != null)
+ {
+ headers.AddRange(extraHeaders);
+ }
+
+ return Upstream.BaseApiRequest(
+ $"{Config.PrivateApiAddr}/{endpoint}", method, headers, payload, query, timeoutMs);
+ }
+
+ ///
+ /// PRIVATE_API_AUTH headers every upstream call carries. Throws the same way the
+ /// Node version failed when they can't be built, so a misconfigured deployment is
+ /// loud rather than silently unauthenticated.
+ ///
+ private static List> RequiredAuthHeaders()
{
var apiAuth = MakeApiAuth();
if (apiAuth == null)
@@ -76,19 +93,26 @@ public static Task ApiRequest(
throw new ApiAuthException();
}
- var url = $"{Config.PrivateApiAddr}/{endpoint}";
-
var headers = new List>();
foreach (var kv in apiAuth)
{
headers.Add(kv);
}
- if (extraHeaders != null)
- {
- headers.AddRange(extraHeaders);
- }
+ return headers;
+ }
- return Upstream.BaseApiRequest(url, method, headers, payload, query, timeoutMs);
+ ///
+ /// multipart/form-data variant of ApiRequest, for endpoints carrying a file.
+ /// Applies the same PRIVATE_API_AUTH headers and fails the same way when they
+ /// can't be built.
+ ///
+ public static Task ApiMultipartRequest(
+ string endpoint,
+ MultipartFormDataContent content,
+ int timeoutMs = Upstream.DefaultTimeoutMs)
+ {
+ return Upstream.BaseMultipartRequest(
+ $"{Config.PrivateApiAddr}/{endpoint}", content, RequiredAuthHeaders(), timeoutMs);
}
/// fetchPromotedEntries + getPromotedEntries (5-minute cached, shuffled).
diff --git a/dotnet/EcencyApi/Infrastructure/Upstream.cs b/dotnet/EcencyApi/Infrastructure/Upstream.cs
index 6c539c30..e5add5d0 100644
--- a/dotnet/EcencyApi/Infrastructure/Upstream.cs
+++ b/dotnet/EcencyApi/Infrastructure/Upstream.cs
@@ -131,30 +131,84 @@ public static async Task BaseApiRequest(
using (resp)
{
- var bytes = await resp.Content.ReadAsByteArrayAsync();
- var status = (int)resp.StatusCode;
- var respHeaders = new HttpResponseHeaders2(resp);
+ return await ReadUpstreamResponse(resp);
+ }
+ }
- // axios responseType "json": try to parse; fall back to raw text.
- var text = Encoding.UTF8.GetString(bytes);
- if (text.Length == 0)
- {
- // axios turns an empty body into an empty string
- return new UpstreamResponse { Status = status, RawText = "", Headers = respHeaders };
- }
+ ///
+ /// Multipart/form-data variant of BaseApiRequest, for endpoints that carry a file
+ /// rather than a JSON body. The JSON path can't be reused: it always sets a
+ /// StringContent body with an application/json content type, and multipart needs
+ /// the boundary that MultipartFormDataContent generates for itself.
+ ///
+ /// The caller owns building the content; response handling is identical.
+ ///
+ public static async Task BaseMultipartRequest(
+ string url,
+ MultipartFormDataContent content,
+ IEnumerable>? headers = null,
+ int timeoutMs = DefaultTimeoutMs)
+ {
+ using var req = new HttpRequestMessage(HttpMethod.Post, url);
+ req.Content = content;
- try
+ if (headers != null)
+ {
+ foreach (var (name, value) in headers)
{
- var node = JsonNode.Parse(text, documentOptions: new JsonDocumentOptions
+ // Never let a caller override Content-Type here: multipart carries a
+ // generated boundary, and replacing it makes the body unparseable
+ // upstream in a way that only shows up as a confusing 400.
+ if (name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase))
{
- AllowTrailingCommas = false,
- });
- return new UpstreamResponse { Status = status, Json = node, Headers = respHeaders };
+ continue;
+ }
+ req.Headers.TryAddWithoutValidation(name, value);
}
- catch (JsonException)
+ }
+
+ using var cts = new CancellationTokenSource(timeoutMs);
+ HttpResponseMessage resp;
+ try
+ {
+ resp = await Http.SendAsync(req, HttpCompletionOption.ResponseContentRead, cts.Token);
+ }
+ catch (OperationCanceledException e) when (cts.IsCancellationRequested)
+ {
+ throw new UpstreamTimeoutException(url, e);
+ }
+
+ using (resp)
+ {
+ return await ReadUpstreamResponse(resp);
+ }
+ }
+
+ private static async Task ReadUpstreamResponse(HttpResponseMessage resp)
+ {
+ var bytes = await resp.Content.ReadAsByteArrayAsync();
+ var status = (int)resp.StatusCode;
+ var respHeaders = new HttpResponseHeaders2(resp);
+
+ // axios responseType "json": try to parse; fall back to raw text.
+ var text = Encoding.UTF8.GetString(bytes);
+ if (text.Length == 0)
+ {
+ // axios turns an empty body into an empty string
+ return new UpstreamResponse { Status = status, RawText = "", Headers = respHeaders };
+ }
+
+ try
+ {
+ var node = JsonNode.Parse(text, documentOptions: new JsonDocumentOptions
{
- return new UpstreamResponse { Status = status, RawText = text, Headers = respHeaders };
- }
+ AllowTrailingCommas = false,
+ });
+ return new UpstreamResponse { Status = status, Json = node, Headers = respHeaders };
+ }
+ catch (JsonException)
+ {
+ return new UpstreamResponse { Status = status, RawText = text, Headers = respHeaders };
}
}
diff --git a/dotnet/parity/__pycache__/driver.cpython-312.pyc b/dotnet/parity/__pycache__/driver.cpython-312.pyc
index 38e24022..40b03ce4 100644
Binary files a/dotnet/parity/__pycache__/driver.cpython-312.pyc and b/dotnet/parity/__pycache__/driver.cpython-312.pyc differ
diff --git a/dotnet/parity/driver.py b/dotnet/parity/driver.py
index e990cc71..30948790 100644
--- a/dotnet/parity/driver.py
+++ b/dotnet/parity/driver.py
@@ -216,6 +216,14 @@ def norm_body(text):
return text
+AI_TRANSCRIBE_DIVERGENCE = (
+ "Dictation route added after the port (ePoints ai-transcribe). The reference build "
+ "has no such route and answers 404; this one validates the request and answers "
+ "400/401. Deterministic and additive -- no behavior the reference ever had is "
+ "changing, so there is nothing meaningful to diff. The catalog generates ::min, "
+ "::pop and ::badcode for every POST route, which is why all three appear here."
+)
+
# Cases where the C# port intentionally differs from Node (Node bugs the port fixes).
KNOWN_DIVERGENCES = {
"/auth-api/hs-token-refresh::min":
@@ -233,6 +241,21 @@ def norm_body(text):
"has no such route, so it answers with the unmatched-GET template page while "
"this one proxies the tips payload. Deterministic and additive; the POST case "
"still covers the shared upstream behavior.",
+ # Dictation routes, added after the port. The reference build predates them and so
+ # answers 404, while this build validates and answers 400/401. Purely additive: no
+ # behavior the reference ever had is changing, so there is nothing to compare.
+ "/private-api/ai-transcribe-price::min":
+ AI_TRANSCRIBE_DIVERGENCE,
+ "/private-api/ai-transcribe-price::pop":
+ AI_TRANSCRIBE_DIVERGENCE,
+ "/private-api/ai-transcribe-price::badcode":
+ AI_TRANSCRIBE_DIVERGENCE,
+ "/private-api/ai-transcribe::min":
+ AI_TRANSCRIBE_DIVERGENCE,
+ "/private-api/ai-transcribe::pop":
+ AI_TRANSCRIBE_DIVERGENCE,
+ "/private-api/ai-transcribe::badcode":
+ AI_TRANSCRIBE_DIVERGENCE,
}
# Deliberately NOT listed above: /wallet-api/portfolio-v2::pop, whose HP action list