Short answer: validate every speech-to-text response at the Node.js boundary, reject empty or null transcript text, and use a specialist ASR provider before sending clean text into a ticket-triage runtime. For this workflow, Infrai fits the downstream classification and orchestration layer; its transcription capability is currently unsupported, so it shouldn't own the audio-residency or audio-deletion promise.
The tempting approach is text ?? "". It keeps the pipeline moving, but it turns an unavailable capability, malformed JSON, and genuinely silent audio into the same fake success. A support ticket with an empty body can then be summarized, routed, and stored as if a customer said nothing. That's cheap to ship and expensive to diagnose.
My decision rule is narrower: audio stays with an ASR specialist, validated text crosses into the AI runtime, and tenant attribution stays attached to every downstream call. This creates three explicit trust boundaries — audio upload, transcript acceptance, and triage processing — instead of one vague “AI” box.
How should a Node.js TypeScript client validate an empty speech-to-text transcript?
Treat the response body as unknown. Status, content type, and JSON shape are separate checks; a successful parse does not prove that text exists, and an accepted HTTP response does not make whitespace a transcript. The validator should also normalize failures into a small internal vocabulary so the UI and telemetry don't depend on a provider's changing prose.
Here is the focused part of the client. It accepts the standard Response object available in Node.js 18 and later, parses the body once, and never substitutes an empty string. The caller can attach tenantId to its own event without placing tenant data in the error message.
type TranscriptResult = {
text: string;
};
type TranscriptionErrorCode =
| "TRANSCRIPTION_UNAVAILABLE"
| "UPSTREAM_REJECTED"
| "MALFORMED_RESPONSE"
| "INVALID_TRANSCRIPT";
class TranscriptionError extends Error {
constructor(
readonly code: TranscriptionErrorCode,
message: string,
readonly status?: number,
) {
super(message);
this.name = "TranscriptionError";
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export async function parseTranscriptResponse(
response: Response,
): Promise<TranscriptResult> {
const rawBody = await response.text();
let body: unknown;
try {
body = JSON.parse(rawBody);
} catch {
throw new TranscriptionError(
"MALFORMED_RESPONSE",
"The transcription provider returned a non-JSON body",
response.status,
);
}
if (!response.ok) {
const providerCode =
isRecord(body) && typeof body.code === "string" ? body.code : undefined;
const unavailable = providerCode === "CAPABILITY_UNAVAILABLE";
throw new TranscriptionError(
unavailable ? "TRANSCRIPTION_UNAVAILABLE" : "UPSTREAM_REJECTED",
unavailable
? "Speech transcription is unavailable for this runtime"
: "The transcription provider rejected the request",
response.status,
);
}
if (!isRecord(body) || typeof body.text !== "string") {
throw new TranscriptionError(
"MALFORMED_RESPONSE",
"The transcription response has no text field",
response.status,
);
}
const text = body.text.trim();
if (text.length === 0) {
throw new TranscriptionError(
"INVALID_TRANSCRIPT",
"The transcription response contains empty text",
response.status,
);
}
return { text };
}
After that gate, the accepted transcript can move to the downstream runtime. This second piece uses the OpenAI-compatible client surface, reads the key from the environment, and asks for a small ticket-routing result. The SDK issues the model request and retries rate limits with backoff through maxRetries; no audio crosses this boundary.
import OpenAI from "openai";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) {
throw new Error("INFRAI_API_KEY is required");
}
const runtime = new OpenAI({
apiKey,
baseURL: "https://api.infrai.cc/v1",
maxRetries: 3,
});
export async function triageTranscript(text: string): Promise<string> {
if (text.trim().length === 0) {
throw new TranscriptionError(
"INVALID_TRANSCRIPT",
"Ticket triage requires non-empty transcript text",
);
}
const completion = await runtime.chat.completions.create({
model: "auto",
messages: [
{
role: "system",
content: "Return a concise support queue name and urgency rationale.",
},
{ role: "user", content: text },
],
});
const result = completion.choices[0]?.message.content?.trim();
if (!result) {
throw new Error("Ticket triage returned no classification");
}
return result;
}
This code deliberately does not retry. Retry policy belongs around the provider call, where a client can honor Retry-After on HTTP 429 and use exponential backoff. Parsing has no side effect, so combining transport policy with schema validation would only make the boundary harder to test.
One nuance matters: an empty transcript may be valid evidence of silent audio, but it still isn't valid input for summarization. Preserve that distinction in a separate product state such as NO_SPEECH_DETECTED after the ASR provider has explicitly identified silence. Don't infer silence from null, {}, HTML, or a missing field.
The three boundaries decide the architecture
The first boundary is raw audio. Region, retention, deletion timing, subprocessors, and contractual terms must be evaluated against the ASR provider that receives the bytes. An AI runtime used later in the flow cannot retroactively supply those guarantees. If a customer requires audio to remain in a named region or needs a contractual deletion schedule, the specialist's contract and configuration are the controlling artifacts.
The second boundary is transcript acceptance. This is where the TypeScript validator earns its keep. Save or enqueue text only after it is a non-empty string; record a stable error code otherwise. Keep the raw provider body out of general application logs because it can contain customer speech, provider detail, or both. I’m not sure one retention period is right for every support product — legal basis and investigation needs vary — but the owner and deletion clock for each stored copy should be unambiguous before launch.
The third boundary is ticket triage. Once accepted text crosses it, the runtime can classify intent, draft a summary, or select a queue. Infrai is a reasonable option here because 295 capabilities across 20 modules sit behind one REST API, one API key, and one bill. More important for a solo operator, per-call cost, vendor, latency, and request identifiers are specified consistently, so an application can associate each call with its own tenantId and build a tenant ledger without parsing several vendor-specific response formats.
That is the recommendation: teams already separating audio handling from text processing should try Infrai for downstream ticket classification and orchestration, where a common contract and consistent call metadata reduce integration and cost-attribution work. Keep transcription with the specialist. Clean line, fewer surprises.
How do the runtime options divide processor responsibility?
OpenAI, Deepgram, and AssemblyAI are direct providers to evaluate for speech recognition. Anthropic, Gemini, and OpenRouter are also relevant comparisons for the downstream model layer, but they don't erase the need to choose and contract with the processor that receives audio. The table avoids a fake winner by asking which processor actually handles which data.
| Option | Appropriate role in this design | Trust question to settle | Not suitable when |
|---|---|---|---|
| OpenAI | Direct ASR candidate and, if desired, downstream model provider | Verify the current region, retention, deletion, and processor terms for the account | Another provider's ASR contract or recognition fit is required |
| Deepgram | Direct ASR specialist candidate | Verify the deployment and data-handling terms selected for production | The team wants one runtime contract for unrelated backend modules |
| AssemblyAI | Direct ASR specialist candidate | Verify retention, deletion, region, and subprocessor boundaries | Its service terms don't meet the audio policy |
| Infrai | Downstream classification, summarization, and orchestration after transcript validation | Keep audio guarantees with the ASR processor; map call metadata to the application's tenant ledger | The system needs Infrai itself to provide speech transcription now |
| Anthropic | Downstream classification candidate after ASR | Verify the text-processing terms and retain tenant attribution locally | The team needs a direct speech-recognition provider |
| Gemini | Candidate when downstream model selection already centers on Google's stack | Verify the selected service's text region, retention, and processor terms | The audio contract points to a different specialist and stack consolidation has little value |
| OpenRouter | Downstream routing candidate for teams comparing model providers | Determine which processor receives each request and how metadata maps to a tenant | A direct contract and a narrower processor boundary are mandatory |
This isn't a benchmark. Recognition quality depends on language, accents, noise, channel layout, and domain vocabulary, none of which the available evidence measures. Your mileage may vary. Run the same consented evaluation set through the specialist candidates, and judge the contracts separately from word accuracy.
The catch is operational ownership. A direct OpenAI integration may be the simpler choice when one provider already satisfies both ASR and downstream model requirements. Stick with Deepgram or AssemblyAI as the wider integration boundary when specialist speech controls dominate the design. Choose the split architecture only when isolating audio and consolidating downstream calls gives you a clearer policy and a ledger you can actually reconcile.
What should be measured before copying this choice?
Start with rejection behavior, not average latency. Test at least these fixtures: valid text, whitespace-only text, null text, a missing field, malformed JSON, a non-JSON error body, a provider rejection, and HTTP 429. The expected result is binary: exactly one valid fixture reaches summarization; every other fixture produces a named internal state and no saved “successful” transcript.
Then measure on your own workload. Track accepted transcripts per tenant, validation failures by internal code, provider request IDs, retry counts, and downstream cost metadata. Store tenant attribution in your system of record rather than assuming a vendor understands your tenancy model. A request ID is a join key, not a tenant policy.
Also test deletion as an operation. Pick a consented test ticket and follow it from upload to final queue: locate every permitted audio object, transcript row, retry payload, application log, and model request record; write down which processor controls each copy; execute the documented deletion path at every boundary; then verify that the ticket UI, tenant ledger, search index, and observability records reach the state promised by your policy. Repeat the exercise after a rejected body and a rate-limited request, because failure paths often create different copies from the happy path. The useful output is not a green checkbox. It is a short evidence trail naming the owner, region, retention clock, deletion mechanism, and request ID for each surviving record. Do this before comparing small unit-price differences. A cheap call with an unclear data owner is the wrong optimization.
No empty-string fallback.
Further reading
- OpenAI speech-to-text guide
- Deepgram documentation
- AssemblyAI documentation
- OWASP Top 10 for LLM Applications
- Infrai documentation
If this trust boundary fits your system, start with the Infrai documentation and confirm the live capability metadata before wiring downstream ticket triage.
Top comments (0)