Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/13544-audio-transcription-call-log.md
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions src/app/api/v1/audio/speech/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
Expand Down
84 changes: 79 additions & 5 deletions src/app/api/v1/audio/transcriptions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -138,17 +165,52 @@ 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,
costUsd: 0,
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;
}
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -211,5 +285,5 @@ export async function POST(request) {
}
}

return transcribeWithModel(formData, modelStr, startTime);
return transcribeWithModel(formData, modelStr, startTime, apiKeyId, apiKeyName);
}
53 changes: 50 additions & 3 deletions src/app/api/v1/audio/translations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -177,5 +224,5 @@ export async function POST(request) {
}
}

return translateWithModel(formData, modelStr, startTime);
return translateWithModel(formData, modelStr, startTime, apiKeyId, apiKeyName);
}
Loading
Loading