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は、local、groq、openai、mistral、xai、elevenlabs、deepinfraなど。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-RefererやX-Titleのような、動作に不要な利用元情報は送らない
認証と設定
APIキーをplugin内へ書かない。Hermesの認証コマンドでcredential poolへ保存する。
hermes auth add openrouter --type api-key
ここで一つ注意がある。手元ではhermes auth status openrouterがlogged 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へ無理に足すより、この形のほうが更新時の差分も追いやすいと思う。