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:
committed by
Xubin Ren
co-authored by
Claude Opus 4.8
parent
28f3a20d64
commit
0eb3010e40
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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_]*)\}")
|
||||
|
||||
Reference in New Issue
Block a user