Skip to content

feat(private-api): add ai-transcribe routes with multipart passthrough - #62

Merged
feruzm merged 2 commits into
mainfrom
feature/ai-transcribe-routes
Jul 31, 2026
Merged

feat(private-api): add ai-transcribe routes with multipart passthrough#62
feruzm merged 2 commits into
mainfrom
feature/ai-transcribe-routes

Conversation

@feruzm

@feruzm feruzm commented Jul 31, 2026

Copy link
Copy Markdown
Member

Proxies the dictation endpoints added in ePoints #45 (merged and deployed).

POST /private-api/ai-transcribe-price  ->  ai-transcribe-price?us=<caller>
POST /private-api/ai-transcribe        ->  ai-transcribe (multipart/form-data)

Second of four PRs: ePoints (done) → vapi → vision-next/SDK + web → mobile.

Why this needed new infrastructure

This is the first private-api route that carries a file. BaseApiRequest always sets a StringContent body with an application/json content type, and multipart needs the boundary that MultipartFormDataContent generates for itself — so the JSON path could not be reused with a different content type bolted on.

Added Upstream.BaseMultipartRequest and ApiClient.ApiMultipartRequest alongside the existing pair. Response handling was factored out into ReadUpstreamResponse and is shared, so the two paths cannot drift on status/JSON/raw-text semantics.

BaseMultipartRequest deliberately ignores any caller-supplied Content-Type header. Overriding it drops the generated boundary and makes the body unparseable upstream, which surfaces only as a confusing 400.

Auth

The code arrives as a form field rather than a JSON property. Rather than grow a second auth path, the handler lifts it into the body shape ValidateCode already takes.

us comes from the validated code, never from the request. Upstream burns Points from whoever us names, so accepting it from the client would let a caller spend someone else's balance. This matches AiAssist, and there is a test pinning it.

Details

  • Timeout 120s, matching ai-assist and ai-image-generate — transcription is a vendor round trip on top of the upload.
  • Kestrel already allows 50MB bodies (Program.cs:27), comfortably above the 12MB cap ePoints enforces, so an oversized upload gets ePoints' structured audio_too_large rather than a Kestrel-level failure.
  • Empty idempotency_key is omitted rather than forwarded blank: upstream validates it against [A-Za-z0-9_-]{8,64}, so a blank one is a 400 while an absent one is simply an un-deduplicated request.
  • Non-multipart requests get a 400 before any auth work.

Tests

8 new, 88 passing overall. They cover the us provenance, the empty-key omission, filename fallback, a missing audio content type (expo-audio and MediaRecorder do not always label the part), and that the generated boundary survives.

Note on merging

Merge to main is a production deploy here. ePoints is already live with these endpoints, so the routes will work immediately on merge — but nothing calls them until the SDK ships in the next PR.

Summary by CodeRabbit

  • New Features

    • Added AI transcription pricing and transcription endpoints.
    • Added support for authenticated multipart audio uploads, including duration, optional idempotency keys, filenames, and content types.
    • Added request timeout handling and consistent upstream response processing.
  • Bug Fixes

    • Improved multipart request handling by preserving generated boundaries and supporting empty, JSON, and plain-text responses.

Proxies the dictation endpoints added in ePoints #45:
  POST /private-api/ai-transcribe-price  -> ai-transcribe-price?us=<caller>
  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.
@greptile-apps

greptile-apps Bot commented Jul 31, 2026

Copy link
Copy Markdown

Greptile Summary

Adds authenticated AI transcription pricing and multipart audio proxy routes.

  • Builds multipart upstream requests while preserving generated boundaries.
  • Derives the billed username from the validated authentication code.
  • Shares upstream JSON, text, status, and timeout handling between request types.
  • Replaces malformed audio media-type parsing with a non-throwing fallback.

Confidence Score: 5/5

The PR appears safe to merge.

The previously reported malformed media-type failure is fixed by non-throwing parsing that drops invalid labels, and no blocking failure remains.

Important Files Changed

Filename Overview
dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs Adds authenticated transcription handlers and safely drops malformed audio media types instead of throwing.
dotnet/EcencyApi/Infrastructure/ApiClient.cs Factors required upstream authentication headers and adds a multipart request entry point.
dotnet/EcencyApi/Infrastructure/Upstream.cs Adds multipart passthrough with boundary preservation and shared upstream response decoding.
dotnet/EcencyApi/Handlers/Routes.cs Registers the two new private transcription routes.
dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs Covers authenticated username provenance, multipart fields, generated boundaries, filename fallback, and malformed media types.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Vision as Vision API
    participant Auth as Code validation
    participant EPoints as ePoints API
    Client->>Vision: POST /private-api/ai-transcribe (multipart)
    Vision->>Vision: Parse form and audio
    Vision->>Auth: Validate code field
    Auth-->>Vision: Authenticated username
    Vision->>Vision: Build multipart with username as us
    Vision->>EPoints: POST ai-transcribe
    EPoints-->>Vision: Status and JSON/text response
    Vision-->>Client: Forward response
Loading

Reviews (2): Last reviewed commit: "fix(transcribe): tolerate a malformed au..." | Re-trigger Greptile

Comment thread dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ea6d697875

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +160 to +161
app.MapPost("/private-api/ai-transcribe-price", PrivateApi.AiTranscribePrice);
app.MapPost("/private-api/ai-transcribe", PrivateApi.AiTranscribe);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Register the new routes as parity divergences

Adding these routes causes the legacy-Node parity run to report six deterministic failures: dotnet/parity/driver.py's load_routes and build_catalog automatically generate ::min, ::pop, and ::badcode JSON probes for each new POST route, while the reference image has neither route and returns 404 instead of the candidate's 400/401. Because none of those case IDs are in KNOWN_DIVERGENCES, every parity comparison now fails before it can identify unintended regressions; add exclusions for the intentional additive routes or otherwise teach the harness how to classify them.

Useful? React with 👍 / 👎.

Comment on lines +719 to +720
fileContent.Headers.ContentType =
System.Net.Http.Headers.MediaTypeHeaderValue.Parse(contentType);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle malformed audio Content-Type without returning 500

When an authenticated multipart upload contains a syntactically malformed per-file Content-Type, IFormFile.ContentType can carry that raw value here and MediaTypeHeaderValue.Parse throws FormatException. This occurs after the form-reading catch and before Upstream.Pipe, so the global middleware turns a client-controlled part header into a 500 and the transcription is never forwarded; use TryParse and omit an invalid media type, as is already done when the header is missing.

Useful? React with 👍 / 👎.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@feruzm, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 49 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 77d41d89-a22e-4a5d-a0c0-07dac5d340aa

📥 Commits

Reviewing files that changed from the base of the PR and between ea6d697 and e57ac6e.

⛔ Files ignored due to path filters (1)
  • dotnet/parity/__pycache__/driver.cpython-312.pyc is excluded by !**/*.pyc
📒 Files selected for processing (4)
  • dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs
  • dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs
  • dotnet/EcencyApi/Infrastructure/ApiClient.cs
  • dotnet/parity/driver.py
📝 Walkthrough

Walkthrough

The PR adds authenticated AI transcription pricing and transcription routes. It builds multipart requests with validated fields and audio metadata. It adds authenticated multipart upstream transport, timeout handling, shared response parsing, and content construction tests.

Changes

AI transcription API

Layer / File(s) Summary
Multipart upstream transport
dotnet/EcencyApi/Infrastructure/Upstream.cs
Multipart POST requests now preserve generated boundaries, filter Content-Type overrides, apply timeouts, and use shared response parsing.
Authenticated multipart client
dotnet/EcencyApi/Infrastructure/ApiClient.cs
ApiMultipartRequest adds private API authentication headers and forwards multipart content upstream.
Transcription endpoints and payload validation
dotnet/EcencyApi/Handlers/Routes.cs, dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs, dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs
The new routes invoke authenticated pricing and transcription handlers. The handlers validate form data, require audio, build transcription fields, and use a 120-second timeout. Tests cover fields, filenames, boundaries, and missing content types.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant Routes
  participant PrivateApi
  participant ApiClient
  participant Upstream
  Caller->>Routes: POST ai-transcribe
  Routes->>PrivateApi: Invoke AiTranscribe
  PrivateApi->>PrivateApi: Validate form code and audio upload
  PrivateApi->>PrivateApi: BuildTranscribeContent
  PrivateApi->>ApiClient: ApiMultipartRequest with 120-second timeout
  ApiClient->>Upstream: BaseMultipartRequest
  Upstream-->>ApiClient: Parsed upstream response
  ApiClient-->>PrivateApi: Transcription response
Loading

Poem

I’m a rabbit with multipart cheer,
Carrying audio from ear to ear.
Boundaries bloom, fields hop in line,
Auth and timeouts keep requests fine.
Tests nibble each case with care.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding private API AI transcription routes with multipart request passthrough.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/ai-transcribe-routes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs (1)

105-113: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for a malformed audio Content-Type.

TolerantOfAMissingAudioContentType only covers a null content type. Add a case with a malformed, non-empty content type (for example "not a media type") to cover the MediaTypeHeaderValue.Parse issue flagged in PrivateApi.Misc.cs BuildTranscribeContent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs` around lines 105 - 113,
Add a test case in AiTranscribeContentTests alongside
TolerantOfAMissingAudioContentType that builds audio content with a malformed
non-empty Content-Type such as "not a media type", renders it, and asserts the
fake audio bytes remain present. This should exercise BuildTranscribeContent and
confirm malformed media types are handled like missing content types.
dotnet/EcencyApi/Infrastructure/ApiClient.cs (1)

94-121: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract duplicated auth-header construction.

ApiMultipartRequest repeats the MakeApiAuth call, null check, error log, and header-list build already present in ApiRequest (Lines 72-91). Extract a shared helper so a future change to auth-header construction does not need to be applied in two places.

♻️ Proposed refactor
+    private static List<KeyValuePair<string, string>> RequireApiAuthHeaders()
+    {
+        var apiAuth = MakeApiAuth();
+        if (apiAuth == null)
+        {
+            Console.Error.WriteLine("Api auth couldn't be create!");
+            throw new ApiAuthException();
+        }
+
+        return new List<KeyValuePair<string, string>>(apiAuth);
+    }
+
     public static Task<UpstreamResponse> ApiRequest(
         string endpoint,
         HttpMethod method,
         IEnumerable<KeyValuePair<string, string>>? extraHeaders = null,
         JsonNode? payload = null,
         IEnumerable<KeyValuePair<string, string?>>? query = null,
         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<KeyValuePair<string, string>>();
-        foreach (var kv in apiAuth)
-        {
-            headers.Add(kv);
-        }
+        var headers = RequireApiAuthHeaders();
         if (extraHeaders != null)
         {
             headers.AddRange(extraHeaders);
         }

         return Upstream.BaseApiRequest(url, method, headers, payload, query, timeoutMs);
     }

     public static Task<UpstreamResponse> 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<KeyValuePair<string, string>>();
-        foreach (var kv in apiAuth)
-        {
-            headers.Add(kv);
-        }
+        var headers = RequireApiAuthHeaders();

         return Upstream.BaseMultipartRequest(url, content, headers, timeoutMs);
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dotnet/EcencyApi/Infrastructure/ApiClient.cs` around lines 94 - 121, The
duplicated authentication setup in ApiMultipartRequest should be extracted into
a shared helper used by both ApiRequest and ApiMultipartRequest. Move
MakeApiAuth invocation, null handling, error logging, and header-list
construction into the helper, then have both request methods reuse it while
preserving the existing ApiAuthException behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs`:
- Around line 716-722: Update the audio content setup in the multipart handler
to use MediaTypeHeaderValue.TryParse for the client-supplied contentType before
assigning fileContent.Headers.ContentType. Set the header only when parsing
succeeds, while preserving the existing behavior for empty values and file
attachment naming.

---

Nitpick comments:
In `@dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs`:
- Around line 105-113: Add a test case in AiTranscribeContentTests alongside
TolerantOfAMissingAudioContentType that builds audio content with a malformed
non-empty Content-Type such as "not a media type", renders it, and asserts the
fake audio bytes remain present. This should exercise BuildTranscribeContent and
confirm malformed media types are handled like missing content types.

In `@dotnet/EcencyApi/Infrastructure/ApiClient.cs`:
- Around line 94-121: The duplicated authentication setup in ApiMultipartRequest
should be extracted into a shared helper used by both ApiRequest and
ApiMultipartRequest. Move MakeApiAuth invocation, null handling, error logging,
and header-list construction into the helper, then have both request methods
reuse it while preserving the existing ApiAuthException behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7972cdd8-ef4e-4b24-b951-668682296209

📥 Commits

Reviewing files that changed from the base of the PR and between a24b8f2 and ea6d697.

📒 Files selected for processing (5)
  • dotnet/EcencyApi.Tests/AiTranscribeContentTests.cs
  • dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs
  • dotnet/EcencyApi/Handlers/Routes.cs
  • dotnet/EcencyApi/Infrastructure/ApiClient.cs
  • dotnet/EcencyApi/Infrastructure/Upstream.cs

Comment thread dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs
…y 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.
@feruzm

feruzm commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

All three findings confirmed and fixed. Greptile, Codex and CodeRabbit all ran; the malformed Content-Type was flagged by all three, the parity gap by Codex, the duplication by CodeRabbit.

Malformed audio Content-Type returned 500

Correct, and the 500 is the whole problem — it is a client-controlled value producing a server error. MediaTypeHeaderValue.Parse throws FormatException, which escapes the handler's form-reading catch and reaches the global middleware.

Now TryParse, dropping an unparseable label exactly like an absent one. The upload itself is valid and upstream identifies the audio from its contents, so failing the request over a bad label would be the wrong trade.

Six malformed values are covered by tests. I checked each actually throws under Parse rather than assuming:

audio                     Parse throws=True   TryParse ok=False
audio/                    Parse throws=True   TryParse ok=False
audio/mp4;                Parse throws=True   TryParse ok=False
audio/mp4; codecs=        Parse throws=True   TryParse ok=False
not a media type at all   Parse throws=True   TryParse ok=False
///                       Parse throws=True   TryParse ok=False
audio/mp4                 Parse throws=False  TryParse ok=True

The last row is a test too — the permissive path must not have quietly become a no-op for valid types.

New routes broke the parity harness

Also correct, and worth more than the six cases: permanent deterministic failures are worse than useless because they train you to ignore the harness and hide real regressions.

Added entries in the style of the existing post-tips/x/x::get twin, sharing one explanatory constant since all six have the same cause.

Rather than assume my key format was right, I ran build_catalog() and checked:

total catalog cases: 308
generated ai-transcribe cases: 6 -> all 6 covered

Duplicated auth-header construction

Extracted into RequiredAuthHeaders(), now shared by ApiRequest and ApiMultipartRequest.

95 tests passing, up from 88.

@feruzm
feruzm merged commit 5fa2035 into main Jul 31, 2026
5 checks passed
@feruzm
feruzm deleted the feature/ai-transcribe-routes branch July 31, 2026 11:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant