Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions agent_reach/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions agent_reach/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
}

Expand Down
48 changes: 45 additions & 3 deletions agent_reach/transcribe.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from __future__ import annotations

import base64
import ipaddress
import shutil
import subprocess
Expand Down Expand Up @@ -40,6 +41,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",
},
}


Expand Down Expand Up @@ -229,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(
Expand All @@ -251,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(
Expand All @@ -263,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)
Expand Down
148 changes: 146 additions & 2 deletions tests/test_transcribe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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 -------------------------------- #

Expand Down Expand Up @@ -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 ----------------------------------------- #

Expand Down Expand Up @@ -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"