From 149e4653e49ca3416e51bde6f5d9a2694ea37a5b Mon Sep 17 00:00:00 2001 From: nankingjing <1079826437@qq.com> Date: Sun, 5 Jul 2026 06:31:20 +0000 Subject: [PATCH 1/3] feat(transcribe): add OpenRouter as an STT/transcription provider (#440) Add OpenRouter to the transcription provider list so users with an OpenRouter API key can route audio transcription through the OpenRouter gateway. Uses openrouter_api_key from config and openai/whisper-1 as the default model. Fixes #440 --- agent_reach/transcribe.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/agent_reach/transcribe.py b/agent_reach/transcribe.py index 0344a05e..26ea055b 100644 --- a/agent_reach/transcribe.py +++ b/agent_reach/transcribe.py @@ -40,6 +40,11 @@ "model": "whisper-1", "key_field": "openai_api_key", }, + "openrouter": { + "endpoint": "https://openrouter.ai/api/v1/audio/transcriptions", + "model": "openai/whisper-1", + "key_field": "openrouter_api_key", + }, } From daf21c326f641255e559dc125103ff9d836d127b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E4=BA=91=E9=BE=99?= <76432572+nankingjing@users.noreply.github.com> Date: Sat, 11 Jul 2026 04:41:30 +0000 Subject: [PATCH 2/3] fix(transcribe): use OpenRouter's JSON/base64 audio format for its STT path OpenRouter's /api/v1/audio/transcriptions is not OpenAI-compatible: it expects a JSON body with base64-encoded audio (input_audio.data) and returns JSON ({"text": ...}), not a multipart file upload / plain text. Branch transcribe_chunk by provider so OpenAI/Groq keep their multipart path and only OpenRouter uses JSON + base64 + JSON-response parsing. Audio format is derived from the chunk extension. --- agent_reach/transcribe.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/agent_reach/transcribe.py b/agent_reach/transcribe.py index 26ea055b..a41d2c17 100644 --- a/agent_reach/transcribe.py +++ b/agent_reach/transcribe.py @@ -13,6 +13,7 @@ from __future__ import annotations +import base64 import ipaddress import shutil import subprocess @@ -234,6 +235,39 @@ def transcribe_chunk( ) info = PROVIDERS[provider] + + # OpenRouter's /audio/transcriptions is NOT OpenAI-compatible: it expects a + # JSON body with base64-encoded audio (`input_audio.data`) and returns JSON + # (`{"text": ...}`), unlike Groq/OpenAI which take a multipart file upload + # and can return plain text. Branch here so each provider gets its format. + if provider == "openrouter": + audio_b64 = base64.b64encode(chunk.read_bytes()).decode("ascii") + fmt = chunk.suffix.lstrip(".").lower() or "m4a" + try: + resp = requests.post( + info["endpoint"], + headers={ + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + }, + json={ + "model": info["model"], + "input_audio": {"data": audio_b64, "format": fmt}, + }, + timeout=timeout, + ) + except requests.RequestException as e: + raise TranscribeError(f"{provider}: network error: {e}") from e + + if not resp.ok: + raise TranscribeError(f"{provider}: HTTP {resp.status_code}: {resp.text[:300]}") + try: + return resp.json()["text"] + except (ValueError, KeyError, TypeError) as e: + raise TranscribeError( + f"{provider}: unexpected response: {resp.text[:300]}" + ) from e + with chunk.open("rb") as fh: try: resp = requests.post( From 96719585e8c7a3310e012468247fd64e9eb1fcf4 Mon Sep 17 00:00:00 2001 From: nankingjing <76432572+nankingjing@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:30:10 +0000 Subject: [PATCH 3/3] feat(transcribe): expose OpenRouter STT in CLI and add contract tests - Add openrouter-key configure subcommand and --provider openrouter. - Register openrouter_whisper feature in Config.FEATURE_REQUIREMENTS. - Update transcribe() docstring/provider-order error message. - Add OpenRouter JSON+base64 contract tests and env-isolated fixture. - Co-Authored-By: Claude --- agent_reach/cli.py | 11 ++- agent_reach/config.py | 1 + agent_reach/transcribe.py | 9 ++- tests/test_transcribe.py | 148 +++++++++++++++++++++++++++++++++++++- 4 files changed, 162 insertions(+), 7 deletions(-) diff --git a/agent_reach/cli.py b/agent_reach/cli.py index 3cd45f61..aaec89fe 100644 --- a/agent_reach/cli.py +++ b/agent_reach/cli.py @@ -81,6 +81,7 @@ def main(): p_conf = sub.add_parser("configure", help="Set a config value or auto-extract from browser") p_conf.add_argument("key", nargs="?", default=None, choices=["proxy", "github-token", "groq-key", "openai-key", + "openrouter-key", "twitter-cookies", "youtube-cookies", "xhs-cookies"], help="What to configure (omit if using --from-browser)") @@ -115,9 +116,9 @@ def main(): # ── check-update ── # ── transcribe ── - p_tr = sub.add_parser("transcribe", help="Transcribe a URL or local audio file (Whisper via Groq/OpenAI)") + p_tr = sub.add_parser("transcribe", help="Transcribe a URL or local audio file (Whisper via Groq/OpenAI/OpenRouter)") p_tr.add_argument("source", help="Audio/video URL or local file path") - p_tr.add_argument("--provider", choices=["auto", "groq", "openai"], default="auto", + p_tr.add_argument("--provider", choices=["auto", "groq", "openai", "openrouter"], default="auto", help="Transcription provider (default: auto = groq → openai fallback)") p_tr.add_argument("-o", "--output", default=None, help="Write transcript to a file instead of stdout") @@ -1131,10 +1132,16 @@ def _cmd_configure(args): config.set("openai_api_key", value) print(f"✅ OpenAI key configured!") + elif args.key == "openrouter-key": + config.set("openrouter_api_key", value) + print(f"✅ OpenRouter key configured!") + def _cmd_transcribe(args): """Transcribe a URL or local audio file via Whisper (Groq → OpenAI fallback).""" from pathlib import Path + # Note: the transcribe() helper also accepts "openrouter" as a provider; + # it just isn't part of the auto-fallback chain so it must be requested explicitly. from agent_reach.transcribe import TranscribeError, transcribe diff --git a/agent_reach/config.py b/agent_reach/config.py index 59d91851..0712aaf4 100644 --- a/agent_reach/config.py +++ b/agent_reach/config.py @@ -26,6 +26,7 @@ class Config: "twitter_xreach": ["twitter_auth_token", "twitter_ct0"], # legacy key name; used by twitter-cli "groq_whisper": ["groq_api_key"], "openai_whisper": ["openai_api_key"], + "openrouter_whisper": ["openrouter_api_key"], "github_token": ["github_token"], } diff --git a/agent_reach/transcribe.py b/agent_reach/transcribe.py index a41d2c17..8bc8660f 100644 --- a/agent_reach/transcribe.py +++ b/agent_reach/transcribe.py @@ -290,7 +290,9 @@ def _provider_order(provider: str) -> List[str]: return ["groq", "openai"] if provider in PROVIDERS: return [provider] - raise TranscribeError(f"unknown provider: {provider} (use groq|openai|auto)") + raise TranscribeError( + f"unknown provider: {provider} (use groq|openai|openrouter|auto)" + ) def transcribe( @@ -302,8 +304,9 @@ def transcribe( ) -> str: """Transcribe a URL or local file path. Returns the joined transcript text. - `provider` is one of `auto` (groq → openai), `groq`, or `openai`. - `out_dir` defaults to a fresh temp directory; intermediate files stay there. + `provider` is one of `auto` (groq → openai), `groq`, `openai`, or + `openrouter`. `out_dir` defaults to a fresh temp directory; intermediate + files stay there. """ cfg = config or Config() order = _provider_order(provider) diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 748a8011..435de10c 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -4,7 +4,9 @@ from pathlib import Path from typing import List +import os import pytest +import yaml from agent_reach import transcribe as tr from agent_reach.config import Config @@ -14,10 +16,21 @@ @pytest.fixture def fake_config(tmp_path, monkeypatch): - """A Config that writes to a temp dir and never touches the user's HOME.""" + """A Config that writes to a temp dir and never touches the user's HOME or env.""" cfg_path = tmp_path / "config.yaml" + cfg_path.write_text(yaml.safe_dump({}), encoding="utf-8") monkeypatch.setattr(Config, "CONFIG_DIR", tmp_path) monkeypatch.setattr(Config, "CONFIG_FILE", cfg_path) + # Prevent ambient API keys from leaking into the fake config. + clean_env = { + k: v + for k, v in os.environ.items() + if not any( + marker in k.lower() + for marker in ("api_key", "auth_token", "ct0", "token", "secret") + ) + } + monkeypatch.setattr(os, "environ", clean_env) cfg = Config(config_path=cfg_path) return cfg @@ -30,14 +43,20 @@ def chunk_file(tmp_path): class FakeResponse: - def __init__(self, status_code: int, text: str = ""): + def __init__(self, status_code: int, text: str = "", payload=None): self.status_code = status_code self.text = text + self._payload = payload @property def ok(self) -> bool: return 200 <= self.status_code < 300 + def json(self): + if self._payload is not None: + return self._payload + raise ValueError("no json payload set") + # --- transcribe_chunk: provider routing -------------------------------- # @@ -93,6 +112,66 @@ def test_unknown_provider(self, fake_config, chunk_file): with pytest.raises(tr.TranscribeError, match="unknown provider"): tr.transcribe_chunk(chunk_file, "azure", config=fake_config) + def test_routes_to_openrouter_json_base64(self, monkeypatch, fake_config, chunk_file): + """OpenRouter must POST JSON+base64 audio, not multipart, and read .json()['text'].""" + import base64 + + fake_config.set("openrouter_api_key", "or-test") + captured = {} + + def fake_post(url, headers=None, files=None, data=None, timeout=None, json=None): + captured["url"] = url + captured["headers"] = headers + captured["json"] = json + captured["files_used"] = files is not None + return FakeResponse(200, "", payload={"text": "openrouter output"}) + + monkeypatch.setattr(tr.requests, "post", fake_post) + text = tr.transcribe_chunk(chunk_file, "openrouter", config=fake_config) + + assert text == "openrouter output" + assert captured["url"] == tr.PROVIDERS["openrouter"]["endpoint"] + assert captured["headers"]["Authorization"] == "Bearer or-test" + assert captured["headers"]["Content-Type"] == "application/json" + assert captured["files_used"] is False + assert captured["json"]["model"] == "openai/whisper-1" + assert captured["json"]["input_audio"]["data"] == base64.b64encode( + chunk_file.read_bytes() + ).decode("ascii") + assert captured["json"]["input_audio"]["format"] == "m4a" + + def test_openrouter_http_error_raises(self, monkeypatch, fake_config, chunk_file): + fake_config.set("openrouter_api_key", "or-test") + monkeypatch.setattr( + tr.requests, + "post", + lambda *a, **k: FakeResponse(400, "bad audio"), + ) + with pytest.raises(tr.TranscribeError, match="HTTP 400"): + tr.transcribe_chunk(chunk_file, "openrouter", config=fake_config) + + def test_openrouter_unexpected_json_raises(self, monkeypatch, fake_config, chunk_file): + fake_config.set("openrouter_api_key", "or-test") + monkeypatch.setattr( + tr.requests, + "post", + lambda *a, **k: FakeResponse(200, "", payload={"oops": "no text"}), + ) + with pytest.raises(tr.TranscribeError, match="unexpected response"): + tr.transcribe_chunk(chunk_file, "openrouter", config=fake_config) + + def test_openrouter_network_error_raises(self, monkeypatch, fake_config, chunk_file): + import requests as req + + fake_config.set("openrouter_api_key", "or-test") + + def boom(*a, **k): + raise req.ConnectionError("boom") + + monkeypatch.setattr(tr.requests, "post", boom) + with pytest.raises(tr.TranscribeError, match="network error"): + tr.transcribe_chunk(chunk_file, "openrouter", config=fake_config) + # --- _transcribe_with_fallback ----------------------------------------- # @@ -399,3 +478,68 @@ def test_openai_whisper_feature_registered(self, fake_config): assert not fake_config.is_configured("openai_whisper") fake_config.set("openai_api_key", "sk-test") assert fake_config.is_configured("openai_whisper") + + def test_openrouter_whisper_feature_registered(self, fake_config): + assert "openrouter_whisper" in Config.FEATURE_REQUIREMENTS + assert Config.FEATURE_REQUIREMENTS["openrouter_whisper"] == [ + "openrouter_api_key" + ] + assert not fake_config.is_configured("openrouter_whisper") + fake_config.set("openrouter_api_key", "or-test") + assert fake_config.is_configured("openrouter_whisper") + + +# --- CLI surface ------------------------------------------------------- # + + +class TestCliSurface: + def test_configure_subcommand_accepts_openrouter_key(self, monkeypatch, capsys): + import sys + + from agent_reach.cli import main as cli_main + + saved = sys.argv + exit_code = None + try: + sys.argv = [ + "agent-reach", + "configure", + "openrouter-key", + "or-key-value", + ] + cli_main() + except SystemExit as exc: + exit_code = exc.code + finally: + sys.argv = saved + + # argparse error would be code 2; success is 0/no exit. + assert exit_code != 2, "CLI rejected openrouter-key" + captured = capsys.readouterr() + assert "OpenRouter key configured" in captured.out + + def test_transcribe_subcommand_accepts_openrouter(self, monkeypatch, fake_config): + """CLI parser exposes the openrouter provider on `agent-reach transcribe`.""" + import sys + + from agent_reach.cli import main as cli_main + + saved = sys.argv + exit_code = None + try: + sys.argv = [ + "agent-reach", + "transcribe", + "https://example.com/audio", + "--provider", + "openrouter", + ] + cli_main() + except SystemExit as exc: + exit_code = exc.code + finally: + sys.argv = saved + + # argparse rejection is code 2; any other exit is fine (we did not pass + # a real audio file, so execution may fail later). + assert exit_code != 2, "CLI rejected --provider openrouter"