From ea6d69787544fa8f57887ff0d98aa2897facf008 Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 31 Jul 2026 11:08:58 +0000 Subject: [PATCH 1/2] feat(private-api): add ai-transcribe routes with multipart passthrough Proxies the dictation endpoints added in ePoints #45: POST /private-api/ai-transcribe-price -> ai-transcribe-price?us= POST /private-api/ai-transcribe -> ai-transcribe (multipart) This is the first private-api route that carries a file, so the JSON path could not be reused. BaseApiRequest always sets a StringContent body with an application/json content type, while multipart needs the boundary that MultipartFormDataContent generates for itself. Added BaseMultipartRequest and ApiMultipartRequest alongside the existing pair, sharing the response handling via a factored-out ReadUpstreamResponse rather than duplicating it. The auth code arrives as a form field rather than a JSON property, so the handler lifts it into the body shape ValidateCode already takes instead of growing a second auth path. `us` is taken from the validated code and never from the request, matching AiAssist: upstream burns Points from whoever `us` names, so accepting it from the client would let a caller spend someone else's balance. Timeout is 120s, matching ai-assist and ai-image-generate -- transcription is a vendor round trip on top of the upload. Kestrel already allows 50MB bodies, comfortably above the 12MB cap upstream enforces. 8 tests over the multipart builder, covering the `us` provenance, omitting an empty idempotency_key rather than sending a blank one that fails upstream's validator, and the generated boundary. --- .../AiTranscribeContentTests.cs | 114 ++++++++++++++++++ dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs | 110 +++++++++++++++++ dotnet/EcencyApi/Handlers/Routes.cs | 2 + dotnet/EcencyApi/Infrastructure/ApiClient.cs | 28 +++++ dotnet/EcencyApi/Infrastructure/Upstream.cs | 90 +++++++++++--- 5 files changed, 326 insertions(+), 18 deletions(-) create mode 100644 dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs diff --git a/dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs b/dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs new file mode 100644 index 00000000..c8828de1 --- /dev/null +++ b/dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs @@ -0,0 +1,114 @@ +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); + } +} diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs index c1d711e4..0c0017d2 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs @@ -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); + } + + /// + /// 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); + if (!string.IsNullOrEmpty(contentType)) + { + fileContent.Headers.ContentType = + System.Net.Http.Headers.MediaTypeHeaderValue.Parse(contentType); + } + 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..3b7806d6 100644 --- a/dotnet/EcencyApi/Infrastructure/ApiClient.cs +++ b/dotnet/EcencyApi/Infrastructure/ApiClient.cs @@ -91,6 +91,34 @@ public static Task ApiRequest( 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) + { + var apiAuth = MakeApiAuth(); + if (apiAuth == null) + { + Console.Error.WriteLine("Api auth couldn't be create!"); + throw new ApiAuthException(); + } + + var url = $"{Config.PrivateApiAddr}/{endpoint}"; + + var headers = new List>(); + foreach (var kv in apiAuth) + { + headers.Add(kv); + } + + return Upstream.BaseMultipartRequest(url, content, headers, timeoutMs); + } + /// fetchPromotedEntries + getPromotedEntries (5-minute cached, shuffled). public static async Task GetPromotedEntries(int limit, int shortContent) { 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 }; } } From e57ac6e0471af5dfb177d74ab96aa633e426fc14 Mon Sep 17 00:00:00 2001 From: feruzm Date: Fri, 31 Jul 2026 11:19:53 +0000 Subject: [PATCH 2/2] fix(transcribe): tolerate a malformed audio content type, cover parity cases Three review findings. A malformed audio Content-Type returned 500. The value is client-controlled and MediaTypeHeaderValue.Parse throws FormatException, which escapes the handler's form-reading catch and becomes a 500 from the global exception middleware. TryParse instead, and drop an unparseable label exactly like an absent one -- the upload is fine and upstream identifies the audio from its contents. Six malformed values are now covered by tests; all six were verified to genuinely throw under Parse, so the cases exercise the path they claim to. The two new POST routes broke the parity harness. The catalog generates ::min, ::pop and ::badcode for every POST route, so these added six cases where the reference build answers 404 (no such route) and this one answers 400/401. Left unlisted they would have been permanent deterministic failures, which is worse than useless because it hides real regressions. Added entries in the style of the existing post-tips GET twin, and verified every generated id is covered rather than assuming the key format was right. Extracted the duplicated PRIVATE_API_AUTH header construction shared by ApiRequest and ApiMultipartRequest into RequiredAuthHeaders. --- .../AiTranscribeContentTests.cs | 36 ++++++++++++++ dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs | 14 ++++-- dotnet/EcencyApi/Infrastructure/ApiClient.cs | 44 ++++++++---------- .../parity/__pycache__/driver.cpython-312.pyc | Bin 15546 -> 16557 bytes dotnet/parity/driver.py | 23 +++++++++ 5 files changed, 89 insertions(+), 28 deletions(-) diff --git a/dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs b/dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs index c8828de1..869f3959 100644 --- a/dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs +++ b/dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs @@ -111,4 +111,40 @@ public async Task TolerantOfAMissingAudioContentType() 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 0c0017d2..66363e08 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs @@ -714,10 +714,16 @@ public static MultipartFormDataContent BuildTranscribeContent( } var fileContent = new StreamContent(audio); - if (!string.IsNullOrEmpty(contentType)) - { - fileContent.Headers.ContentType = - System.Net.Http.Headers.MediaTypeHeaderValue.Parse(contentType); + // 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); diff --git a/dotnet/EcencyApi/Infrastructure/ApiClient.cs b/dotnet/EcencyApi/Infrastructure/ApiClient.cs index 3b7806d6..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,12 @@ 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 Upstream.BaseApiRequest(url, method, headers, payload, query, timeoutMs); + return headers; } /// @@ -101,22 +111,8 @@ public static Task ApiMultipartRequest( MultipartFormDataContent content, int timeoutMs = Upstream.DefaultTimeoutMs) { - var apiAuth = MakeApiAuth(); - if (apiAuth == null) - { - Console.Error.WriteLine("Api auth couldn't be create!"); - throw new ApiAuthException(); - } - - var url = $"{Config.PrivateApiAddr}/{endpoint}"; - - var headers = new List>(); - foreach (var kv in apiAuth) - { - headers.Add(kv); - } - - return Upstream.BaseMultipartRequest(url, content, headers, timeoutMs); + return Upstream.BaseMultipartRequest( + $"{Config.PrivateApiAddr}/{endpoint}", content, RequiredAuthHeaders(), timeoutMs); } /// fetchPromotedEntries + getPromotedEntries (5-minute cached, shuffled). diff --git a/dotnet/parity/__pycache__/driver.cpython-312.pyc b/dotnet/parity/__pycache__/driver.cpython-312.pyc index 38e2402274682a797fb0702516f094a2e3a1a33c..40b03ce491b8ecb1e99ad5cc75ccb8cb36779e27 100644 GIT binary patch delta 1390 zcma)6L2naB6yCMnHYQ4dq82Ekd4WQRnvDZ8Dl)VZAr?(rg($J88mU6#ooDZa_0BRg zYsYEJwi1`Br^;9Q3s4W#RSy*xByRN3b5EQ&8&#@$?AzVA*r=-TdUv%u-}}Dry>GO? zc7OcFo_;(vwa=noe8=DZ`_lK*N9^D3&z;Jbw~q5$Tyaf@!Q;;l9^IYXdYi|*eaGIK zLExR6XDmxSz{2)&@BZtDj?Eq=wyhI*g72g&khAb4ow@A~dw27*bKQHnM9;GB+1cH; zx;aAcrs#JFd2Gm4{378RK3)7Wc^X#lRehCKeMVJ*0PYa#XG5eM*uL##LBV+(dMh1a#jvQFSpaSC| zgi;wegX>ZRMgtR$Q7q7|5_Ozkgc>0fHc(;UBGe<{bMTmkK!T23Z^#)CW1u^zG?bUh z7l~MCh-HA#X1?IepiadU-ilBg0*@9fmC8$J7hwg-Q%eLw8{uXU&V>*%qz zkeTo2uCPt5c#H#Sx*iLfB4{pvmT@AfFnJwFauhV7g)E?ZBl5vW;G)sUT6CFVzHCAh z15~NoN`;&(K&2AOF!fTY)ERdrM`)0PM6(O)*EVa}2o|8@37Q9Kb-XSxp9Bti3TfV9 zh>ZM^lg0k$?|y2>Y?@+O!w$I2^^nzlgsasWn2rcQHZsyus6?BLI4l&UP-^2C5$c!B zBWWH)GUY6ijxb(2{wQcM6O$-+QblZqK8ZlcnwT^|PSWI(;6f#L#9nNPHi5dnWc>ef zN`l9%0heh63X%rlN*Y#{zxJ3qOxrICwNc1g|3f zlb$N{A76m{@`1uDHG7b^?4yG>?c%yUc-1a$($y-Sdopo$P{iM9HSqrj)cWM#-hh zr<7)EFfbJDNoC1$n0!%6SXxmMs5eS6RWYRuETh20kg5d4Oj&M|*D)(HmQTLGEGnp& zQUR7#0m?|j^m0~$^ul=7RUqDKCLjR>j0}}bKr^esx*~zPSh74IGRP!QTn?rm)ihqP zX+U{ZuzaDA@Z|L@9Nab8EUVGc zYM{c-^on7#w1p2dquS&Q>w}Y>ZM?a^f*r@eQ2c^<^Gq8BW?2zN&H%;_Y>b@2K$3ye X=mCe}M^SE3rV9*$pJgWhw%-H*=U-YV 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