mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-18 04:42:30 +03:00
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:
committed by
GitHub
parent
79c4197f07
commit
d7d518a873
1
changelog.d/fixes/13544-audio-transcription-call-log.md
Normal file
1
changelog.d/fixes/13544-audio-transcription-call-log.md
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
165
tests/unit/issue-13544-audio-transcription-call-log.test.ts
Normal file
165
tests/unit/issue-13544-audio-transcription-call-log.test.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user