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
+150 -1
View File
@@ -2,17 +2,24 @@
from __future__ import annotations
import base64
from pathlib import Path
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from nanobot.audio.transcription import resolve_transcription_config
from nanobot.audio.transcription import (
EffectiveTranscriptionConfig,
resolve_transcription_config,
transcribe_audio_file,
)
from nanobot.config.schema import Config
from nanobot.providers.transcription import (
GroqTranscriptionProvider,
OpenAITranscriptionProvider,
OpenRouterTranscriptionProvider,
_audio_format,
_resolve_transcription_url,
)
@@ -71,6 +78,59 @@ def test_resolver_prefers_top_level_transcription_over_legacy_channels() -> None
assert resolved.api_base == "https://groq.example/openai/v1"
def test_resolver_supports_openrouter_transcription_provider() -> None:
config = Config()
config.transcription.provider = "openrouter"
config.transcription.model = "nvidia/parakeet-tdt-0.6b-v3"
config.transcription.language = "en"
config.providers.openrouter.api_key = "sk-or-test"
config.providers.openrouter.api_base = "https://openrouter.ai/api/v1"
resolved = resolve_transcription_config(config)
assert resolved.provider == "openrouter"
assert resolved.model == "nvidia/parakeet-tdt-0.6b-v3"
assert resolved.language == "en"
assert resolved.api_key == "sk-or-test"
assert resolved.api_base == "https://openrouter.ai/api/v1"
@pytest.mark.asyncio
async def test_transcribe_audio_file_routes_openrouter_provider(audio_file: Path) -> None:
captured: dict[str, object] = {}
class StubOpenRouter:
def __init__(self, **kwargs):
captured.update(kwargs)
async def transcribe(self, file_path: str | Path) -> str:
captured["file_path"] = Path(file_path)
return "openrouter ok"
config = EffectiveTranscriptionConfig(
enabled=True,
provider="openrouter",
model="nvidia/parakeet-tdt-0.6b-v3",
language="en",
api_key="sk-or-test",
api_base="https://openrouter.ai/api/v1",
max_duration_sec=120,
max_upload_mb=25,
)
with patch("nanobot.providers.transcription.OpenRouterTranscriptionProvider", StubOpenRouter):
result = await transcribe_audio_file(audio_file, config)
assert result == "openrouter ok"
assert captured == {
"api_key": "sk-or-test",
"api_base": "https://openrouter.ai/api/v1",
"language": "en",
"model": "nvidia/parakeet-tdt-0.6b-v3",
"file_path": audio_file,
}
def test_resolved_transcription_repr_hides_api_key() -> None:
config = Config()
config.providers.groq.api_key = "gsk-secret"
@@ -347,6 +407,95 @@ async def test_returns_empty_on_non_dict_json_body(audio_file: Path) -> None:
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Configurable model: forwarded to the multipart "model" field on all providers
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"provider_cls,default_model",
[(OpenAITranscriptionProvider, "whisper-1"), (GroqTranscriptionProvider, "whisper-large-v3")],
ids=["openai", "groq"],
)
def test_multipart_provider_model_defaults_and_override(provider_cls, default_model):
assert provider_cls(api_key="k").model == default_model
assert provider_cls(api_key="k", model="custom-stt").model == "custom-stt"
@pytest.mark.parametrize(
"provider_cls",
[OpenAITranscriptionProvider, GroqTranscriptionProvider],
ids=["openai", "groq"],
)
@pytest.mark.asyncio
async def test_multipart_provider_sends_configured_model(audio_file: Path, provider_cls) -> None:
provider = provider_cls(api_key="k", model="my-stt-model")
post = AsyncMock(return_value=_response(200, {"text": "ok"}))
with patch("httpx.AsyncClient.post", post), patch("asyncio.sleep", AsyncMock()):
assert await provider.transcribe(audio_file) == "ok"
assert post.await_args_list[0].kwargs["files"]["model"] == (None, "my-stt-model")
# ---------------------------------------------------------------------------
# OpenRouter provider — JSON body with base64 audio + configurable STT model
# ---------------------------------------------------------------------------
def test_audio_format_maps_known_extensions() -> None:
assert _audio_format(Path("v.oga")) == "ogg" # Telegram voice notes
assert _audio_format(Path("v.opus")) == "ogg"
assert _audio_format(Path("v.mp4")) == "m4a"
assert _audio_format(Path("v.mp3")) == "mp3"
assert _audio_format(Path("v.wav")) == "wav" # passthrough for unknown
def test_openrouter_defaults_and_chat_base_normalization() -> None:
default = OpenRouterTranscriptionProvider(api_key="k")
assert default.api_url == "https://openrouter.ai/api/v1/audio/transcriptions"
assert default.model == "openai/whisper-1"
# A chat-style base (what users copy from provider config) gets the path appended.
chat_base = OpenRouterTranscriptionProvider(api_key="k", api_base="https://openrouter.ai/api/v1")
assert chat_base.api_url == "https://openrouter.ai/api/v1/audio/transcriptions"
@pytest.mark.asyncio
async def test_openrouter_sends_json_base64_body(audio_file: Path) -> None:
"""OpenRouter gets a JSON body with base64 audio + format — never multipart."""
provider = OpenRouterTranscriptionProvider(
api_key="k", model="nvidia/parakeet-tdt-0.6b-v3", language="en"
)
post = AsyncMock(return_value=_response(200, {"text": "hi"}))
with patch("httpx.AsyncClient.post", post), patch("asyncio.sleep", AsyncMock()):
assert await provider.transcribe(audio_file) == "hi"
call = post.await_args_list[0].kwargs
assert "files" not in call # not multipart
body = call["json"]
assert body["model"] == "nvidia/parakeet-tdt-0.6b-v3"
assert body["language"] == "en"
assert body["input_audio"]["format"] == "ogg" # .ogg fixture
assert base64.b64decode(body["input_audio"]["data"]) == audio_file.read_bytes()
@pytest.mark.asyncio
async def test_openrouter_omits_language_when_unset(audio_file: Path) -> None:
provider = OpenRouterTranscriptionProvider(api_key="k", model="openai/whisper-1")
post = AsyncMock(return_value=_response(200, {"text": "ok"}))
with patch("httpx.AsyncClient.post", post), patch("asyncio.sleep", AsyncMock()):
assert await provider.transcribe(audio_file) == "ok"
assert "language" not in post.await_args_list[0].kwargs["json"]
@pytest.mark.asyncio
async def test_openrouter_shares_retry_contract(audio_file: Path) -> None:
"""OpenRouter goes through the same retry helper: 503 retried, then 200."""
provider = OpenRouterTranscriptionProvider(api_key="k", model="openai/whisper-1")
post = AsyncMock(side_effect=[_response(503), _response(200, {"text": "recovered"})])
with patch("httpx.AsyncClient.post", post), patch("asyncio.sleep", AsyncMock()):
assert await provider.transcribe(audio_file) == "recovered"
assert post.await_count == 2
@pytest.mark.parametrize("status", [408, 429, 500, 502, 503, 504])
@pytest.mark.asyncio
async def test_retries_on_every_advertised_transient_status(
+41
View File
@@ -265,6 +265,23 @@ def test_settings_payload_includes_effective_transcription_config(
assert payload["transcription"]["language"] == "en"
def test_settings_payload_exposes_openrouter_transcription_provider(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.providers.openrouter.api_key = "sk-or-test"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
providers = {provider["name"]: provider for provider in payload["transcription"]["providers"]}
assert providers["openrouter"]["label"] == "OpenRouter"
assert providers["openrouter"]["configured"] is True
def test_update_transcription_settings_writes_top_level_only(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
@@ -301,6 +318,30 @@ def test_update_transcription_settings_writes_top_level_only(
assert payload["transcription"]["provider_configured"] is True
def test_update_transcription_settings_accepts_openrouter(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.providers.openrouter.api_key = "sk-or-test"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = update_transcription_settings(
{
"provider": ["openrouter"],
"model": ["nvidia/parakeet-tdt-0.6b-v3"],
}
)
saved = load_config(config_path)
assert saved.transcription.provider == "openrouter"
assert saved.transcription.model == "nvidia/parakeet-tdt-0.6b-v3"
assert payload["transcription"]["provider"] == "openrouter"
assert payload["transcription"]["provider_configured"] is True
def test_update_transcription_settings_validates_language(
tmp_path,
monkeypatch: pytest.MonkeyPatch,