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).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-09-16 06:11:13 -03:00
committed by GitHub
parent 79c4197f07
commit d7d518a873
5 changed files with 330 additions and 8 deletions

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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<Response> {
// 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);
}

View File

@@ -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<Response> {
// 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);
}

View File

@@ -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<typeof createProviderNode>[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<string, unknown>;
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<typeof createProviderNode>[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<string, unknown>).status, 500);
}
);