-
Notifications
You must be signed in to change notification settings - Fork 2
feat(private-api): add ai-transcribe routes with multipart passthrough #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| using System.Text; | ||
| using EcencyApi.Handlers; | ||
| using Xunit; | ||
|
|
||
| namespace EcencyApi.Tests; | ||
|
|
||
| /// <summary> | ||
| /// 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. | ||
| /// </summary> | ||
| 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<string> 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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -613,4 +613,114 @@ 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); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// 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. | ||
| /// </summary> | ||
| 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); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// 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. | ||
| /// </summary> | ||
| 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); | ||
| if (!string.IsNullOrEmpty(contentType)) | ||
| { | ||
| fileContent.Headers.ContentType = | ||
| System.Net.Http.Headers.MediaTypeHeaderValue.Parse(contentType); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an authenticated multipart upload contains a syntactically malformed per-file Useful? React with 👍 / 👎. |
||
| } | ||
| content.Add(fileContent, "audio", string.IsNullOrEmpty(fileName) ? "audio" : fileName); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| return content; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
Comment on lines
+160
to
+161
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Adding these routes causes the legacy-Node parity run to report six deterministic failures: Useful? React with 👍 / 👎. |
||
| app.MapPost("/private-api/usr-activity", PrivateApi.Activities); | ||
| app.MapPost("/private-api/get-game", PrivateApi.GameGet); | ||
| app.MapPost("/private-api/post-game", PrivateApi.GamePost); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.