0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Hermes AgentでOpenRouterのSTTを使う。設定だけでは動かなかったのでpluginを書いた

0
Posted at

Discordに投げた音声をHermes Agentに文字起こしさせたかった。OpenAIのgpt-4o-transcribeで試すと、返ってきたのはHTTP 429のinsufficient_quota。ChatGPT PlusやCodexの契約と、OpenAI Platformの音声API課金は別枠だった。

そこでOpenRouterへ切り替えた。OpenRouterには専用のSTT APIがあるし、手元のOGGファイルを直接送れば日本語も認識できる。ところがHermesの設定をこう変えても動かない。

stt:
  enabled: true
  provider: openrouter
  openrouter:
    model: openai/gpt-4o-transcribe

エラーはこれだった。

No STT provider available.

設定値は保存できている。APIキーも正しい。OpenRouterへ直接投げた音声も文字になった。それでもDiscord経由では失敗する。調べてみると、問題はOpenRouterではなくHermes側のprovider登録だった。

今回作ったstandalone pluginは3ファイルだけだ。コードも省略せず載せるので、認証と設定を済ませれば同じ構成を再現できる。

先に結果

検証した環境はHermes Agent v0.19.0、macOS、Discord Gateway。モデルはopenai/gpt-4o-transcribeを使った。

plugin導入後のGatewayログはこうなった。

Plugin 'openrouter-stt' registered transcription provider: openrouter
Transcribing with plugin STT provider 'openrouter'...

Discordから送った音声:

こんにちは、内容を確認してください。今私の声を聞こえていますか?

Hermesへ渡った文字列:

"こんにちは、内容を確認してください。今私の声を聞こえていますか?"

保存済みのOGGでもtranscribe_audio()を通して成功した。単体・登録テストは8件。不正なモデルを指定した実HTTP試験では、キーを含まない形でエラーを返すことも確認した。

OpenRouterのSTTは非公式な裏技ではなかった

最初は「OpenRouterはLLMのchat APIが中心で、音声は実験的なのでは」と疑った。公式資料を当たると、これは外れだった。

OpenRouterは2026年5月1日のAudio APIs発表で、専用のPOST /api/v1/audio/transcriptionsを公開している。公式のSpeech-to-Textドキュメントには、JSONのbase64入力とOpenAI互換のmultipart入力が載っている。対応形式にはOGGも含まれる。

Speech-to-Textモデルのcollectionもある。2026年7月26日にページへ表示されていた利用量は、GPT-4o Mini Transcribeが212M tokens、GPT-4o Transcribeが71.2M、Voxtral Mini Transcribeが8.02M。上位3モデルだけで291.22M tokensになる。少なくともOpenRouter内では、ほとんど使われていない機能ではない。

ただし、利用者数や市場シェアは公開されていない。「世の中でどれくらい普及しているか」までは断定できない。このあたりは利用トークン数と市場全体の人気を混同しないほうがいい。

なぜstt.provider: openrouterだけでは動かないのか

Hermes v0.19.0の組み込みSTT providerは、localgroqopenaimistralxaielevenlabsdeepinfraなど。OpenRouterは入っていなかった。

少し紛らわしいのは、hermes config set stt.provider openrouter自体は成功することだ。Hermesは外部拡張のため未知のprovider名も設定へ保存する。でも、保存できることと実装が存在することは別だった。

実行時は組み込みprovider、config-drivenなcommand provider、登録済みPython pluginの順に探す。どこにもopenrouterがないので、最後にNo STT provider availableとなる。

Hermesの既存OpenRouter pluginも調べた。一つはLLM推論用、もう一つは画像生成用で、音声のTranscriptionProviderは登録しない。hermes model auxiliary sttという切り替え方も、v0.19.0には存在しなかった。

HermesのIssueとPRも確認した。OpenRouter STTの実装案は複数あったが、coreへは入らずclosedになっている。外部サービスとの統合は~/.hermes/plugins/へ置くstandalone pluginとして配る、というmaintainerの方針だった。公式のplugin開発ガイドにも、第三者サービスのpluginはcore treeではなくstandaloneで配布すると書かれている。

pluginを作る

配置はこうする。

~/.hermes/plugins/openrouter-stt/
├── plugin.yaml
├── __init__.py
└── provider.py

plugin.yaml

name: openrouter-stt
version: 0.1.0
description: Speech-to-text through OpenRouter's audio transcription API
author: Shin / Hermes Agent

__init__.py

"""Hermes plugin entry point for OpenRouter speech-to-text."""

from .provider import OpenRouterSTTProvider


def register(ctx) -> None:
    ctx.register_transcription_provider(OpenRouterSTTProvider())

ctx.register_transcription_provider()へprovider instanceを渡すのが接続点になる。

provider.py

少し長いが、ここは省略しない。コピペで動く版を載せる。

"""OpenRouter speech-to-text provider for Hermes Agent."""

from __future__ import annotations

import logging
import mimetypes
from pathlib import Path
from typing import Any, Callable, Dict, Optional

from agent.transcription_provider import TranscriptionProvider

logger = logging.getLogger(__name__)

DEFAULT_MODEL = "openai/gpt-4o-transcribe"
DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
DEFAULT_TIMEOUT_SECONDS = 120.0

CredentialResolver = Callable[[str], Dict[str, Any]]
PostCallable = Callable[..., Any]


def _default_credential_resolver(provider: str) -> Dict[str, Any]:
    from hermes_cli.runtime_provider import resolve_runtime_provider

    # This is the same resolver used by Hermes inference and image plugins. It
    # understands OpenRouter's credential pool even when the optional
    # OpenRouter model-provider plugin is disabled.
    return resolve_runtime_provider(requested=provider)


def _default_post(url: str, **kwargs: Any) -> Any:
    import httpx

    return httpx.post(url, **kwargs)


def _load_provider_config() -> Dict[str, Any]:
    try:
        from hermes_cli.config import load_config

        config = load_config()
        stt = config.get("stt") if isinstance(config, dict) else None
        section = stt.get("openrouter") if isinstance(stt, dict) else None
        return section if isinstance(section, dict) else {}
    except Exception as exc:  # best-effort configuration lookup
        logger.debug("Could not load OpenRouter STT config: %s", exc)
        return {}


def _error(message: str) -> Dict[str, Any]:
    return {
        "success": False,
        "transcript": "",
        "error": message,
        "provider": "openrouter",
    }


def _redact(value: str, secret: str) -> str:
    text = str(value or "")
    if secret:
        text = text.replace(secret, "[REDACTED]")
    return text


def _extract_error_message(response: Any) -> str:
    try:
        payload = response.json()
    except Exception:
        payload = None
    if isinstance(payload, dict):
        error = payload.get("error")
        if isinstance(error, dict):
            message = error.get("message") or error.get("code")
            if message:
                return str(message)
        elif error:
            return str(error)
        message = payload.get("message")
        if message:
            return str(message)
    text = str(getattr(response, "text", "") or "").strip()
    return text[:500] if text else "request failed"


class OpenRouterSTTProvider(TranscriptionProvider):
    """Transcribe audio through OpenRouter's OpenAI-compatible STT endpoint."""

    def __init__(
        self,
        *,
        credential_resolver: CredentialResolver = _default_credential_resolver,
        post: PostCallable = _default_post,
        timeout: Optional[float] = None,
    ) -> None:
        self._credential_resolver = credential_resolver
        self._post = post
        self._timeout_override = timeout

    @property
    def name(self) -> str:
        return "openrouter"

    @property
    def display_name(self) -> str:
        return "OpenRouter STT"

    def _credentials(self) -> Dict[str, Any]:
        try:
            resolved = self._credential_resolver("openrouter")
            return resolved if isinstance(resolved, dict) else {}
        except Exception as exc:
            logger.debug("OpenRouter credential resolution failed: %s", exc)
            return {}

    def is_available(self) -> bool:
        return bool(str(self._credentials().get("api_key") or "").strip())

    def list_models(self):
        return [
            {"id": DEFAULT_MODEL, "display": "OpenAI GPT-4o Transcribe"},
            {
                "id": "openai/gpt-4o-mini-transcribe",
                "display": "OpenAI GPT-4o Mini Transcribe",
            },
            {
                "id": "mistralai/voxtral-mini-transcribe",
                "display": "Mistral Voxtral Mini Transcribe",
            },
            {
                "id": "openai/whisper-large-v3-turbo",
                "display": "Whisper Large V3 Turbo",
            },
        ]

    def default_model(self) -> str:
        return DEFAULT_MODEL

    def get_setup_schema(self) -> Dict[str, Any]:
        return {
            "name": self.display_name,
            "badge": "paid",
            "tag": "Speech-to-text through OpenRouter",
            "env_vars": [
                {
                    "key": "OPENROUTER_API_KEY",
                    "prompt": "OpenRouter API key",
                    "url": "https://openrouter.ai/keys",
                }
            ],
        }

    def _timeout(self) -> float:
        if self._timeout_override is not None:
            return float(self._timeout_override)
        raw = _load_provider_config().get("timeout", DEFAULT_TIMEOUT_SECONDS)
        try:
            value = float(raw)
        except (TypeError, ValueError):
            return DEFAULT_TIMEOUT_SECONDS
        return value if value > 0 else DEFAULT_TIMEOUT_SECONDS

    def transcribe(
        self,
        file_path: str,
        *,
        model: Optional[str] = None,
        language: Optional[str] = None,
        **extra: Any,
    ) -> Dict[str, Any]:
        path = Path(file_path)
        if not path.is_file():
            return _error(f"Audio file not found: {file_path}")

        credentials = self._credentials()
        api_key = str(credentials.get("api_key") or "").strip()
        if not api_key:
            return _error(
                "OpenRouter credential is unavailable. Configure it with "
                "`hermes auth add openrouter --type api-key`."
            )

        base_url = str(credentials.get("base_url") or DEFAULT_BASE_URL).strip().rstrip("/")
        if not base_url:
            base_url = DEFAULT_BASE_URL
        url = f"{base_url}/audio/transcriptions"

        selected_model = str(model or self.default_model()).strip() or self.default_model()
        data: Dict[str, str] = {"model": selected_model}
        if language and str(language).strip():
            data["language"] = str(language).strip()

        try:
            audio_bytes = path.read_bytes()
        except OSError as exc:
            return _error(f"Could not read audio file: {_redact(str(exc), api_key)}")

        content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
        headers = {"Authorization": f"Bearer {api_key}"}

        try:
            response = self._post(
                url,
                headers=headers,
                data=data,
                files={"file": (path.name, audio_bytes, content_type)},
                timeout=self._timeout(),
            )
        except Exception as exc:  # network/client errors become Hermes envelopes
            message = _redact(str(exc), api_key)
            return _error(f"OpenRouter STT request failed: {message}")

        status = int(getattr(response, "status_code", 0) or 0)
        if status < 200 or status >= 300:
            detail = _redact(_extract_error_message(response), api_key)
            return _error(f"OpenRouter STT HTTP {status}: {detail}")

        try:
            payload = response.json()
        except Exception:
            return _error("OpenRouter STT returned invalid JSON")
        if not isinstance(payload, dict):
            return _error("OpenRouter STT returned an invalid response object")

        transcript = payload.get("text")
        if not isinstance(transcript, str) or not transcript.strip():
            return _error("OpenRouter STT returned an empty transcript")

        return {
            "success": True,
            "transcript": transcript.strip(),
            "provider": self.name,
        }

やっていることは単純で、Hermesから渡された音声ファイルをOpenRouterのmultipart APIへ送っている。ただ、実運用では「APIを呼べた」だけでは足りなかったので、次も入れた。

  • Hermesのcredential poolからキーを読む
  • ファイル不存在、HTTPエラー、壊れたJSON、空の文字起こしを区別する
  • 例外をHermes共通のresponse envelopeへ戻す
  • エラー文字列にAPIキーが混ざった場合は[REDACTED]へ置き換える
  • HTTP-RefererX-Titleのような、動作に不要な利用元情報は送らない

認証と設定

APIキーをplugin内へ書かない。Hermesの認証コマンドでcredential poolへ保存する。

hermes auth add openrouter --type api-key

ここで一つ注意がある。手元ではhermes auth status openrouterlogged outと表示されても、hermes auth list openrouterにはAPIキーcredentialが存在した。statusはOAuthログイン状態寄りの表示なので、APIキーの有無はlistと実通信で確認したほうが確実だった。

pluginを有効化する。

hermes plugins enable openrouter-stt

STTをOpenRouterへ向ける。

hermes config set stt.enabled true
hermes config set stt.provider openrouter
hermes config set stt.openrouter.model openai/gpt-4o-transcribe

設定確認:

hermes config get stt
hermes config check
hermes plugins list --enabled --plain

Gatewayを使っている場合は再起動する。

hermes gateway restart

Gateway自身の会話内からこのコマンドを実行すると、restart loop防止のため拒否される。別のTerminalから実行するか、対応するslash commandを使う。

再起動後のログに次が出ればprovider登録はできている。

Plugin 'openrouter-stt' registered transcription provider: openrouter

あとはDiscordやTelegramから短い音声を送る。処理時には次のログが出る。

Transcribing with plugin STT provider 'openrouter'...

credential resolverで一度詰まった

初版ではhermes_cli.auth.resolve_api_key_provider_credentials("openrouter")を呼んでいた。これだと、OpenRouterのmodel provider pluginが無効な環境で静的registryにopenrouterが見つからず、こうなる。

Provider 'openrouter' does not support API key credentials

HermesのLLM・画像providerが使っているruntime resolverへ合わせたら解決した。

from hermes_cli.runtime_provider import resolve_runtime_provider

credentials = resolve_runtime_provider(requested="openrouter")

このresolverなら、動的provider定義とHermesのcredential poolをまとめて扱える。今回のpluginで地味に一番時間がかかった箇所だった。

動作確認に使ったコマンド

手元に音声ファイルがあるなら、Discordへ送る前にHermes本体のdispatchを直接確認できる。

~/.hermes/hermes-agent/venv/bin/python - <<'PY'
from hermes_cli.plugins import PluginManager
from tools.transcription_tools import transcribe_audio

PluginManager().discover_and_load(force=True)
result = transcribe_audio("/path/to/voice.ogg")
print({
    "success": result.get("success"),
    "provider": result.get("provider"),
    "transcript": result.get("transcript"),
})
PY

成功時はこんな形になる。

{
    'success': True,
    'provider': 'openrouter',
    'transcript': '音声から認識されたテキスト'
}

最後にGateway経由の音声も試す。直接API、transcribe_audio()、実際のメッセージ受信は別の経路なので、どれか一つだけ成功して終わりにしないほうがいい。今回はこの3段階を全部通した。

使うモデルを変える

設定のmodel IDを変えれば、OpenRouter上の別のSTTモデルも選べる。

hermes config set stt.openrouter.model openai/gpt-4o-mini-transcribe

モデルIDは固定で覚えず、OpenRouterのSTT model collectionで現行のものを確認するのが安全だ。providerごとに対応パラメータが違う場合もあるので、まず短い音声で試す。

結局、どこが悪かったのか

今回の原因はAPIキーでもOGGでもなかった。Hermesにopenrouterという名前のtranscription providerが登録されていなかっただけだ。

設定が保存されたから対応済み、直接APIが成功したからGatewayでも成功する。どちらも今回は成り立たなかった。provider registryと実際のdispatchを見たら、ようやく話がつながった。

pluginはHermes本体を変更しないので、不要ならdisableしてディレクトリを消せる。coreへ無理に足すより、この形のほうが更新時の差分も追いやすいと思う。

参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?