diff --git a/helpers.py b/helpers.py index 3ec6a87..7a3e9bc 100644 --- a/helpers.py +++ b/helpers.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import re from typing import Any @@ -33,13 +34,39 @@ def summarize_error(body: Any, fallback: str) -> str: if isinstance(err, dict): msg = err.get("message") if isinstance(msg, str) and msg.strip(): - return msg.strip() + return _compact_error_text(msg) msg = body.get("message") if isinstance(msg, str) and msg.strip(): - return msg.strip() + return _compact_error_text(msg) - return fallback + return _compact_error_text(fallback) + + +def _compact_error_text(text: str, limit: int = 240) -> str: + clean = text.strip() + if not clean: + return "Unknown provider error" + + lower = clean.lower() + if "\s*([^<]+?)\s*", clean, flags=re.IGNORECASE | re.DOTALL) + title = re.sub(r"\s+", " ", title_match.group(1)).strip() if title_match else "HTML error page" + code_match = re.search(r"(?:error code\s*)?([45]\d{2})", clean, flags=re.IGNORECASE) + code = code_match.group(1) if code_match else None + phrase = "Bad gateway" if "bad gateway" in lower else title + if "cloudflare" in lower or "openrouter.ai" in lower: + if code: + return f"Cloudflare/OpenRouter returned HTTP {code} {phrase}" + return f"Cloudflare/OpenRouter returned {phrase}" + if code: + return f"Provider returned HTTP {code} HTML error page: {title}" + return f"Provider returned HTML error page: {title}" + + clean = re.sub(r"\s+", " ", clean) + if len(clean) > limit: + return clean[: limit - 1].rstrip() + "…" + return clean def build_transcript_block(display_name: str, transcript: str) -> str: diff --git a/openrouter_client.py b/openrouter_client.py index 88b0451..5ac631b 100644 --- a/openrouter_client.py +++ b/openrouter_client.py @@ -19,10 +19,15 @@ load_dotenv() logger = logging.getLogger(__name__) OPENROUTER_BASE = "https://openrouter.ai/api/v1" +OPENAI_BASE = "https://api.openai.com/v1" TRANSCRIPTION_MODELS = [ "openai/gpt-4o-mini-transcribe", "openai/whisper-large-v3", ] +DIRECT_OPENAI_TRANSCRIPTION_MODELS = [ + "gpt-4o-mini-transcribe", + "whisper-1", +] TRANSCRIPTION_CHUNK_SECONDS = 600 MAX_TRANSCRIPTION_FILE_BYTES = 25 * 1024 * 1024 SUMMARY_TRANSCRIPT_CHAR_LIMIT = 12000 @@ -32,6 +37,10 @@ def _api_key() -> str: return os.getenv("OPENROUTER_API_KEY", "") +def _openai_api_key() -> str: + return os.getenv("OPENAI_API_KEY", "") + + def _auth_headers() -> dict[str, str]: api_key = _api_key() if not api_key: @@ -177,7 +186,7 @@ async def _split_audio_for_transcription(audio_path: str) -> list[str]: return chunk_paths -async def _transcribe_chunk(client: httpx.AsyncClient, audio_path: str, headers: dict[str, str]) -> str: +async def _transcribe_chunk_with_openrouter(client: httpx.AsyncClient, audio_path: str, headers: dict[str, str]) -> str: failures: list[str] = [] for model in TRANSCRIPTION_MODELS: resp = await client.post( @@ -193,7 +202,7 @@ async def _transcribe_chunk(client: httpx.AsyncClient, audio_path: str, headers: suffix = f"; generation_id={generation_id}" if generation_id else "" failures.append(f"{model}: HTTP {resp.status_code}: {detail}{suffix}") logger.warning( - "Transcription model failed: model=%s chunk=%s status=%s detail=%s generation_id=%s", + "Transcription model failed: provider=openrouter model=%s chunk=%s status=%s detail=%s generation_id=%s", model, Path(audio_path).name, resp.status_code, @@ -206,7 +215,7 @@ async def _transcribe_chunk(client: httpx.AsyncClient, audio_path: str, headers: text = data.get("text", "") if text.strip(): logger.info( - "Transcription chunk completed: chunk=%s model=%s transcript_chars=%s", + "Transcription chunk completed: provider=openrouter chunk=%s model=%s transcript_chars=%s", Path(audio_path).name, model, len(text.strip()), @@ -215,7 +224,7 @@ async def _transcribe_chunk(client: httpx.AsyncClient, audio_path: str, headers: failures.append(f"{model}: returned empty text") logger.warning( - "Transcription model returned empty text: model=%s chunk=%s", + "Transcription model returned empty text: provider=openrouter model=%s chunk=%s", model, Path(audio_path).name, ) @@ -223,6 +232,83 @@ async def _transcribe_chunk(client: httpx.AsyncClient, audio_path: str, headers: raise RuntimeError("OpenRouter transcription failed across all models: " + " | ".join(failures)) +async def _transcribe_chunk_with_openai(client: httpx.AsyncClient, audio_path: str) -> str: + api_key = _openai_api_key() + if not api_key: + raise RuntimeError("OPENAI_API_KEY not set") + + failures: list[str] = [] + headers = {"Authorization": f"Bearer {api_key}"} + for model in DIRECT_OPENAI_TRANSCRIPTION_MODELS: + with open(audio_path, "rb") as audio_file: + files = {"file": (Path(audio_path).name, audio_file, "audio/wav")} + data = {"model": model} + resp = await client.post( + f"{OPENAI_BASE}/audio/transcriptions", + headers=headers, + data=data, + files=files, + ) + try: + resp.raise_for_status() + except httpx.HTTPStatusError: + detail = summarize_error(_safe_json(resp), fallback=resp.text) + request_id = resp.headers.get("x-request-id") + suffix = f"; request_id={request_id}" if request_id else "" + failures.append(f"{model}: HTTP {resp.status_code}: {detail}{suffix}") + logger.warning( + "Transcription model failed: provider=openai model=%s chunk=%s status=%s detail=%s request_id=%s", + model, + Path(audio_path).name, + resp.status_code, + detail, + request_id, + ) + continue + + data = resp.json() + text = data.get("text", "") + if text.strip(): + logger.info( + "Transcription chunk completed: provider=openai chunk=%s model=%s transcript_chars=%s", + Path(audio_path).name, + model, + len(text.strip()), + ) + return text.strip() + + failures.append(f"{model}: returned empty text") + logger.warning( + "Transcription model returned empty text: provider=openai model=%s chunk=%s", + model, + Path(audio_path).name, + ) + + raise RuntimeError("Direct OpenAI transcription failed across all models: " + " | ".join(failures)) + + +async def _transcribe_chunk(client: httpx.AsyncClient, audio_path: str, headers: dict[str, str]) -> str: + failures: list[str] = [] + try: + return await _transcribe_chunk_with_openrouter(client, audio_path, headers) + except RuntimeError as exc: + failures.append(str(exc)) + logger.warning( + "OpenRouter transcription provider failed for chunk=%s; checking direct OpenAI fallback", + Path(audio_path).name, + ) + + if _openai_api_key(): + try: + return await _transcribe_chunk_with_openai(client, audio_path) + except RuntimeError as exc: + failures.append(str(exc)) + else: + failures.append("direct OpenAI fallback skipped: OPENAI_API_KEY not set") + + raise RuntimeError("Transcription failed across providers: " + " | ".join(failures)) + + async def transcribe(audio_path: str) -> str: """Send audio to OpenRouter STT models and return transcript text.""" logger.info( diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 96720d8..f1775cc 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -33,6 +33,23 @@ def test_summarize_error_falls_back_to_string(): assert summarize_error({"unexpected": True}, fallback="fallback") == "fallback" +def test_summarize_error_compacts_cloudflare_html(): + html = """ + +