Files
elizaos--eliza/plugins/plugin-openrouter/models/audio.ts
T
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

249 lines
7.9 KiB
TypeScript

/**
* The `TRANSCRIPTION` model handler. Normalizes the several accepted input
* shapes — URL string, `Buffer`, `Blob`/`File`, core `{ audioUrl }`, and the
* local `{ audio, language?, model?, ... }` object — into base64 plus a detected
* container format, then POSTs directly to OpenRouter's `/audio/transcriptions`
* endpoint (not through the AI SDK). Format is sniffed from mime type, URL
* extension, then magic bytes (`detectBufferFormat`). Emits a `MODEL_USED` event
* from the response usage. URL inputs are constrained to http/https.
*/
import type { TranscriptionParams as CoreTranscriptionParams, IAgentRuntime } from "@elizaos/core";
import { logger, ModelType } from "@elizaos/core";
import { getApiKey, getBaseURL, getTranscriptionModel } from "../utils/config";
import { emitModelUsageEvent } from "../utils/events";
type AudioInput = Blob | File | Buffer;
interface OpenRouterTranscriptionParams {
audio: AudioInput;
format?: string;
language?: string;
mimeType?: string;
model?: string;
temperature?: number;
}
type TranscriptionInput =
| AudioInput
| CoreTranscriptionParams
| OpenRouterTranscriptionParams
| string;
interface OpenRouterTranscriptionResponse {
text?: string;
usage?: {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
};
}
function isBlobOrFile(value: unknown): value is Blob | File {
if (typeof Blob !== "undefined" && value instanceof Blob) return true;
return typeof File !== "undefined" && value instanceof File;
}
function isBuffer(value: unknown): value is Buffer {
return typeof Buffer !== "undefined" && Buffer.isBuffer(value);
}
function isCoreTranscriptionParams(value: unknown): value is CoreTranscriptionParams {
return (
typeof value === "object" &&
value !== null &&
"audioUrl" in value &&
typeof (value as CoreTranscriptionParams).audioUrl === "string"
);
}
function isOpenRouterTranscriptionParams(value: unknown): value is OpenRouterTranscriptionParams {
return (
typeof value === "object" &&
value !== null &&
"audio" in value &&
(isBlobOrFile((value as OpenRouterTranscriptionParams).audio) ||
isBuffer((value as OpenRouterTranscriptionParams).audio))
);
}
function audioFormatFromMimeType(mimeType: string | undefined): string | null {
if (!mimeType?.startsWith("audio/")) return null;
const subtype = mimeType.slice("audio/".length).split(";")[0]?.trim();
if (!subtype) return null;
if (subtype === "mpeg") return "mp3";
if (subtype === "x-wav") return "wav";
return subtype;
}
function audioFormatFromUrl(url: string): string | null {
const pathname = new URL(url).pathname;
const match = pathname.match(/\.([a-z0-9]+)$/i);
return match?.[1]?.toLowerCase() ?? null;
}
function detectBufferFormat(buffer: Buffer): string {
if (buffer.length >= 12 && buffer.toString("ascii", 0, 4) === "RIFF") {
return "wav";
}
if (buffer.length >= 4 && buffer.toString("ascii", 0, 4) === "OggS") {
return "ogg";
}
if (buffer.length >= 4 && buffer.toString("ascii", 0, 4) === "fLaC") {
return "flac";
}
if (buffer.length >= 3 && buffer.toString("ascii", 0, 3) === "ID3") {
return "mp3";
}
if (buffer.length >= 2 && buffer[0] === 0xff && ((buffer[1] ?? 0) & 0xe0) === 0xe0) {
return "mp3";
}
return "webm";
}
function validateAudioUrl(url: string): string {
const parsed = new URL(url);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("TRANSCRIPTION audioUrl must use http or https");
}
return parsed.toString();
}
async function fetchAudioFromUrl(url: string): Promise<{
base64: string;
format: string;
}> {
const audioUrl = validateAudioUrl(url);
// @trajectory-allow Fetches caller-provided audio bytes; no model inference happens here.
const response = await fetch(audioUrl);
if (!response.ok) {
throw new Error(`Failed to fetch audio from URL: ${audioUrl}`);
}
const bytes = Buffer.from(await response.arrayBuffer());
return {
base64: bytes.toString("base64"),
format:
audioFormatFromMimeType(response.headers.get("content-type") ?? undefined) ??
audioFormatFromUrl(audioUrl) ??
detectBufferFormat(bytes),
};
}
async function encodeAudio(input: AudioInput): Promise<{
base64: string;
format: string;
}> {
if (isBuffer(input)) {
return {
base64: input.toString("base64"),
format: detectBufferFormat(input),
};
}
const bytes = Buffer.from(await input.arrayBuffer());
return {
base64: bytes.toString("base64"),
format:
audioFormatFromMimeType(input.type) ??
(typeof File !== "undefined" && input instanceof File
? audioFormatFromUrl(`https://local/${input.name}`)
: null) ??
detectBufferFormat(bytes),
};
}
async function normalizeTranscriptionInput(input: TranscriptionInput): Promise<{
audio: { data: string; format: string };
language?: string;
model?: string;
temperature?: number;
}> {
if (typeof input === "string") {
const audio = await fetchAudioFromUrl(input);
return { audio: { data: audio.base64, format: audio.format } };
}
if (isBlobOrFile(input) || isBuffer(input)) {
const audio = await encodeAudio(input);
return { audio: { data: audio.base64, format: audio.format } };
}
if (isCoreTranscriptionParams(input)) {
const audio = await fetchAudioFromUrl(input.audioUrl);
return { audio: { data: audio.base64, format: audio.format } };
}
if (isOpenRouterTranscriptionParams(input)) {
const audio = await encodeAudio(input.audio);
return {
audio: {
data: audio.base64,
format: input.format ?? audioFormatFromMimeType(input.mimeType) ?? audio.format,
},
...(input.language ? { language: input.language } : {}),
...(input.model ? { model: input.model } : {}),
...(input.temperature !== undefined ? { temperature: input.temperature } : {}),
};
}
throw new Error("TRANSCRIPTION expects Buffer, Blob, File, URL string, or TranscriptionParams");
}
export async function handleTranscription(
runtime: IAgentRuntime,
input: TranscriptionInput
): Promise<string> {
const apiKey = getApiKey(runtime);
if (!apiKey) {
throw new Error("OPENROUTER_API_KEY is not set");
}
const normalized = await normalizeTranscriptionInput(input);
const modelName = normalized.model ?? getTranscriptionModel(runtime);
const baseURL = getBaseURL(runtime);
logger.debug(`[OpenRouter] Using TRANSCRIPTION model: ${modelName}`);
// @trajectory-allow Audio bytes are caller-provided transcription input, not generated text.
const response = await fetch(`${baseURL}/audio/transcriptions`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: modelName,
input_audio: normalized.audio,
...(normalized.language ? { language: normalized.language } : {}),
...(normalized.temperature !== undefined ? { temperature: normalized.temperature } : {}),
}),
});
if (!response.ok) {
// error-policy:J6 best-effort diagnostics on an already-failed response —
// the typed failure below throws regardless of whether the body was readable.
const errorText = await response.text().catch(() => "Unknown error");
throw new Error(
`OpenRouter transcription failed: ${response.status} ${response.statusText} - ${errorText}`
);
}
const result = (await response.json()) as OpenRouterTranscriptionResponse;
if (typeof result.text !== "string") {
throw new Error("OpenRouter transcription response did not include text");
}
if (result.usage) {
emitModelUsageEvent(
runtime,
ModelType.TRANSCRIPTION,
"audio transcription",
{
inputTokens: result.usage.input_tokens ?? 0,
outputTokens: result.usage.output_tokens ?? 0,
totalTokens: result.usage.total_tokens ?? 0,
},
modelName
);
}
return result.text;
}