feat(transcription): configurable STT model + OpenRouter provider

Add a `transcriptionModel` channel setting and an OpenRouter transcription
backend so voice messages can be transcribed through OpenRouter's
speech-to-text endpoint (e.g. nvidia/parakeet-tdt-0.6b-v3, openai/whisper-1),
alongside the existing Groq/OpenAI Whisper providers.

- schema: add channels.transcriptionModel (None = provider default)
- providers/transcription: extract a shared POST/retry skeleton; add a
  JSON+base64 OpenRouterTranscriptionProvider; make the STT model a
  constructor param on all providers instead of hardcoding it
- channels: route transcriptionProvider="openrouter" and thread the model
  through the manager to each channel
- docs + tests

Only dedicated STT models work on OpenRouter's transcription endpoint;
chat LLMs (e.g. google/gemini-3.5-flash) are rejected there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Breitburg
2026-06-09 04:01:37 +08:00
committed by Xubin Ren
co-authored by Claude Opus 4.8
parent 28f3a20d64
commit 0eb3010e40
7 changed files with 319 additions and 18 deletions
+11 -1
View File
@@ -18,12 +18,13 @@ from loguru import logger
from nanobot.config.paths import get_media_dir
from nanobot.utils.media_decode import FileSizeExceeded, save_base64_data_url
TranscriptionProviderName = Literal["groq", "openai"]
TranscriptionProviderName = Literal["groq", "openai", "openrouter"]
_DEFAULT_PROVIDER: TranscriptionProviderName = "groq"
_DEFAULT_MODELS: dict[TranscriptionProviderName, str] = {
"groq": "whisper-large-v3",
"openai": "whisper-1",
"openrouter": "openai/whisper-1",
}
_MAX_AUDIO_BYTES_FALLBACK = 25 * 1024 * 1024
_AUDIO_MIME_ALLOWED: frozenset[str] = frozenset({
@@ -171,6 +172,15 @@ async def transcribe_audio_file(
language=config.language,
model=config.model,
)
elif config.provider == "openrouter":
from nanobot.providers.transcription import OpenRouterTranscriptionProvider
provider = OpenRouterTranscriptionProvider(
api_key=config.api_key,
api_base=config.api_base or None,
language=config.language,
model=config.model,
)
else:
from nanobot.providers.transcription import GroqTranscriptionProvider
+1 -1
View File
@@ -47,7 +47,7 @@ class TranscriptionConfig(Base):
"""Cross-channel audio transcription configuration."""
enabled: bool = True
provider: Literal["groq", "openai"] | None = None
provider: Literal["groq", "openai", "openrouter"] | None = None
model: str | None = None
language: str | None = Field(default=None, pattern=r"^[a-z]{2,3}$")
max_duration_sec: int = Field(default=120, ge=1, le=600)
+111 -10
View File
@@ -1,14 +1,17 @@
"""Provider-specific voice transcription adapters.
This module only knows how to call external transcription APIs such as Groq
and OpenAI Whisper. Product-level config fallback, WebUI upload validation,
and channel integration live in ``nanobot.audio.transcription``.
This module only knows how to call external transcription APIs such as Groq,
OpenAI Whisper, and OpenRouter. Product-level config fallback, WebUI upload
validation, and channel integration live in ``nanobot.audio.transcription``.
"""
import asyncio
import base64
import mimetypes
import os
from collections.abc import Callable
from pathlib import Path
from typing import Any
import httpx
from loguru import logger
@@ -23,6 +26,13 @@ _AUDIO_MIME_OVERRIDES = {
".weba": "audio/webm",
".webm": "audio/webm",
}
_FORMAT_ALIASES = {
"oga": "ogg",
"opus": "ogg",
"mpga": "mp3",
"mpeg": "mp3",
"mp4": "m4a",
}
def _resolve_transcription_url(api_base: str | None, default_url: str) -> str:
@@ -49,6 +59,12 @@ def _audio_mime_type(path: Path) -> str:
)
def _audio_format(path: Path) -> str:
"""Map an audio file's extension to an OpenRouter ``format`` value."""
ext = path.suffix.lstrip(".").lower()
return _FORMAT_ALIASES.get(ext, ext)
# Up to 3 retries (4 attempts total) with exponential backoff on transient
# failures. Whisper endpoints occasionally return 502/503 under load, and
# mobile-network transcription callers hit sporadic connect/read errors.
@@ -91,16 +107,61 @@ async def _post_transcription_with_retry(
return ""
headers = {"Authorization": f"Bearer {api_key}"}
def build_request() -> dict[str, Any]:
files = {
"file": (path.name, data, _audio_mime_type(path)),
"model": (None, model),
}
if language:
files["language"] = (None, language)
return {"url": url, "headers": headers, "files": files, "timeout": 60.0}
return await _post_with_retry(build_request, provider_label)
async def _post_json_transcription_with_retry(
url: str,
*,
api_key: str | None,
path: Path,
model: str,
provider_label: str,
language: str | None = None,
) -> str:
"""POST base64 JSON audio for providers that do not accept multipart uploads."""
try:
data = path.read_bytes()
except OSError as e:
logger.exception("{} transcription error: cannot read audio file: {}", provider_label, e)
return ""
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
def build_request() -> dict[str, Any]:
body: dict[str, object] = {
"model": model,
"input_audio": {
"data": base64.b64encode(data).decode(),
"format": _audio_format(path),
},
}
if language:
body["language"] = language
return {"url": url, "headers": headers, "json": body, "timeout": 60.0}
return await _post_with_retry(build_request, provider_label)
async def _post_with_retry(
build_request: Callable[[], dict[str, Any]],
provider_label: str,
) -> str:
async with httpx.AsyncClient() as client:
for attempt in range(_MAX_RETRIES + 1):
files = {
"file": (path.name, data, _audio_mime_type(path)),
"model": (None, model),
}
if language:
files["language"] = (None, language)
try:
response = await client.post(url, headers=headers, files=files, timeout=60.0)
response = await client.post(**build_request())
except _RETRYABLE_EXCEPTIONS as e:
if attempt < _MAX_RETRIES:
logger.warning(
@@ -167,6 +228,7 @@ async def _post_transcription_with_retry(
)
return ""
return payload.get("text", "")
return ""
class OpenAITranscriptionProvider:
@@ -256,3 +318,42 @@ class GroqTranscriptionProvider:
provider_label="Groq",
language=self.language,
)
class OpenRouterTranscriptionProvider:
"""Voice transcription provider using OpenRouter's speech-to-text endpoint."""
def __init__(
self,
api_key: str | None = None,
api_base: str | None = None,
language: str | None = None,
model: str | None = None,
):
self.api_key = api_key or os.environ.get("OPENROUTER_API_KEY")
self.api_url = _resolve_transcription_url(
api_base or os.environ.get("OPENROUTER_BASE_URL"),
"https://openrouter.ai/api/v1/audio/transcriptions",
)
self.language = language or None
self.model = model or "openai/whisper-1"
logger.debug("OpenRouter transcription endpoint: {}", self.api_url)
async def transcribe(self, file_path: str | Path) -> str:
if not self.api_key:
logger.warning("OpenRouter API key not configured for transcription")
return ""
path = Path(file_path)
if not path.exists():
logger.error("Audio file not found: {}", file_path)
return ""
return await _post_json_transcription_with_retry(
self.api_url,
api_key=self.api_key,
path=path,
model=self.model,
provider_label="OpenRouter",
language=self.language,
)
+1 -1
View File
@@ -91,7 +91,7 @@ _IMAGE_GENERATION_ASPECT_RATIOS = {
"2:3",
"21:9",
}
_TRANSCRIPTION_PROVIDERS = ("groq", "openai")
_TRANSCRIPTION_PROVIDERS = ("groq", "openai", "openrouter")
_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 262_144}
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")