From d7d518a873c4c58153445fe1c52fafbd7bb84136 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 16 Sep 2026 06:11:13 -0300 Subject: [PATCH] fix(api): record audio transcription/translation/speech requests in call_logs (#13544) (#13803) Merged in the 2026-09-16 sweep of the maintainer's own open PRs, at the owner's explicit instruction. No push was made to the PR branch: the merge took the head as the owning session left it (verified OPEN, non-draft and MERGEABLE against the release tip immediately before merging). --- .../13544-audio-transcription-call-log.md | 1 + src/app/api/v1/audio/speech/route.ts | 35 ++++ src/app/api/v1/audio/transcriptions/route.ts | 84 ++++++++- src/app/api/v1/audio/translations/route.ts | 53 +++++- ...13544-audio-transcription-call-log.test.ts | 165 ++++++++++++++++++ 5 files changed, 330 insertions(+), 8 deletions(-) create mode 100644 changelog.d/fixes/13544-audio-transcription-call-log.md create mode 100644 tests/unit/issue-13544-audio-transcription-call-log.test.ts diff --git a/changelog.d/fixes/13544-audio-transcription-call-log.md b/changelog.d/fixes/13544-audio-transcription-call-log.md new file mode 100644 index 0000000000..9930a28937 --- /dev/null +++ b/changelog.d/fixes/13544-audio-transcription-call-log.md @@ -0,0 +1 @@ +- **fix(api):** `/v1/audio/transcriptions`, `/v1/audio/translations` and `/v1/audio/speech` requests now show up in Dashboard → Request Logs — the three routes never called the shared call-log pipeline, so every successful (and failed) transcription/translation/speech request was silently dropped from `call_logs` ([#13544](https://github.com/diegosouzapw/OmniRoute/issues/13544)) — thanks @delafu diff --git a/src/app/api/v1/audio/speech/route.ts b/src/app/api/v1/audio/speech/route.ts index 686a5abd90..55887bb1ad 100644 --- a/src/app/api/v1/audio/speech/route.ts +++ b/src/app/api/v1/audio/speech/route.ts @@ -18,6 +18,7 @@ import { import { attachOmniRouteMetaToResponse } from "@/domain/omnirouteResponseMeta"; import { calculateModalCost } from "@/lib/usage/costCalculator"; import { generateRequestId } from "@/shared/utils/requestId"; +import { saveCallLog } from "@/lib/usageDb"; /** * Handle CORS preflight @@ -102,6 +103,12 @@ async function postHandler(request, context) { resolvedProvider: providerConfig, resolvedModel, }); + + const connectionId = (credentials as { connectionId?: string } | null)?.connectionId || undefined; + const logModel = `${provider}/${resolvedModel || body.model}`; + const apiKeyId = policy.apiKeyInfo?.id || undefined; + const apiKeyName = policy.apiKeyInfo?.name || undefined; + if (response?.ok) { await clearRecoveredProviderState(credentials); // TTS is billed per input character; attach cost telemetry without @@ -117,6 +124,34 @@ async function postHandler(request, context) { latencyMs: Date.now() - startTime, requestId: generateRequestId(), }); + saveCallLog({ + method: "POST", + path: "/v1/audio/speech", + status: 200, + model: logModel, + provider, + connectionId, + duration: Date.now() - startTime, + apiKeyId, + apiKeyName, + }).catch(() => {}); + } else if (response) { + const errorText = await response + .clone() + .text() + .catch(() => ""); + saveCallLog({ + method: "POST", + path: "/v1/audio/speech", + status: response.status, + model: logModel, + provider, + connectionId, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + apiKeyId, + apiKeyName, + }).catch(() => {}); } return response; } diff --git a/src/app/api/v1/audio/transcriptions/route.ts b/src/app/api/v1/audio/transcriptions/route.ts index 0a8ce034ae..a20c8c78eb 100644 --- a/src/app/api/v1/audio/transcriptions/route.ts +++ b/src/app/api/v1/audio/transcriptions/route.ts @@ -28,6 +28,31 @@ import { getComboByName, getCombos } from "@/lib/db/combos"; import { getDatabaseSettings } from "@/lib/db/databaseSettings"; import { handleComboChat } from "@omniroute/open-sse/services/combo.ts"; import { log } from "@omniroute/open-sse/utils/logger.ts"; +import { saveCallLog } from "@/lib/usageDb"; + +/** + * Best-effort peek at a successful transcription response for upstream duration + * usage (e.g. Scaleway's `usage: {type:"duration", seconds:N}`) so it is at least + * visible/auditable on the call_logs row even before a per-second cost rule + * consumes it (#13544). Never touches the original response body/stream — reads + * a clone, and any parse failure is swallowed so logging never blocks the reply. + */ +export async function peekDurationUsage( + response: Response +): Promise<{ type?: string; seconds?: number } | undefined> { + try { + const contentType = response.headers.get("content-type") || ""; + if (!contentType.includes("application/json")) return undefined; + const parsed = (await response.clone().json()) as { usage?: unknown } | null; + const usage = parsed && typeof parsed === "object" ? parsed.usage : null; + if (usage && typeof usage === "object" && (usage as { type?: unknown }).type === "duration") { + return usage as { type?: string; seconds?: number }; + } + } catch { + // Best-effort only — the transcription response itself already succeeded. + } + return undefined; +} /** * Copy a multipart body, swapping only the `model` field. Combo fan-out needs one @@ -63,7 +88,9 @@ export async function OPTIONS() { async function transcribeWithModel( formData: FormData, modelStr: string, - startTime: number + startTime: number, + apiKeyId?: string | null, + apiKeyName?: string | null ): Promise { // Provider nodes eligible for transcription: this route's own audio type plus // general chat/responses gateways. Remote hosts are opt-in (default OFF). @@ -138,10 +165,16 @@ async function transcribeWithModel( resolvedProvider: providerConfig, resolvedModel, }); + + const connectionId = (credentials as { connectionId?: string } | null)?.connectionId || undefined; + const logModel = `${provider}/${resolvedModel}`; + if (response?.ok) { await clearRecoveredProviderState(credentials); - // No text body / playback duration available from the multipart upload, so - // per-second pricing cannot be applied → cost 0 (ADD-only headers, body intact). + const durationUsage = await peekDurationUsage(response); + // No per-second pricing rule exists yet for transcription duration → cost 0 + // (ADD-only headers, body intact). The upstream usage is still persisted on + // the call_logs row below so it is auditable ahead of that pricing rule. response = attachOmniRouteMetaToResponse(response, { provider, model: resolvedModel, @@ -149,6 +182,35 @@ async function transcribeWithModel( latencyMs: Date.now() - startTime, requestId: generateRequestId(), }); + saveCallLog({ + method: "POST", + path: "/v1/audio/transcriptions", + status: 200, + model: logModel, + provider, + connectionId, + duration: Date.now() - startTime, + responseBody: durationUsage ? { usage: durationUsage } : undefined, + apiKeyId: apiKeyId || undefined, + apiKeyName: apiKeyName || undefined, + }).catch(() => {}); + } else if (response) { + const errorText = await response + .clone() + .text() + .catch(() => ""); + saveCallLog({ + method: "POST", + path: "/v1/audio/transcriptions", + status: response.status, + model: logModel, + provider, + connectionId, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + apiKeyId: apiKeyId || undefined, + apiKeyName: apiKeyName || undefined, + }).catch(() => {}); } return response; } @@ -177,6 +239,12 @@ export async function POST(request) { const policy = await enforceApiKeyPolicy(request, modelStr); if (policy.rejection) return policy.rejection; + // Forwarded into transcribeWithModel() (and combo fan-out below) so the + // resulting call_logs row is attributable to the API key that made the + // request, matching the pattern every other proxied route follows (#13544). + const apiKeyId = policy.apiKeyInfo?.id || null; + const apiKeyName = policy.apiKeyInfo?.name || null; + // A bare name (no "/") may be a combo. /v1/models advertises combos, and chat and // embeddings both resolve them — resolving here too keeps the catalog honest and // frees callers from hardcoding a provider's internal model id. @@ -197,7 +265,13 @@ export async function POST(request) { body: { model: modelStr } as any, combo: combo as any, handleSingleModel: async (_reqBody: any, targetModelStr: string) => - transcribeWithModel(withModel(formData, targetModelStr), targetModelStr, startTime), + transcribeWithModel( + withModel(formData, targetModelStr), + targetModelStr, + startTime, + apiKeyId, + apiKeyName + ), isModelAvailable: undefined, log, settings, @@ -211,5 +285,5 @@ export async function POST(request) { } } - return transcribeWithModel(formData, modelStr, startTime); + return transcribeWithModel(formData, modelStr, startTime, apiKeyId, apiKeyName); } diff --git a/src/app/api/v1/audio/translations/route.ts b/src/app/api/v1/audio/translations/route.ts index 65c45d0268..ae1d79a210 100644 --- a/src/app/api/v1/audio/translations/route.ts +++ b/src/app/api/v1/audio/translations/route.ts @@ -23,6 +23,7 @@ import { getComboByName, getCombos } from "@/lib/db/combos"; import { getDatabaseSettings } from "@/lib/db/databaseSettings"; import { handleComboChat } from "@omniroute/open-sse/services/combo.ts"; import { log } from "@omniroute/open-sse/utils/logger.ts"; +import { saveCallLog } from "@/lib/usageDb"; /** * Copy a multipart body, swapping only the `model` field. Combo fan-out needs one @@ -58,7 +59,9 @@ export async function OPTIONS() { async function translateWithModel( formData: FormData, modelStr: string, - startTime: number + startTime: number, + apiKeyId?: string | null, + apiKeyName?: string | null ): Promise { // Translation is served by the transcription-capable nodes (Whisper-style // endpoints expose both), plus general chat/responses gateways. Remote hosts are @@ -101,6 +104,10 @@ async function translateWithModel( resolvedProvider: providerConfig, resolvedModel, }); + + const connectionId = (credentials as { connectionId?: string } | null)?.connectionId || undefined; + const logModel = `${provider}/${resolvedModel}`; + if (response?.ok) { await clearRecoveredProviderState(credentials); // No text body / playback duration available from the multipart upload, so @@ -112,6 +119,34 @@ async function translateWithModel( latencyMs: Date.now() - startTime, requestId: generateRequestId(), }); + saveCallLog({ + method: "POST", + path: "/v1/audio/translations", + status: 200, + model: logModel, + provider, + connectionId, + duration: Date.now() - startTime, + apiKeyId: apiKeyId || undefined, + apiKeyName: apiKeyName || undefined, + }).catch(() => {}); + } else if (response) { + const errorText = await response + .clone() + .text() + .catch(() => ""); + saveCallLog({ + method: "POST", + path: "/v1/audio/translations", + status: response.status, + model: logModel, + provider, + connectionId, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + apiKeyId: apiKeyId || undefined, + apiKeyName: apiKeyName || undefined, + }).catch(() => {}); } return response; } @@ -142,6 +177,12 @@ export async function POST(request) { const policy = await enforceApiKeyPolicy(request, modelStr); if (policy.rejection) return policy.rejection; + // Forwarded into translateWithModel() (and combo fan-out below) so the + // resulting call_logs row is attributable to the API key that made the + // request, matching the pattern every other proxied route follows (#13544). + const apiKeyId = policy.apiKeyInfo?.id || null; + const apiKeyName = policy.apiKeyInfo?.name || null; + // A bare name (no "/") may be a combo. /v1/models advertises combos, and chat, // embeddings and the sibling /v1/audio/transcriptions all resolve them — // resolving here too keeps the catalog honest and frees callers from hardcoding @@ -163,7 +204,13 @@ export async function POST(request) { body: { model: modelStr } as any, combo: combo as any, handleSingleModel: async (_reqBody: any, targetModelStr: string) => - translateWithModel(withModel(formData, targetModelStr), targetModelStr, startTime), + translateWithModel( + withModel(formData, targetModelStr), + targetModelStr, + startTime, + apiKeyId, + apiKeyName + ), isModelAvailable: undefined, log, settings, @@ -177,5 +224,5 @@ export async function POST(request) { } } - return translateWithModel(formData, modelStr, startTime); + return translateWithModel(formData, modelStr, startTime, apiKeyId, apiKeyName); } diff --git a/tests/unit/issue-13544-audio-transcription-call-log.test.ts b/tests/unit/issue-13544-audio-transcription-call-log.test.ts new file mode 100644 index 0000000000..e5f9fba12f --- /dev/null +++ b/tests/unit/issue-13544-audio-transcription-call-log.test.ts @@ -0,0 +1,165 @@ +// #13544 — successful /v1/audio/transcriptions requests are not recorded in +// call_logs (or proxy_logs), so they never appear in Dashboard -> Request Logs. +// +// open-sse/handlers/audioTranscription.ts and src/app/api/v1/audio/transcriptions/route.ts +// never import/call saveCallLog() (@/lib/usageDb), unlike every other proxied API +// surface (embeddings, images, video, rerank, search, music...). This test drives +// a real POST through the actual route (provider node resolution, enforceApiKeyPolicy, +// upstream dispatch) against a loopback OpenAI-compatible transcription provider and +// asserts a call_logs row is created — mirroring what every other successful proxied +// request produces. It also asserts the persisted row carries the provider/model/ +// api_key_id identity fields, and that upstream `usage: {type:"duration", seconds}` +// is preserved on the row for future cost-pipeline consumption (Validation Plan +// steps 4 and 5). + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-audio-tx-13544-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const { createProviderNode } = await import("../../src/lib/db/providers.ts"); +const { getCallLogs, getCallLogById, waitForCallLogSaves } = + await import("../../src/lib/usage/callLogs.ts"); +const route = await import("../../src/app/api/v1/audio/transcriptions/route.ts"); + +const originalFetch = globalThis.fetch; +const CALL_LOG_SAVE_TIMEOUT_MS = 60_000; + +test.after(async () => { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +/** Minimal but structurally valid WAV so nothing rejects the upload shape. */ +function makeWav(): Blob { + const dataLen = 1600; + const b = Buffer.alloc(44 + dataLen); + b.write("RIFF", 0, "ascii"); + b.writeUInt32LE(36 + dataLen, 4); + b.write("WAVE", 8, "ascii"); + b.write("fmt ", 12, "ascii"); + b.writeUInt32LE(16, 16); + b.writeUInt16LE(1, 20); + b.writeUInt16LE(1, 22); + b.writeUInt32LE(16000, 24); + b.writeUInt32LE(32000, 28); + b.writeUInt16LE(2, 32); + b.writeUInt16LE(16, 34); + b.write("data", 36, "ascii"); + b.writeUInt32LE(dataLen, 40); + return new Blob([b], { type: "audio/wav" }); +} + +function transcriptionRequest(model: string) { + const fd = new FormData(); + fd.set("model", model); + fd.set("file", makeWav(), "test.mp3"); + fd.set("language", "es"); + return new Request("http://localhost/v1/audio/transcriptions", { method: "POST", body: fd }); +} + +test( + "#13544: a successful transcription through an OpenAI-compatible provider node creates a call_logs entry", + { timeout: 120_000 }, + async () => { + await createProviderNode({ + id: "scw-whisper-node-13544", + type: "openai-compatible", + name: "Scaleway Whisper", + prefix: "scwwhisper13544", + apiType: "audio-transcriptions", + baseUrl: "http://localhost:9544/v1", + } as Parameters[0]); + + const upstreamCalls: string[] = []; + globalThis.fetch = (async (url: RequestInfo | URL) => { + upstreamCalls.push(String(url)); + return new Response( + JSON.stringify({ + text: " Prueba de transcripción con ScaleY", + usage: { type: "duration", seconds: 3 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; + + const res = await route.POST(transcriptionRequest("scwwhisper13544/whisper-large-v3")); + const body = await res.text(); + + assert.equal(res.status, 200, `expected a successful transcription, got: ${body}`); + assert.ok( + upstreamCalls.some((u) => u.includes("/audio/transcriptions")), + `expected the upstream provider to be dispatched, calls: ${JSON.stringify(upstreamCalls)}` + ); + + await waitForCallLogSaves(CALL_LOG_SAVE_TIMEOUT_MS); + + const logs = await getCallLogs({ provider: "scwwhisper13544", limit: 20 }); + assert.ok( + logs.length > 0, + "expected a call_logs row for the successful /v1/audio/transcriptions request " + + "(none was created — the transcription path bypasses the normal call-log pipeline, #13544)" + ); + + const row = logs[0] as Record; + assert.equal(row.provider, "scwwhisper13544", "call_logs row must carry the provider"); + assert.equal( + row.model, + "scwwhisper13544/whisper-large-v3", + "call_logs row must carry the resolved provider/model" + ); + assert.equal(row.status, 200); + assert.ok("apiKeyId" in row, "call_logs row must carry the apiKeyId field"); + + const detail = await getCallLogById(row.id as string); + const usage = (detail?.responseBody as { usage?: { type?: string; seconds?: number } } | null) + ?.usage; + assert.equal( + usage?.type, + "duration", + `expected the upstream usage.type:"duration" to be preserved on the call_logs row, got responseBody=${JSON.stringify( + detail?.responseBody + )}` + ); + assert.equal(usage?.seconds, 3, "expected the upstream usage.seconds:3 to be preserved"); + } +); + +test( + "#13544: a failed upstream transcription request also creates a call_logs entry", + { timeout: 120_000 }, + async () => { + await createProviderNode({ + id: "scw-whisper-node-13544-fail", + type: "openai-compatible", + name: "Scaleway Whisper (failing)", + prefix: "scwwhisper13544fail", + apiType: "audio-transcriptions", + baseUrl: "http://localhost:9545/v1", + } as Parameters[0]); + + globalThis.fetch = (async () => + new Response(JSON.stringify({ error: { message: "boom" } }), { + status: 500, + headers: { "Content-Type": "application/json" }, + })) as typeof fetch; + + const res = await route.POST(transcriptionRequest("scwwhisper13544fail/whisper-large-v3")); + assert.equal(res.status, 500); + + await waitForCallLogSaves(CALL_LOG_SAVE_TIMEOUT_MS); + + const logs = await getCallLogs({ provider: "scwwhisper13544fail", limit: 20 }); + assert.ok( + logs.length > 0, + "expected a call_logs row for the failed /v1/audio/transcriptions request too (#13544)" + ); + assert.equal((logs[0] as Record).status, 500); + } +);