From 63a9413d3e2d6e3afef7651881fb582c76e5e5cc Mon Sep 17 00:00:00 2001 From: MyButtermilk <153296172+MyButtermilk@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:10:05 +0200 Subject: [PATCH] feat: add EU-default fail-closed OpenRouter region routing --- AGENTS.md | 13 + .../settings/OpenRouterRegionPicker.test.tsx | 43 +++ .../settings/OpenRouterRegionPicker.tsx | 42 +++ .../src/i18n/translations/de/settings.ts | 23 +- Frontend/client/src/lib/api-types.ts | 3 + .../src/lib/meeting-error-message.test.ts | 12 + .../client/src/lib/meeting-error-message.ts | 19 +- Frontend/client/src/pages/Settings.tsx | 24 +- README.md | 37 +++ src/cloud_async_stt.py | 14 +- src/config.py | 9 + src/core/provider_errors.py | 37 ++- src/meeting_finalizer.py | 4 + src/openrouter_region.py | 33 +++ src/pipeline.py | 13 +- src/summarization.py | 25 +- src/transcript_artifacts.py | 12 + src/web_api.py | 32 ++- tests/conftest.py | 1 + tests/core/test_cloud_async_stt.py | 6 +- tests/core/test_openrouter_stt_retry.py | 4 +- tests/test_config.py | 18 ++ tests/test_frontend_type_gates.py | 8 +- tests/test_meeting_finalizer.py | 24 ++ tests/test_microphone_device_resolution.py | 24 ++ tests/test_openrouter_region.py | 259 ++++++++++++++++++ tests/test_pipeline_stop.py | 6 + tests/test_summarization.py | 56 ++-- tests/test_summary_retry_api.py | 15 +- tests/test_web_api_provider_audio.py | 43 ++- 30 files changed, 786 insertions(+), 73 deletions(-) create mode 100644 Frontend/client/src/components/settings/OpenRouterRegionPicker.test.tsx create mode 100644 Frontend/client/src/components/settings/OpenRouterRegionPicker.tsx create mode 100644 src/openrouter_region.py create mode 100644 tests/test_openrouter_region.py diff --git a/AGENTS.md b/AGENTS.md index b120b7b6..25219d9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1474,6 +1474,19 @@ Packaging and scripts: to MP3 before upload. The shipped control remains post-stop FFmpeg MP3; capture-time FFmpeg MP3 is production-safe but default-off after its mixed canonical A/B result. Do not restore WAV upload without measured provider need. +- OpenRouter data residency is independent of Soniox. `SCRIBER_OPENROUTER_REGION` + accepts only `eu`, `us`, and `global`, with EU as the default for missing legacy + settings. `src/openrouter_region.py` owns the allowlisted origins for STT and + all text-generation paths, including Meeting analysis and cloud cleanup + fallbacks. Never follow HTTP redirects or silently switch to global after a + regional failure. Preserve `region_unavailable` HTTP 404 guidance through + Pipecat ErrorFrames, summary retries, Meeting recovery, and post-processing; + never retain raw provider messages. Freeze STT region plus endpoint hash and + pin the region across request retries. Region-less legacy global hashes fail + consistency checks under EU rather than silently changing their route. + OpenRouter keys/model IDs remain unchanged; regional routing needs Business + or Enterprise. Availability must be checked through the region's models API; + dedicated STT needs `output_modalities=transcription`, not the chat catalog. - `openrouter_stt` is the separate active OpenRouter route for Microsoft MAI-Transcribe-2. Keep its exact model fixed to `microsoft/mai-transcribe-2`, reuse the existing `OPENROUTER_API_KEY` used diff --git a/Frontend/client/src/components/settings/OpenRouterRegionPicker.test.tsx b/Frontend/client/src/components/settings/OpenRouterRegionPicker.test.tsx new file mode 100644 index 00000000..69aa7c71 --- /dev/null +++ b/Frontend/client/src/components/settings/OpenRouterRegionPicker.test.tsx @@ -0,0 +1,43 @@ +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { LANGUAGE_STORAGE_KEY, LocaleProvider } from "@/i18n"; +import { OpenRouterRegionPicker } from "./OpenRouterRegionPicker"; + +describe("OpenRouter region", () => { + beforeEach(() => window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "en")); + + it("shows EU and requires an explicit selection to use global", async () => { + const change = vi.fn(); + render( + + + , + ); + const select = screen.getByRole("combobox", { name: "OpenRouter data processing region" }); + expect(select).toHaveValue("eu"); + expect(select).toHaveAccessibleDescription(/Business or Enterprise/); + expect(select).toHaveAccessibleDescription(/never switches to Global automatically/); + expect(screen.getAllByRole("option").map((option) => (option as HTMLOptionElement).value)).toEqual([ + "eu", + "us", + "global", + ]); + expect(change).not.toHaveBeenCalled(); + await userEvent.selectOptions(select, "global"); + expect(change).toHaveBeenCalledExactlyOnceWith("global"); + }); + + it("shows the persisted US choice and German scope guidance", async () => { + render( + + + , + ); + act(() => window.dispatchEvent(new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY, newValue: "de" }))); + const select = await screen.findByRole("combobox", { name: "OpenRouter-Region für die Datenverarbeitung" }); + expect(select).toHaveValue("us"); + expect(select).toHaveAccessibleDescription(/andere Anbieter/); + expect(screen.getByRole("option", { name: "Europäische Union (Standard)" })).toBeInTheDocument(); + }); +}); diff --git a/Frontend/client/src/components/settings/OpenRouterRegionPicker.tsx b/Frontend/client/src/components/settings/OpenRouterRegionPicker.tsx new file mode 100644 index 00000000..c97cf5a9 --- /dev/null +++ b/Frontend/client/src/components/settings/OpenRouterRegionPicker.tsx @@ -0,0 +1,42 @@ +import { useId } from "react"; +import { useI18n } from "@/i18n"; +import type { OpenRouterRegion } from "@/lib/api-types"; + +export function OpenRouterRegionPicker({ + value, + onValueChange, +}: { + value: OpenRouterRegion; + onValueChange: (value: OpenRouterRegion) => void; +}) { + const { t } = useI18n(); + const id = useId(); + return ( +
+ + +

+ {t( + "Applies to OpenRouter transcription, summaries, meeting analysis, and cloud cleanup, including OpenRouter fallbacks. Other providers keep their own settings.", + )} +

+

+ {t( + "EU and US routing require OpenRouter Business or Enterprise. Your API key and model IDs stay the same. Models unavailable in the selected region fail with HTTP 404; Scriber never switches to Global automatically.", + )} +

+
+ ); +} diff --git a/Frontend/client/src/i18n/translations/de/settings.ts b/Frontend/client/src/i18n/translations/de/settings.ts index 485adf37..75402e35 100644 --- a/Frontend/client/src/i18n/translations/de/settings.ts +++ b/Frontend/client/src/i18n/translations/de/settings.ts @@ -84,8 +84,7 @@ export const settingsTranslations = { "Schätzung für eine Stunde mit getrennten Mikrofon- und Systemaudiospuren. Tatsächliche Kosten können je nach Sprachanteil, Textausgabe, Tarif, Steuern, Wiederholungen und Anbieteränderungen abweichen. Der Einführungspreis für Azure MAI-Transcribe-2 gilt bis zum 31.12.2026.", "Microsoft MAI · OpenRouter": "Microsoft MAI · OpenRouter", "Microsoft MAI · Azure direct": "Microsoft MAI · Azure direkt", - "One key for STT and AI · about 5% credit purchase fee": - "Ein Schlüssel für STT und KI · rund 5 % Gebühr beim Guthabenkauf", + "One key for STT and AI · EU by default": "Ein Schlüssel für STT und KI · EU als Standard", "Azure direct · $0.10/audio hour through December 31, 2026 · no OpenRouter fee": "Azure direkt · 0,10 $/Audiostunde bis 31.12.2026 · keine OpenRouter-Gebühr", "Gemini 3.5 Transcribe": "Gemini 3.5 Transcribe", @@ -132,6 +131,24 @@ export const settingsTranslations = { "Cerebras API key": "Cerebras-API-Schlüssel", "Celeris API key": "Celeris-API-Schlüssel", "OpenRouter API key": "OpenRouter-API-Schlüssel", + "OpenRouter data processing region": "OpenRouter-Region für die Datenverarbeitung", + "European Union (default)": "Europäische Union (Standard)", + "United States": "USA", + "Global (no region restriction)": "Global (ohne Regionsbeschränkung)", + "Applies to OpenRouter transcription, summaries, meeting analysis, and cloud cleanup, including OpenRouter fallbacks. Other providers keep their own settings.": + "Gilt für OpenRouter-Transkription, Zusammenfassungen, Meeting-Analyse und Cloud-Nachbearbeitung einschließlich OpenRouter-Fallbacks. Für andere Anbieter gelten deren eigene Einstellungen.", + "EU and US routing require OpenRouter Business or Enterprise. Your API key and model IDs stay the same. Models unavailable in the selected region fail with HTTP 404; Scriber never switches to Global automatically.": + "EU- und US-Routing erfordern OpenRouter Business oder Enterprise. API-Schlüssel und Modell-IDs bleiben gleich. In der gewählten Region nicht verfügbare Modelle führen zu HTTP 404; Scriber wechselt niemals automatisch zu Global.", + "One OpenRouter API key covers Microsoft MAI STT, summaries, meeting analysis, and cloud cleanup. Europe is the default region.": + "Ein OpenRouter-API-Schlüssel gilt für Microsoft-MAI-Transkription, Zusammenfassungen, Meeting-Analyse und Cloud-Nachbearbeitung. Europa ist die Standardregion.", + "The selected OpenRouter model is unavailable in your region. Scriber did not switch to Global.": + "Das gewählte OpenRouter-Modell ist in deiner Region nicht verfügbar. Scriber hat nicht zu Global gewechselt.", + "Choose a model available in your OpenRouter region, or explicitly change that region in Settings before retrying.": + "Wähle ein in deiner OpenRouter-Region verfügbares Modell oder ändere diese Region ausdrücklich in den Einstellungen, bevor du es erneut versuchst.", + "OpenRouter has no available endpoint for the selected model in the EU region (HTTP 404). Choose a model available in that region or explicitly change the OpenRouter region in Settings. Scriber did not switch to the global endpoint.": + "OpenRouter hat für das gewählte Modell keinen verfügbaren Endpunkt in der EU (HTTP 404). Wähle ein in dieser Region verfügbares Modell oder ändere die OpenRouter-Region ausdrücklich in den Einstellungen. Scriber hat nicht zum globalen Endpunkt gewechselt.", + "OpenRouter has no available endpoint for the selected model in the US region (HTTP 404). Choose a model available in that region or explicitly change the OpenRouter region in Settings. Scriber did not switch to the global endpoint.": + "OpenRouter hat für das gewählte Modell keinen verfügbaren Endpunkt in den USA (HTTP 404). Wähle ein in dieser Region verfügbares Modell oder ändere die OpenRouter-Region ausdrücklich in den Einstellungen. Scriber hat nicht zum globalen Endpunkt gewechselt.", "Data processing region": "Region der Datenverarbeitung", "This selection applies to Soniox realtime and uploaded-audio transcription.": "Diese Auswahl gilt für Soniox-Echtzeittranskription und die Transkription hochgeladener Audiodateien.", @@ -754,8 +771,6 @@ export const settingsTranslations = { "Used for OpenAI STT and summarization.": "Wird für OpenAI-STT und Zusammenfassungen verwendet.", "One key unlocks Gemini STT, summaries, and cleanup.": "Ein Schlüssel aktiviert Gemini-STT, Zusammenfassungen und Bereinigung.", - "One OpenRouter API key covers Microsoft MAI STT, summaries, meeting analysis, and cloud cleanup. OpenRouter does not mark up model prices; buying credits currently adds about 5%: 5.5% (minimum $0.80) for standard payments or 5% for crypto.": - "Ein OpenRouter-API-Schlüssel deckt Microsoft-MAI-STT, Zusammenfassungen, Meeting-Analysen und Cloud-Bereinigung ab. OpenRouter erhebt keinen Aufschlag auf Modellpreise; beim Guthabenkauf fallen derzeit rund 5 % an: 5,5 % (mindestens 0,80 $) bei normalen Zahlungen oder 5 % bei Krypto.", "Used for direct Cerebras summary and cleanup models.": "Wird für direkte Cerebras-Modelle zur Zusammenfassung und Bereinigung verwendet.", "Short structured summaries with an 8K context window": diff --git a/Frontend/client/src/lib/api-types.ts b/Frontend/client/src/lib/api-types.ts index 03c5e09f..a21f617e 100644 --- a/Frontend/client/src/lib/api-types.ts +++ b/Frontend/client/src/lib/api-types.ts @@ -1090,6 +1090,7 @@ export interface SettingsApiKeys { } export type OverlayVisualizerStyle = "bars" | "energy_wave" | "blue_flame"; +export type OpenRouterRegion = "eu" | "us" | "global"; export interface SettingsResponse { hotkey?: string; @@ -1098,6 +1099,7 @@ export interface SettingsResponse { defaultSttService?: string; sonioxMode?: "realtime" | "async" | string; sonioxRegion?: "us" | "eu" | string; + openrouterRegion?: OpenRouterRegion; sonioxRealtimeModel?: string; sonioxAsyncModel?: string; transcriptionProviderModels?: Record; @@ -1153,6 +1155,7 @@ export interface SettingsUpdatePayload { defaultSttService?: string; sonioxMode?: "realtime" | "async"; sonioxRegion?: "us" | "eu"; + openrouterRegion?: OpenRouterRegion; sonioxAsyncModel?: string; language?: string; micDevice?: string; diff --git a/Frontend/client/src/lib/meeting-error-message.test.ts b/Frontend/client/src/lib/meeting-error-message.test.ts index 6ad5ef2a..c2a5c7f5 100644 --- a/Frontend/client/src/lib/meeting-error-message.test.ts +++ b/Frontend/client/src/lib/meeting-error-message.test.ts @@ -50,3 +50,15 @@ test("hides legacy provider errors behind generic meeting-analysis guidance", () assert.equal(presentation.reason, "The AI service could not finish the meeting brief."); assert.equal(JSON.stringify(presentation).includes("gemini-3.1-pro-preview"), false); }); + +test("regional model errors retain region guidance without exposing provider text", () => { + const presentation = meetingAnalysisFailurePresentation( + "meeting_analysis_region_unavailable", + "PRIVATE-ECHO", + translate, + ); + assert.match(presentation.reason, /unavailable in your region/); + assert.match(presentation.reason, /did not switch to Global/); + assert.match(presentation.settingsGuidance, /explicitly change that region/); + assert.equal(JSON.stringify(presentation).includes("PRIVATE-ECHO"), false); +}); diff --git a/Frontend/client/src/lib/meeting-error-message.ts b/Frontend/client/src/lib/meeting-error-message.ts index 5fbea1f4..a511948f 100644 --- a/Frontend/client/src/lib/meeting-error-message.ts +++ b/Frontend/client/src/lib/meeting-error-message.ts @@ -49,18 +49,25 @@ export function meetingAnalysisFailurePresentation( normalizedCode === "meeting_analysis_incomplete_response" || /max_tokens|finish_reason|max output|partial summary was discarded/.test(normalizedMessage); const timedOut = normalizedCode === "meeting_analysis_timeout" || /timed out|time limit/.test(normalizedMessage); + const regionUnavailable = normalizedCode === "meeting_analysis_region_unavailable"; return { title: t("The meeting brief could not be completed."), - reason: incomplete - ? t("The AI service did not return a complete response for every part of this meeting.") - : timedOut - ? t("The AI service took too long to finish the meeting brief.") - : t("The AI service could not finish the meeting brief."), + reason: regionUnavailable + ? t("The selected OpenRouter model is unavailable in your region. Scriber did not switch to Global.") + : incomplete + ? t("The AI service did not return a complete response for every part of this meeting.") + : timedOut + ? t("The AI service took too long to finish the meeting brief.") + : t("The AI service could not finish the meeting brief."), safety: t("Your transcript, recording, speaker names, and notes are saved."), retryGuidance: t( "Select “Try meeting brief again” below. Scriber reuses completed parts and only creates the meeting brief again.", ), - settingsGuidance: t("If it fails again, choose another summary model in Meeting settings and try again."), + settingsGuidance: regionUnavailable + ? t( + "Choose a model available in your OpenRouter region, or explicitly change that region in Settings before retrying.", + ) + : t("If it fails again, choose another summary model in Meeting settings and try again."), }; } diff --git a/Frontend/client/src/pages/Settings.tsx b/Frontend/client/src/pages/Settings.tsx index fd474a8b..f5f2117d 100644 --- a/Frontend/client/src/pages/Settings.tsx +++ b/Frontend/client/src/pages/Settings.tsx @@ -29,6 +29,7 @@ import { type LucideIcon, } from "lucide-react"; import { Switch } from "@/components/ui/switch"; +import { OpenRouterRegionPicker } from "@/components/settings/OpenRouterRegionPicker"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Button } from "@/components/ui/button"; import { WavePhysicsLoader } from "@/components/ui/wave-physics-loader"; @@ -86,6 +87,7 @@ import type { OnnxModelInfo, OnnxModelsResponse, OverlayVisualizerStyle, + OpenRouterRegion, OutlookCalendarStatus, OutlookCalendarSyncResponse, PostProcessingEngine, @@ -942,7 +944,7 @@ function createProviderModelOptions( 2.0, "cloud_async", "openrouter", - t("One key for STT and AI · about 5% credit purchase fee"), + t("One key for STT and AI · EU by default"), ), benchmarkOption( "azure_mai", @@ -1853,6 +1855,7 @@ export default function Settings() { const [youtubeKey, setYoutubeKey] = useState(""); const [sonioxKey, setSonioxKey] = useState(""); const [sonioxRegion, setSonioxRegion] = useState<"us" | "eu">("us"); + const [openrouterRegion, setOpenrouterRegion] = useState("eu"); const [modulateKey, setModulateKey] = useState(""); const [mistralKey, setMistralKey] = useState(""); const [smallestKey, setSmallestKey] = useState(""); @@ -2844,6 +2847,7 @@ export default function Settings() { setSonioxRealtimeModel(settings.sonioxRealtimeModel || "stt-rt-v5"); setTranscriptionProviderModels(settings.transcriptionProviderModels || {}); setSonioxRegion(settings.sonioxRegion === "eu" ? "eu" : "us"); + setOpenrouterRegion(settings.openrouterRegion ?? "eu"); setMeetingTranscriptionMode(settings.meetingTranscriptionMode === "final_only" ? "final_only" : "live_final"); setMeetingFinalProvider(settings.meetingFinalProvider || "soniox_async"); setMeetingAnalysisModel( @@ -3128,6 +3132,7 @@ export default function Settings() { await updateSettings({ apiKeys, ...(provider === "Soniox" ? { sonioxRegion } : {}), + ...(provider === "OpenRouter" ? { openrouterRegion } : {}), }); const credentialReady = (() => { @@ -6020,9 +6025,7 @@ export default function Settings() { handleSaveApiKey("OpenRouter")} note={t( - "One OpenRouter API key covers Microsoft MAI STT, summaries, meeting analysis, and cloud cleanup. OpenRouter does not mark up model prices; buying credits currently adds about 5%: 5.5% (minimum $0.80) for standard payments or 5% for crypto.", + "One OpenRouter API key covers Microsoft MAI STT, summaries, meeting analysis, and cloud cleanup. Europe is the default region.", )} {...credentialDialogProps("OpenRouter")} - /> + > + { + setOpenrouterRegion(region); + setSavedKeys((prev) => ({ ...prev, OpenRouter: false })); + setCredentialReadyKeys((prev) => ({ ...prev, OpenRouter: false })); + }} + /> + dict[str, Any]: """Transcribe verified audio through OpenRouter's dedicated STT endpoint.""" @@ -846,6 +847,8 @@ async def transcribe_with_openrouter_audio_transcription( raise ValueError("OpenRouter STT has no verified exact model contract.") audio_format = openrouter_audio_format(filename, content_type) language_code = provider_language_code(language) + selected_region = normalize_openrouter_region(Config.OPENROUTER_REGION if region is None else region) + url = openrouter_stt_url(selected_region) source_position = None if not isinstance(audio_source, bytes): @@ -889,7 +892,8 @@ async def transcribe_with_openrouter_audio_transcription( _report_progress(on_progress, "Uploading audio...") _report_progress(on_progress, "Processing transcription...") async with session.post( - OPENROUTER_STT_URL, + url, + allow_redirects=False, data=body, headers={ "Authorization": f"Bearer {api_key}", @@ -900,7 +904,7 @@ async def transcribe_with_openrouter_audio_transcription( timeout=aiohttp.ClientTimeout(total=max(0.001, deadline - loop.time())), ) as response: raw = await read_response_text_limited(response, 64 * 1024 * 1024) - if response.status < 400: + if response.status < 300: if not raw: return {} parsed = parse_provider_json_response("openrouter_stt", "transcription_response", raw) @@ -911,6 +915,7 @@ async def transcribe_with_openrouter_audio_transcription( status=response.status, response_body=raw, request_bytes=request_bytes, + region=selected_region, ) if ( response.status != 429 @@ -1443,11 +1448,13 @@ def __init__( language: Language | str | None, session: aiohttp.ClientSession | None = None, on_progress: Callable[[str], None] | None = None, + region: str | None = None, ) -> None: super().__init__(session=session, on_progress=on_progress, diarize=False) self._api_key = api_key self._model = model self._language = language + self._region = normalize_openrouter_region(Config.OPENROUTER_REGION if region is None else region) async def _transcribe_wav(self, wav_source: BinaryIO) -> str: async with prepare_provider_wav_stream(wav_source, provider="openrouter_stt", model=self._model) as ( @@ -1464,6 +1471,7 @@ async def _call(session: aiohttp.ClientSession) -> dict[str, Any]: filename=f"audio{prepared.path.suffix}", content_type=prepared.content_type, model=self._model, + region=self._region, language=self._language, on_progress=self._on_progress, ) diff --git a/src/config.py b/src/config.py index 7b65d86d..7417ff2e 100644 --- a/src/config.py +++ b/src/config.py @@ -9,6 +9,7 @@ from dotenv import dotenv_values, load_dotenv +from src.openrouter_region import DEFAULT_OPENROUTER_REGION, normalize_openrouter_region from src.runtime.paths import env_path, migrate_legacy_runtime_data, repo_root, settings_path from src.soniox_region import ( DEFAULT_SONIOX_REGION as SONIOX_DEFAULT_REGION, @@ -290,6 +291,7 @@ class Config: DEFAULT_SONIOX_ASYNC_MODEL = "stt-async-v5" DEFAULT_SONIOX_RT_MODEL = "stt-rt-v5" DEFAULT_SONIOX_REGION = SONIOX_DEFAULT_REGION + DEFAULT_OPENROUTER_REGION = DEFAULT_OPENROUTER_REGION _LEGACY_DEFAULT_SONIOX_ASYNC_MODELS: ClassVar[set[str]] = { "stt-async-preview", "stt-async-v3", @@ -320,6 +322,7 @@ class Config: DEEPGRAM_API_KEY = os.getenv("DEEPGRAM_API_KEY") OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") + OPENROUTER_REGION = normalize_openrouter_region(os.getenv("SCRIBER_OPENROUTER_REGION")) MODEL_API_KEY = os.getenv("MODEL_API_KEY") CEREBRAS_API_KEY = os.getenv("CEREBRAS_API_KEY") CELERIS_API_KEY = os.getenv("CELERIS_API_KEY") @@ -809,6 +812,11 @@ def set_soniox_region(cls, region: str) -> None: cls.SONIOX_REGION = normalize_soniox_region(region, strict=True) os.environ["SCRIBER_SONIOX_REGION"] = cls.SONIOX_REGION + @classmethod + def set_openrouter_region(cls, region: str) -> None: + cls.OPENROUTER_REGION = normalize_openrouter_region(region, strict=True) + os.environ["SCRIBER_OPENROUTER_REGION"] = cls.OPENROUTER_REGION + @classmethod def set_debug(cls, enabled: bool) -> None: cls.DEBUG = bool(enabled) @@ -1124,6 +1132,7 @@ def add(k, v): add("SCRIBER_MODE", cls.MODE) add("SCRIBER_SONIOX_MODE", cls.SONIOX_MODE) add("SCRIBER_SONIOX_REGION", cls.SONIOX_REGION) + add("SCRIBER_OPENROUTER_REGION", cls.OPENROUTER_REGION) add("SCRIBER_SONIOX_ASYNC_MODEL", cls.SONIOX_ASYNC_MODEL) add("SCRIBER_SONIOX_RT_MODEL", cls.SONIOX_RT_MODEL) add("SCRIBER_ASSEMBLYAI_ASYNC_MODEL", cls.ASSEMBLYAI_ASYNC_MODEL) diff --git a/src/core/provider_errors.py b/src/core/provider_errors.py index 26f9b1ce..bb7b3ee9 100644 --- a/src/core/provider_errors.py +++ b/src/core/provider_errors.py @@ -31,6 +31,7 @@ def __init__( request_bytes: int | None = None, reason: str = "", upstream_code: str = "", + region: str = "", ) -> None: self.provider = _bounded_identifier(provider, fallback="provider") self.operation = _bounded_identifier(operation, fallback="request") @@ -39,6 +40,7 @@ def __init__( self.retryable = retryable self.reason = reason if reason in _PROVIDER_REASON_MESSAGES else "" self.upstream_code = upstream_code if _is_safe_code(upstream_code) else "" + self.region = region if region in {"eu", "us", "global"} else "" self.response_bytes = ( max(0, int(response_bytes)) if isinstance(response_bytes, int) and not isinstance(response_bytes, bool) @@ -56,12 +58,16 @@ def __init__( if self.code: details.append(f"code={self.code}") suffix = f" ({', '.join(details)})" if details else "" - super().__init__(f"{self.provider} {self.operation} failed{suffix}") + message = f"{self.provider} {self.operation} failed{suffix}" + if self.code == "region_unavailable" and self.region in _OPENROUTER_REGION_MESSAGES: + message = _OPENROUTER_REGION_MESSAGES[self.region] + super().__init__(message) def diagnostic_metadata(self) -> dict[str, Any]: """Bounded protocol details that survive the public log projection.""" return { "provider_operation": self.operation, + **({"region": self.region} if self.region else {}), **({"status": self.status} if self.status is not None else {}), **({"provider_error_code": self.code} if self.code else {}), **({"reason": self.reason} if self.reason else {}), @@ -88,6 +94,7 @@ class ProviderUserError: _HTTP_STATUS_RE = re.compile(r"(? ProviderUserError: dependency_error = error if isinstance(error, ProviderRuntimeDependencyError) else None @@ -221,6 +237,20 @@ def provider_user_error(provider: str | None, error: Exception | str) -> Provide else _public_error_code(raw, payload, status=status) ) + if normalized_provider in {"openrouter", "openrouter_stt"}: + # Live STT crosses Pipecat's string-only ErrorFrame boundary. Match only + # our complete canonical message, then reconstruct it without raw text. + region_message = next((message for message in _OPENROUTER_REGION_MESSAGES.values() if message in raw), None) + if region_message: + return _make_error( + normalized_provider, + label, + ErrorCategory.CONFIG_INVALID, + region_message, + code="region_unavailable", + retryable=False, + ) + if dependency_error: return _make_error( normalized_provider, @@ -357,10 +387,14 @@ def provider_transport_error( code: str = "", retryable: bool | None = None, request_bytes: int | None = None, + region: str = "", ) -> ProviderTransportError: """Build a sanitized provider error without retaining response content.""" extracted_code = code if _is_safe_code(code) else _provider_code_from_body(response_body) + if provider in {"openrouter", "openrouter_stt"} and region in {"eu", "us"} and status == 404: + extracted_code = "region_unavailable" + retryable = False reason, upstream_code = _provider_response_details(response_body) if isinstance(response_body, bytes): response_bytes = len(response_body) @@ -378,6 +412,7 @@ def provider_transport_error( request_bytes=request_bytes, reason=reason, upstream_code=upstream_code, + region=region, ) diff --git a/src/meeting_finalizer.py b/src/meeting_finalizer.py index 9eebfaca..1015e940 100644 --- a/src/meeting_finalizer.py +++ b/src/meeting_finalizer.py @@ -250,6 +250,8 @@ def _frozen_meeting_route(self, meeting: dict[str, Any]): return freeze_provider_route( **route_args, + provider_region=base_route.provider_region or None, + provider_endpoint_sha256=base_route.provider_endpoint_sha256 or None, provider_route=base_route.provider_route, audio_input_format=selected_format, audio_selection_mode=selection_mode, @@ -504,6 +506,8 @@ def _execution_route_for_snapshot(snapshot: Any) -> dict[str, Any]: "custom_vocab": vocab, "transport": snapshot.transport, "provider_route": options.get("providerRoute"), + "provider_region": options.get("providerRegion"), + "provider_endpoint_sha256": options.get("providerEndpointSha256"), "audio_input_format": options.get("audioInputFormat"), "provider_audio_capability_id": options.get("providerAudioCapabilityId"), "provider_audio_capability_revision": options.get("providerAudioCapabilityRevision"), diff --git a/src/openrouter_region.py b/src/openrouter_region.py new file mode 100644 index 00000000..ae668552 --- /dev/null +++ b/src/openrouter_region.py @@ -0,0 +1,33 @@ +"""One allowlisted OpenRouter origin for every STT and text request.""" + +from __future__ import annotations + +from typing import Final + +DEFAULT_OPENROUTER_REGION: Final = "eu" +SUPPORTED_OPENROUTER_REGIONS: Final = frozenset({"eu", "us", "global"}) + +_API_BASE_URLS: Final = { + "eu": "https://eu.openrouter.ai/api/v1", + "us": "https://us.openrouter.ai/api/v1", + "global": "https://openrouter.ai/api/v1", +} + + +def normalize_openrouter_region(value: object, *, strict: bool = False) -> str: + """Missing legacy settings adopt EU; invalid explicit values fail closed.""" + + normalized = str(value if value is not None else "").strip().lower() + if not normalized and not strict: + return DEFAULT_OPENROUTER_REGION + if normalized in SUPPORTED_OPENROUTER_REGIONS: + return normalized + raise ValueError("Invalid OpenRouter region. Choose eu, us, or global.") + + +def openrouter_api_base_url(region: object) -> str: + return _API_BASE_URLS[normalize_openrouter_region(region)] + + +def openrouter_stt_url(region: object) -> str: + return f"{openrouter_api_base_url(region)}/audio/transcriptions" diff --git a/src/pipeline.py b/src/pipeline.py index 28044f8a..5dd600a0 100644 --- a/src/pipeline.py +++ b/src/pipeline.py @@ -66,6 +66,7 @@ ) from src.core.provider_capabilities import get_capabilities from src.mic_silence_stop import MicSilenceStopObserver +from src.openrouter_region import openrouter_stt_url from src.runtime.audio_spool import append_pcm_frame, close_pcm_spool, create_pcm_spool, pcm_stream_to_wav from src.runtime.env_values import env_float from src.runtime.http_response import read_response_json_limited, read_response_text_limited @@ -2278,6 +2279,8 @@ def stt_runtime_configuration(self) -> dict[str, Any]: } if service in {"soniox", "soniox_async"}: configuration["region"] = self._execution_provider_region(Config.SONIOX_REGION) + elif service == "openrouter_stt": + configuration["region"] = self._execution_provider_region(Config.OPENROUTER_REGION) return configuration def _log_stt_runtime_configuration(self, *, workload: str) -> None: @@ -3426,7 +3429,6 @@ async def push_error(self, error_msg: str, exception=None, fatal: bool = False): elif self.service_name == "openrouter_stt": from src.cloud_async_stt import ( OPENROUTER_MAI_TRANSCRIBE_MODELS, - OPENROUTER_STT_URL, OpenRouterSTTProcessor, ) @@ -3449,11 +3451,13 @@ async def push_error(self, error_msg: str, exception=None, fatal: bool = False): AudioInputFormat.MP3, route_kind=ProviderAudioRouteKind.BATCH, ) - self._bind_execution_provider_endpoint(OPENROUTER_STT_URL) + region = self._execution_provider_region(Config.OPENROUTER_REGION) + self._bind_execution_provider_endpoint(openrouter_stt_url(region)) logger.info("Using Microsoft MAI Transcribe through OpenRouter") return OpenRouterSTTProcessor( api_key=api_key, model=bound_model, + region=region, language=self._execution_language(), session=session, on_progress=self.on_progress, @@ -4682,7 +4686,6 @@ async def _transcribe_file_direct_prepared( if self.service_name == "openrouter_stt": from src.cloud_async_stt import ( - OPENROUTER_STT_URL, openai_transcript_payload_to_text, transcribe_with_openrouter_audio_transcription, ) @@ -4690,7 +4693,8 @@ async def _transcribe_file_direct_prepared( api_key = Config.get_api_key("openrouter_stt") if not api_key: raise ValueError("OpenRouter API key is missing") - self._bind_execution_provider_endpoint(OPENROUTER_STT_URL) + region = self._execution_provider_region(Config.OPENROUTER_REGION) + self._bind_execution_provider_endpoint(openrouter_stt_url(region)) async with self._provider_session() as session: with open(path, "rb") as f: @@ -4701,6 +4705,7 @@ async def _transcribe_file_direct_prepared( filename=path.name, content_type=content_type, model=self._execution_model(Config.DEFAULT_OPENROUTER_STT_MODEL), + region=region, language=self._execution_language(), on_progress=self.on_progress, timeout_secs=batch_timeout_seconds, diff --git a/src/summarization.py b/src/summarization.py index 9ef64219..10e3036f 100644 --- a/src/summarization.py +++ b/src/summarization.py @@ -31,6 +31,7 @@ provider_public_code, provider_transport_error, ) +from src.openrouter_region import normalize_openrouter_region, openrouter_api_base_url from src.runtime.http_response import read_response_text_limited from src.runtime.provider_http import ProviderHttpTransport from src.summary_html import normalize_summary_document_html @@ -884,6 +885,12 @@ async def _try_openrouter_summary_fallback( # Preserve the bounded public classification for Meeting recovery. # Provider/model diagnostics were already written by the transport. raise + except ProviderTransportError as exc: + if exc.code == "region_unavailable": + raise + if _is_incomplete_summary_error(primary_error): + raise primary_error from None + raise RuntimeError(f"{primary_model} summarization failed and the OpenRouter fallback also failed.") from None except TimeoutError as exc: if _is_incomplete_summary_error(primary_error): raise primary_error from None @@ -1619,6 +1626,7 @@ async def _post_chat_completion_json( payload: dict[str, Any], headers: dict[str, str], session: aiohttp.ClientSession, + region: str = "", ) -> dict[str, Any]: """Read one bounded chat response, retrying a truncated HTTP payload once.""" @@ -1631,14 +1639,16 @@ async def _post_chat_completion_json( retryable_errors = (aiohttp.ClientPayloadError, aiohttp.ClientConnectionError) for attempt in range(retries + 1): try: - async with session.post(url, json=payload, headers=headers) as resp: + routing_options = {"allow_redirects": False} if provider == "openrouter" else {} + async with session.post(url, json=payload, headers=headers, **routing_options) as resp: raw = await read_response_text_limited(resp, 8 * 1024 * 1024) - if resp.status >= 400: + if resp.status >= 400 or (provider == "openrouter" and resp.status >= 300): raise provider_transport_error( provider, "summarization", status=resp.status, response_body=raw, + region=region, ) try: return json.loads(raw) @@ -1669,13 +1679,17 @@ async def _post_openrouter_chat_completion( payload: dict[str, Any], headers: dict[str, str], session: aiohttp.ClientSession, + *, + region: str | None = None, ) -> dict[str, Any]: + selected_region = normalize_openrouter_region(Config.OPENROUTER_REGION if region is None else region) return await _post_chat_completion_json( provider="openrouter", - url="https://openrouter.ai/api/v1/chat/completions", + url=f"{openrouter_api_base_url(selected_region)}/chat/completions", payload=payload, headers=headers, session=session, + region=selected_region, ) @@ -1754,6 +1768,9 @@ async def _summarize_openrouter( api_key = getattr(Config, "OPENROUTER_API_KEY", "") or "" if not api_key: raise ValueError("OpenRouter API key not configured. Please add it in Settings.") + # Pin the origin across every semantic/HTTP retry, including Settings edits + # made while an earlier attempt is awaiting its response. + region = normalize_openrouter_region(Config.OPENROUTER_REGION) timeout_seconds = _summary_timeout_seconds() timeout = aiohttp.ClientTimeout( @@ -1820,7 +1837,7 @@ def bounded_attempt_models(candidates: Sequence[str]) -> list[str]: attempt_models = attempts[attempt_index] attempt_max_tokens = attempt_budgets[attempt_index] payload = _build_openrouter_payload(prompt, attempt_models, attempt_max_tokens) - data = await _post_openrouter_chat_completion(payload, headers, session) + data = await _post_openrouter_chat_completion(payload, headers, session, region=region) content = _extract_openrouter_response_text(data).strip() used_model = _openrouter_used_model(data, attempt_models) diff --git a/src/transcript_artifacts.py b/src/transcript_artifacts.py index 837474e4..75b7f101 100644 --- a/src/transcript_artifacts.py +++ b/src/transcript_artifacts.py @@ -33,6 +33,7 @@ RouteSnapshotDraft, StageUnit, ) +from src.openrouter_region import normalize_openrouter_region, openrouter_stt_url from src.provider_transcript import azure_mai_used_text_fallback, has_speaker_evidence, normalize_provider_segments from src.youtube_download import YouTubeCaptionCue @@ -350,6 +351,17 @@ def freeze_provider_route( ): raise UnsupportedProviderAudioRoute("Provider endpoint fingerprint is invalid.") + if key == "openrouter_stt": + # Meeting finalization also freezes routes directly through this + # boundary. Bind both tracks and recovered snapshots to the same origin. + resolved_region = normalize_openrouter_region( + Config.OPENROUTER_REGION if provider_region is None else provider_region + ) + endpoint_sha256 = hashlib.sha256(openrouter_stt_url(resolved_region).encode("utf-8")).hexdigest() + if resolved_endpoint_sha256 and resolved_endpoint_sha256 != endpoint_sha256: + raise UnsupportedProviderAudioRoute("OpenRouter endpoint no longer matches the frozen region.") + resolved_endpoint_sha256 = endpoint_sha256 + # OpenRouter's Microsoft MAI transcription contract does not expose a # prompt or phrase-list field. Keep the frozen execution evidence aligned # with the bytes actually sent so later vocabulary changes cannot block an diff --git a/src/web_api.py b/src/web_api.py index f332e652..6ed7f201 100644 --- a/src/web_api.py +++ b/src/web_api.py @@ -238,6 +238,7 @@ show_transcribing_overlay, update_overlay_audio, ) +from src.openrouter_region import normalize_openrouter_region, openrouter_stt_url from src.outlook_calendar import OutlookCalendarService from src.podcasts.processor import PodcastProcessor from src.podcasts.service import PodcastService @@ -1964,6 +1965,8 @@ def __init__(self, provider: str) -> None: def _meeting_analysis_failure_details(exc: Exception) -> tuple[str, str]: """Return stable public recovery details without exposing provider internals.""" + if isinstance(exc, ProviderTransportError) and exc.code == "region_unavailable": + return "meeting_analysis_region_unavailable", provider_user_error(None, exc).message if isinstance(exc, ProviderTransportError) and exc.provider == "gemini" and exc.code == "authentication_error": return ( "meeting_analysis_provider_auth", @@ -2000,6 +2003,7 @@ def _meeting_analysis_fallback_status(model: str, reason: str) -> str: "meeting_analysis_timeout", "meeting_analysis_failed", "meeting_analysis_provider_auth", + "meeting_analysis_region_unavailable", "process_interrupted_during_analysis", } ) @@ -5483,7 +5487,10 @@ def _freeze_background_provider_route( elif provider_key == "groq": endpoint_identity = "https://api.groq.com/openai/v1" elif provider_key == "openrouter_stt": - endpoint_identity = "https://openrouter.ai/api/v1/audio/transcriptions" + resolved_region = normalize_openrouter_region( + Config.OPENROUTER_REGION if provider_region is None else provider_region + ) + endpoint_identity = openrouter_stt_url(resolved_region) elif provider_key in {"meta_stt", "meta_stt_async"}: endpoint_identity = "https://api.meta.ai/v1/asr/transcribe" resolved_endpoint_sha256 = ( @@ -8524,6 +8531,8 @@ def get_hot_path_metrics(self, *, limit: int = 50, include_active: bool = False) @staticmethod def _post_processing_error_summary(exc: Exception) -> str: if isinstance(exc, ProviderTransportError): + if exc.code == "region_unavailable": + return provider_user_error(None, exc).message return str(exc)[:240] return f"{exc.__class__.__name__} during live mic post-processing" @@ -9589,7 +9598,14 @@ async def _post_process_and_inject_live_transcript( raise pending_cancel if processing_failure is not None: await self.broadcast( - status_event("Post-processing failed; inserting raw transcript", False, session_id=session_id) + status_event( + processing_failure_summary + if isinstance(processing_failure, ProviderTransportError) + and processing_failure.code == "region_unavailable" + else "Post-processing failed; inserting raw transcript", + False, + session_id=session_id, + ) ) self._emit_workflow_event( message="Live mic post-processing failed; raw transcript retained", @@ -17670,6 +17686,7 @@ def resolve_device_id(device_id: str) -> str | None: "defaultSttService": Config.DEFAULT_STT_SERVICE, "sonioxMode": Config.SONIOX_MODE, "sonioxRegion": Config.SONIOX_REGION, + "openrouterRegion": Config.OPENROUTER_REGION, "sonioxRealtimeModel": Config.SONIOX_RT_MODEL, "sonioxAsyncModel": Config.SONIOX_ASYNC_MODEL, "transcriptionProviderModels": Config.transcription_provider_models(), @@ -17764,6 +17781,7 @@ async def _update_settings_unlocked(self, payload: dict[str, Any]) -> dict[str, validated_service: str | None = None validated_soniox_mode: str | None = None validated_soniox_region: str | None = None + validated_openrouter_region: str | None = None validated_summarization_model: str | None = None validated_meeting_analysis_model: str | None = None validated_meeting_transcription_mode: str | None = None @@ -17795,6 +17813,10 @@ async def _update_settings_unlocked(self, payload: dict[str, Any]) -> dict[str, if not isinstance(payload["sonioxRegion"], str): raise ValueError("Soniox region must be text.") validated_soniox_region = _validate_soniox_region(payload["sonioxRegion"]) + if "openrouterRegion" in payload: + if not isinstance(payload["openrouterRegion"], str): + raise ValueError("OpenRouter region must be text.") + validated_openrouter_region = normalize_openrouter_region(payload["openrouterRegion"], strict=True) if "summarizationModel" in payload and isinstance(payload["summarizationModel"], str): validated_summarization_model = _validate_summarization_model(payload["summarizationModel"]) if "meetingAnalysisModel" in payload and isinstance(payload["meetingAnalysisModel"], str): @@ -17945,6 +17967,8 @@ async def _update_settings_unlocked(self, payload: dict[str, Any]) -> dict[str, if validated_soniox_region is not None: Config.set_soniox_region(validated_soniox_region) + if validated_openrouter_region is not None: + Config.set_openrouter_region(validated_openrouter_region) if "sonioxAsyncModel" in payload and isinstance(payload["sonioxAsyncModel"], str): Config.SONIOX_ASYNC_MODEL = payload["sonioxAsyncModel"].strip() @@ -18269,7 +18293,9 @@ async def mark_failed(public_message: str | ValueError) -> None: return SummaryOutcome(kind="rejected", message=str(exc)) except Exception as exc: info = provider_user_error(None, exc) - public_message = "Could not create the summary. Please try again." + public_message = ( + info.message if info.code == "region_unavailable" else "Could not create the summary. Please try again." + ) logger.error( "Summarization failed (error_type={}, code={})", type(exc).__name__, diff --git a/tests/conftest.py b/tests/conftest.py index 31bacbf9..72e66d19 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ os.environ.setdefault("SCRIBER_INJECT_METHOD", "type") os.environ.setdefault("SCRIBER_DISABLE_DEVICE_MONITOR", "1") os.environ["SCRIBER_MIC_ALWAYS_ON"] = "0" +os.environ["SCRIBER_OPENROUTER_REGION"] = "eu" # Never let the automated suite inherit real provider credentials from a # developer shell or local .env file. Tests that exercise credential handling diff --git a/tests/core/test_cloud_async_stt.py b/tests/core/test_cloud_async_stt.py index dd2ed6b8..56da1eb5 100644 --- a/tests/core/test_cloud_async_stt.py +++ b/tests/core/test_cloud_async_stt.py @@ -12,7 +12,6 @@ from src.cloud_async_stt import ( OPENROUTER_MAI_TRANSCRIBE_MODEL, - OPENROUTER_STT_URL, OpenRouterSTTProcessor, SpeechmaticsAsyncProcessor, _build_openrouter_stt_json_body, @@ -32,6 +31,7 @@ from src.config import Config from src.core.provider_errors import ProviderTransportError from src.microphone import RustCaptureWavArtifact +from src.openrouter_region import openrouter_stt_url from src.runtime.audio_spool import create_pcm_spool from src.runtime.media_tools import find_media_tool @@ -155,7 +155,7 @@ def post(self, url, **kwargs): ) request = json.loads(session.raw_body) - assert session.url == OPENROUTER_STT_URL + assert session.url == openrouter_stt_url(Config.OPENROUTER_REGION) assert session.headers["Authorization"] == "Bearer openrouter-secret" assert session.headers["Content-Type"] == "application/json" assert request == { @@ -233,7 +233,7 @@ def post(self, url, **kwargs): self.calls += 1 self.body = kwargs["data"] request = json.loads(self.body.read()) - assert url == OPENROUTER_STT_URL + assert url == openrouter_stt_url(Config.OPENROUTER_REGION) assert request["input_audio"]["format"] == "mp3" audio = base64.b64decode(request["input_audio"]["data"], validate=True) assert len(audio) < len(original) diff --git a/tests/core/test_openrouter_stt_retry.py b/tests/core/test_openrouter_stt_retry.py index 603355c1..7cf63c3c 100644 --- a/tests/core/test_openrouter_stt_retry.py +++ b/tests/core/test_openrouter_stt_retry.py @@ -85,7 +85,9 @@ async def transcribe(request): site = web.TCPSite(runner, "127.0.0.1", 0) await site.start() monkeypatch.setattr( - cloud_async_stt, "OPENROUTER_STT_URL", f"http://127.0.0.1:{runner.addresses[0][1]}/transcriptions" + cloud_async_stt, + "openrouter_stt_url", + lambda _region: f"http://127.0.0.1:{runner.addresses[0][1]}/transcriptions", ) audio = io.BytesIO(b"prefix:retained-audio") audio.seek(7) diff --git a/tests/test_config.py b/tests/test_config.py index cdd2af54..e5b36c16 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -819,6 +819,24 @@ def test_persist_to_env_file_includes_soniox_region(monkeypatch, tmp_path): assert "SCRIBER_SONIOX_REGION=eu" in target.read_text(encoding="utf-8") +@pytest.mark.parametrize("stored", [None, "eu", "us", "global"]) +def test_openrouter_region_survives_restart_and_legacy_defaults_to_eu(tmp_path, stored): + if stored is not None: + (tmp_path / ".env").write_text(f"SCRIBER_OPENROUTER_REGION={stored}\n", encoding="utf-8") + env = os.environ.copy() + env.pop("SCRIBER_OPENROUTER_REGION", None) + env.update(SCRIBER_DATA_DIR=str(tmp_path), SCRIBER_SKIP_LEGACY_DATA_MIGRATION="1") + result = subprocess.run( + [sys.executable, "-c", "from src.config import Config; print(Config.OPENROUTER_REGION)"], + cwd=Path(__file__).resolve().parents[1], + env=env, + capture_output=True, + text=True, + check=True, + ) + assert result.stdout.strip() == (stored or "eu") + + def test_meeting_transcription_mode_is_validated_and_persisted(monkeypatch, tmp_path): target = tmp_path / ".env" monkeypatch.setattr(Config, "MEETING_TRANSCRIPTION_MODE", "live_final") diff --git a/tests/test_frontend_type_gates.py b/tests/test_frontend_type_gates.py index 78cb74f0..43ce5f52 100644 --- a/tests/test_frontend_type_gates.py +++ b/tests/test_frontend_type_gates.py @@ -1640,7 +1640,7 @@ def test_settings_exposes_microsoft_mai_through_openrouter_with_one_key() -> Non ] assert_source_contains_tokens( provider_options_source, - 'benchmarkOption("openrouter_stt", t("Microsoft MAI · OpenRouter"), 1.6667, 2.0, "cloud_async", "openrouter", t("One key for STT and AI · about 5% credit purchase fee"))', + 'benchmarkOption("openrouter_stt", t("Microsoft MAI · OpenRouter"), 1.6667, 2.0, "cloud_async", "openrouter", t("One key for STT and AI · EU by default"))', ) assert_source_contains_tokens( provider_options_source, @@ -1678,14 +1678,10 @@ def test_settings_exposes_microsoft_mai_through_openrouter_with_one_key() -> Non openrouter_key_note = ( "One OpenRouter API key covers Microsoft MAI STT, summaries, meeting analysis, and cloud cleanup. " - "OpenRouter does not mark up model prices; buying credits currently adds about 5%: 5.5% " - "(minimum $0.80) for standard payments or 5% for crypto." + "Europe is the default region." ) assert_source_contains_tokens(settings_source, f't("{openrouter_key_note}")') assert f'"{openrouter_key_note}":' in translations - assert "OpenRouter erhebt keinen Aufschlag auf Modellpreise" in translations - assert "5,5 % (mindestens 0,80 $)" in translations - assert "5 % bei Krypto" in translations def test_settings_exposes_modulate_final_text_only_realtime_and_batch() -> None: diff --git a/tests/test_meeting_finalizer.py b/tests/test_meeting_finalizer.py index f6fa88ba..67af1e86 100644 --- a/tests/test_meeting_finalizer.py +++ b/tests/test_meeting_finalizer.py @@ -102,6 +102,30 @@ def test_direct_meeting_route_freezes_exact_preparation_before_attempt( assert recovered["audio_input_format_verified"] is True +@pytest.mark.parametrize( + "region,host", [("eu", "eu.openrouter.ai"), ("us", "us.openrouter.ai"), ("global", "openrouter.ai")] +) +def test_openrouter_meeting_tracks_and_recovery_keep_frozen_region(monkeypatch, tmp_path, region, host): + from src.config import Config + + monkeypatch.setattr(Config, "OPENROUTER_REGION", region) + finalizer = MeetingFinalizer( + SimpleNamespace(), + tmp_path, + lambda **_kwargs: None, + lambda *_args, **_kwargs: None, + artifact_store=SimpleNamespace(), + ) + route = finalizer._frozen_meeting_route( + {"finalProvider": "openrouter_stt", "language": "de", "captureMetadata": {}} + ) + Config.OPENROUTER_REGION = "us" if region == "eu" else "eu" + recovered = finalizer._execution_route_for_snapshot(route.snapshot_draft()) + assert recovered["provider_region"] == route.execution_route()["provider_region"] == region + expected = hashlib.sha256(f"https://{host}/api/v1/audio/transcriptions".encode()).hexdigest() + assert recovered["provider_endpoint_sha256"] == route.execution_route()["provider_endpoint_sha256"] == expected + + def test_voice_reprocess_temp_cleanup_is_bounded_to_runtime_directory(tmp_path): audio_root = tmp_path / "meetings" stale = audio_root / ".runtime" / "voice-reprocess" / "interrupted" diff --git a/tests/test_microphone_device_resolution.py b/tests/test_microphone_device_resolution.py index 9abf1982..29e3f110 100644 --- a/tests/test_microphone_device_resolution.py +++ b/tests/test_microphone_device_resolution.py @@ -419,6 +419,30 @@ async def test_soniox_region_settings_are_validated_and_exposed( ctl.shutdown() +@pytest.mark.asyncio +async def test_openrouter_region_settings_validate_before_mutating_other_settings(monkeypatch): + ctl = ScriberWebController(asyncio.get_running_loop()) + monkeypatch.setattr(Config, "OPENROUTER_REGION", "eu") + monkeypatch.setenv("SCRIBER_OPENROUTER_REGION", "eu") + monkeypatch.setattr(ctl, "list_microphones", lambda: [{"deviceId": "default", "label": "Default"}]) + monkeypatch.setattr(ctl, "_schedule_settings_persist", lambda: None) + key, soniox = Config.OPENROUTER_API_KEY, Config.SONIOX_REGION + try: + for region in ["eu", "us", "global"]: + assert (await ctl.update_settings({"openrouterRegion": region}))["openrouterRegion"] == region + assert ctl.get_settings()["openrouterRegion"] == region + for invalid in ["jp", "", "https://openrouter.ai", None, 42, False]: + with pytest.raises(ValueError, match="OpenRouter region"): + await ctl.update_settings( + {"openrouterRegion": invalid, "sonioxRegion": "eu", "apiKeys": {"openrouter": "changed"}} + ) + assert Config.OPENROUTER_REGION == "global" + assert key == Config.OPENROUTER_API_KEY + assert soniox == Config.SONIOX_REGION + finally: + ctl.shutdown() + + @pytest.mark.asyncio async def test_get_settings_falls_back_to_first_available_when_favorite_missing( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_openrouter_region.py b/tests/test_openrouter_region.py new file mode 100644 index 00000000..8e19d194 --- /dev/null +++ b/tests/test_openrouter_region.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +import json + +import pytest + +from src import summarization +from src.cloud_async_stt import OpenRouterSTTProcessor, transcribe_with_openrouter_audio_transcription +from src.config import Config +from src.core.error_taxonomy import ErrorCategory +from src.core.provider_errors import ProviderTransportError, provider_transport_error, provider_user_error +from src.openrouter_region import normalize_openrouter_region, openrouter_api_base_url + +REGIONS = [ + ("eu", "https://eu.openrouter.ai/api/v1"), + ("us", "https://us.openrouter.ai/api/v1"), + ("global", "https://openrouter.ai/api/v1"), +] + + +class Response: + def __init__(self, status=200, payload=None): + self.status = status + self.payload = payload or {"text": "Synthetic transcript"} + self.headers = {} + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return False + + async def text(self): + return json.dumps(self.payload) + + +class Session: + def __init__(self, responses): + self.responses = iter(responses) + self.calls = [] + + def post(self, url, **kwargs): + if "data" in kwargs: + kwargs["body"] = json.loads(kwargs["data"].read()) + self.calls.append((url, kwargs)) + return next(self.responses) + + async def close(self): + pass + + +def test_missing_and_legacy_region_defaults_to_eu(): + assert Config.DEFAULT_OPENROUTER_REGION == "eu" + assert normalize_openrouter_region(None) == "eu" + assert normalize_openrouter_region("") == "eu" + assert normalize_openrouter_region(" US ", strict=True) == "us" + + +@pytest.mark.parametrize("value", ["jp", "https://openrouter.ai", False, 1, [], {}]) +def test_invalid_region_never_selects_a_different_origin(value): + with pytest.raises(ValueError, match="Invalid OpenRouter region"): + openrouter_api_base_url(value) + + +@pytest.mark.parametrize("region,base_url", REGIONS) +def test_setting_round_trips_independently_of_api_key_and_soniox(monkeypatch, tmp_path, region, base_url): + monkeypatch.setattr(Config, "OPENROUTER_REGION", "eu") + monkeypatch.setenv("SCRIBER_OPENROUTER_REGION", "eu") + key, soniox_region = Config.OPENROUTER_API_KEY, Config.SONIOX_REGION + Config.set_openrouter_region(region) + target = tmp_path / ".env" + Config.persist_to_env_file(str(target)) + assert f"SCRIBER_OPENROUTER_REGION={region}" in target.read_text() + assert openrouter_api_base_url(Config.OPENROUTER_REGION) == base_url + assert key == Config.OPENROUTER_API_KEY + assert soniox_region == Config.SONIOX_REGION + + +@pytest.mark.asyncio +@pytest.mark.parametrize("region,base_url", REGIONS) +async def test_stt_and_chat_use_the_same_region_key_and_model_contract(monkeypatch, region, base_url): + monkeypatch.setattr(Config, "OPENROUTER_REGION", region) + session = Session([Response(), Response()]) + await transcribe_with_openrouter_audio_transcription( + session=session, + api_key="synthetic-key", + audio_source=b"test-audio", + filename="audio.mp3", + content_type="audio/mpeg", + language="de", + ) + payload = {"model": "openai/gpt-oss-120b", "messages": []} + await summarization._post_openrouter_chat_completion(payload, {"Authorization": "Bearer synthetic-key"}, session) + assert [url for url, _ in session.calls] == [f"{base_url}/audio/transcriptions", f"{base_url}/chat/completions"] + for _, options in session.calls: + assert options["allow_redirects"] is False + assert options["headers"]["Authorization"] == "Bearer synthetic-key" + assert session.calls[0][1]["body"]["model"] == "microsoft/mai-transcribe-2" + assert session.calls[1][1]["json"] == payload + + +@pytest.mark.asyncio +@pytest.mark.parametrize("region", ["eu", "us"]) +@pytest.mark.parametrize("workload", ["stt", "summary", "meeting", "post_processing", "fallback"]) +async def test_regional_404_is_actionable_and_never_replayed_or_sent_global(monkeypatch, region, workload): + monkeypatch.setattr(Config, "OPENROUTER_REGION", region) + monkeypatch.setattr(Config, "OPENROUTER_API_KEY", "synthetic-key") + session = Session( + [Response(404, {"error": {"message": "No endpoints found supporting your data region. PRIVATE-ECHO"}})] + ) + monkeypatch.setattr(summarization.aiohttp, "ClientSession", lambda **_kwargs: session) + model = "openai/gpt-oss-120b" + with pytest.raises(ProviderTransportError) as caught: + if workload == "stt": + await transcribe_with_openrouter_audio_transcription( + session=session, + api_key="synthetic-key", + audio_source=b"audio", + filename="audio.mp3", + content_type="audio/mpeg", + language=None, + ) + elif workload == "summary": + await summarization.summarize_text("Synthetic transcript", model) + elif workload == "meeting": + from src.meeting_analysis import analyze_meeting + + await analyze_meeting( + "Synthetic meeting", + [{"id": "segment-1", "startMs": 0, "endMs": 1000, "text": "Synthetic speech"}], + [], + model=model, + generate=summarization.generate_text_with_model, + ) + elif workload == "post_processing": + from src.post_processing import post_process_live_transcript + + await post_process_live_transcript("Synthetic speech", model=model, engine="cloud") + else: + await summarization._try_openrouter_summary_fallback( + "Synthetic prompt", + primary_model="cerebras/gemma-4-31b", + primary_error=RuntimeError("synthetic primary failure"), + max_output_tokens=128, + timeout_seconds=10, + ) + error = caught.value + assert error.status == 404 + assert error.code == "region_unavailable" + assert error.region == region + assert error.retryable is False + assert "PRIVATE-ECHO" not in str(error) + assert "PRIVATE-ECHO" not in str(error.diagnostic_metadata()) + public = provider_user_error(None, error) + assert public.category == ErrorCategory.CONFIG_INVALID + assert f"{region.upper()} region" in public.message + assert "did not switch to the global endpoint" in public.message + # Live Mic's string-only ErrorFrame must retain the same guidance. + assert ( + provider_user_error("openrouter_stt", f"openrouter_stt async error: {public.message}").message == public.message + ) + assert len(session.calls) == 1 + assert session.calls[0][0].startswith(f"https://{region}.openrouter.ai/") + + +def test_global_404_keeps_generic_model_error(): + error = provider_transport_error("openrouter", "summarization", status=404, region="global") + assert error.code != "region_unavailable" + assert "EU" not in str(error) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("workload", ["stt", "chat"]) +async def test_redirect_is_rejected_instead_of_followed(monkeypatch, workload): + monkeypatch.setattr(Config, "OPENROUTER_REGION", "eu") + response = Response(307) + response.headers = {"Location": "https://openrouter.ai/api/v1/chat/completions"} + session = Session([response]) + with pytest.raises(ProviderTransportError) as caught: + if workload == "stt": + await transcribe_with_openrouter_audio_transcription( + session=session, + api_key="test", + audio_source=b"audio", + filename="audio.mp3", + content_type="audio/mpeg", + language=None, + ) + else: + await summarization._post_openrouter_chat_completion({}, {}, session) + assert caught.value.status == 307 + assert len(session.calls) == 1 + assert session.calls[0][1]["allow_redirects"] is False + + +@pytest.mark.asyncio +async def test_chat_semantic_retries_pin_region_even_if_settings_change(monkeypatch): + monkeypatch.setattr(Config, "OPENROUTER_REGION", "eu") + monkeypatch.setattr(Config, "OPENROUTER_API_KEY", "test") + captured_regions = [] + + async def post(_payload, _headers, _session, *, region): + captured_regions.append(region) + Config.OPENROUTER_REGION = "global" + content, reason = ("partial", "length") if len(captured_regions) == 1 else ("complete", "stop") + return {"model": "openai/gpt-oss-120b", "choices": [{"message": {"content": content}, "finish_reason": reason}]} + + monkeypatch.setattr(summarization, "_post_openrouter_chat_completion", post) + assert await summarization._summarize_openrouter("Synthetic prompt", "openai/gpt-oss-120b", 128) == "complete" + assert captured_regions == ["eu", "eu"] + + +def test_buffered_stt_captures_region_at_construction(monkeypatch): + monkeypatch.setattr(Config, "OPENROUTER_REGION", "eu") + processor = OpenRouterSTTProcessor(api_key="test", language=None) + Config.OPENROUTER_REGION = "global" + assert processor._region == "eu" + + +@pytest.mark.asyncio +async def test_stt_rate_limit_retry_keeps_region_after_settings_change(monkeypatch): + from src import cloud_async_stt + + monkeypatch.setattr(Config, "OPENROUTER_REGION", "eu") + monkeypatch.setattr(cloud_async_stt, "_openrouter_retry_delay", lambda *_args: 0) + + class RateLimit(Response): + async def text(self): + Config.OPENROUTER_REGION = "global" + return await super().text() + + session = Session([RateLimit(429), Response()]) + await transcribe_with_openrouter_audio_transcription( + session=session, + api_key="test", + audio_source=b"audio", + filename="audio.mp3", + content_type="audio/mpeg", + language=None, + ) + assert [url for url, _ in session.calls] == ["https://eu.openrouter.ai/api/v1/audio/transcriptions"] * 2 + + +def test_meeting_recovery_and_post_processing_keep_safe_region_guidance(): + from src.web_api import ( + ScriberWebController, + _meeting_analysis_failure_details, + _persisted_meeting_analysis_failure_details, + ) + + error = provider_transport_error( + "openrouter", "summarization", status=404, region="eu", response_body="PRIVATE-ECHO" + ) + code, message = _meeting_analysis_failure_details(error) + assert code == "meeting_analysis_region_unavailable" + assert "EU region" in message + assert "PRIVATE-ECHO" not in message + assert _persisted_meeting_analysis_failure_details({"errorCode": code, "errorMessage": message}) == (code, message) + assert ScriberWebController._post_processing_error_summary(error) == message diff --git a/tests/test_pipeline_stop.py b/tests/test_pipeline_stop.py index 6a2e8110..807d9db9 100644 --- a/tests/test_pipeline_stop.py +++ b/tests/test_pipeline_stop.py @@ -1138,11 +1138,13 @@ async def fake_transcribe(**kwargs): fake_transcribe, ) monkeypatch.setattr(Config, "OPENROUTER_API_KEY", "one-key") + monkeypatch.setattr(Config, "OPENROUTER_REGION", "global") pipeline = ScriberPipeline( service_name="openrouter_stt", execution_route={ "model": model, "language": "de-DE", + "provider_region": "eu", }, ) monkeypatch.setattr(Config, "LANGUAGE", "en-US") @@ -1152,6 +1154,7 @@ async def fake_transcribe(**kwargs): assert captured["api_key"] == "one-key" assert captured["model"] == model assert captured["language"] == "de-DE" + assert captured["region"] == "eu" assert "custom_vocab" not in captured assert "diarize" not in captured @@ -1559,6 +1562,7 @@ def test_buffered_provider_factories_enable_diarization_for_batch_jobs(monkeypat def test_openrouter_mai_factory_and_runtime_configuration_use_pinned_batch_route(monkeypatch, model): monkeypatch.setattr(Config, "OPENROUTER_API_KEY", "shared-openrouter-key") monkeypatch.setattr(Config, "LANGUAGE", "de-DE") + monkeypatch.setattr(Config, "OPENROUTER_REGION", "us") session = object() pipeline = ScriberPipeline( service_name="openrouter_stt", @@ -1577,6 +1581,8 @@ def test_openrouter_mai_factory_and_runtime_configuration_use_pinned_batch_route assert configuration["model"] == (model or "microsoft/mai-transcribe-2") assert configuration["mode"] == "batch" assert configuration["language"] == "de-DE" + assert configuration["region"] == "us" + assert service._region == "us" def test_onnx_file_factory_uses_bounded_flushing_service(monkeypatch): diff --git a/tests/test_summarization.py b/tests/test_summarization.py index 9e626612..c3c16f8a 100644 --- a/tests/test_summarization.py +++ b/tests/test_summarization.py @@ -265,7 +265,7 @@ async def test_meta_muse_chat_completion_uses_official_contract(monkeypatch: pyt calls: list[tuple[dict[str, object], dict[str, str]]] = [] monkeypatch.setattr(summarization.Config, "MODEL_API_KEY", "meta-test-key", raising=False) - async def _fake_post(payload, headers, _session): + async def _fake_post(payload, headers, _session, **_routing): calls.append((payload, headers)) return { "model": "muse-spark-1.3", @@ -294,7 +294,7 @@ async def _fake_post(payload, headers, _session): async def test_meta_muse_discards_output_marked_incomplete(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(summarization.Config, "MODEL_API_KEY", "meta-test-key", raising=False) - async def _fake_post(_payload, _headers, _session): + async def _fake_post(_payload, _headers, _session, **_routing): return { "model": "muse-spark-1.3-contributor", "choices": [ @@ -461,7 +461,7 @@ async def test_live_mic_generation_uses_context_local_low_reasoning(monkeypatch: monkeypatch.delenv("SCRIBER_SUMMARY_OPENROUTER_REASONING_EFFORT", raising=False) monkeypatch.setattr(summarization.Config, "OPENROUTER_API_KEY", "openrouter-key", raising=False) - async def _fake_post(payload, _headers, _session): + async def _fake_post(payload, _headers, _session, **_routing): calls.append(payload) return { "model": "minimax/minimax-m3", @@ -527,7 +527,7 @@ async def _rejected_primary(_prompt, model, _max_output_tokens): retryable=False, ) - async def _accepted_fallback(payload, _headers, _session): + async def _accepted_fallback(payload, _headers, _session, **_routing): fallback_calls.append(payload) return { "model": "z-ai/glm-5.3-flash", @@ -615,7 +615,7 @@ async def _transient_primary(_prompt, _model, _max_output_tokens): retryable=True, ) - async def _accepted_fallback(_payload, _headers, _session): + async def _accepted_fallback(_payload, _headers, _session, **_routing): return { "model": "minimax/minimax-m3", "choices": [ @@ -666,7 +666,7 @@ async def _rejected_primary(*_args, **_kwargs): retryable=False, ) - async def _accepted_fallback(_payload, _headers, _session): + async def _accepted_fallback(_payload, _headers, _session, **_routing): return { "model": "minimax/minimax-m3", "choices": [ @@ -722,7 +722,7 @@ async def _rejected_primary(_prompt, _model, _max_output_tokens): retryable=False, ) - async def _accepted_fallback(_payload, _headers, _session): + async def _accepted_fallback(_payload, _headers, _session, **_routing): return { "model": "minimax/minimax-m3", "choices": [ @@ -778,7 +778,7 @@ async def _rejected_primary(_prompt, _model, _max_output_tokens): retryable=False, ) - async def _accepted_fallback(_payload, _headers, _session): + async def _accepted_fallback(_payload, _headers, _session, **_routing): return { "model": "minimax/minimax-m3", "choices": [ @@ -844,7 +844,7 @@ async def __aexit__(self, *_args): return False class _Session: - def post(self, _url, *, json, headers): + def post(self, _url, *, json, headers, **_routing): del json, headers post_calls.append(mode) return _Response() @@ -867,7 +867,7 @@ async def _read_response(_response, _limit): } ) - async def _post_with_fake_session(payload, headers, _session): + async def _post_with_fake_session(payload, headers, _session, **_routing): return await summarization._post_chat_completion_json( provider="openrouter", url="https://openrouter.invalid/v1/chat/completions", @@ -1125,7 +1125,7 @@ async def session_view(self, *, provider, marker=None): providers.append(provider) return borrowed - async def _fake_post(_payload, _headers, session): + async def _fake_post(_payload, _headers, session, **_routing): observed_sessions.append(session) assert isinstance(session, summarization._RequestTimeoutSessionView) assert session._session is borrowed @@ -1162,7 +1162,7 @@ async def test_live_mic_generation_caps_openrouter_attempts(monkeypatch: pytest. monkeypatch.delenv("SCRIBER_SUMMARY_OPENROUTER_REASONING_EFFORT", raising=False) monkeypatch.setattr(summarization.Config, "OPENROUTER_API_KEY", "openrouter-key", raising=False) - async def _fake_post(payload, _headers, _session): + async def _fake_post(payload, _headers, _session, **_routing): calls.append(payload) max_tokens = payload["max_tokens"] return { @@ -1202,7 +1202,7 @@ async def test_live_mic_openrouter_retries_remain_single_model(monkeypatch: pyte calls: list[dict[str, object]] = [] monkeypatch.setattr(summarization.Config, "OPENROUTER_API_KEY", "openrouter-key", raising=False) - async def _fake_post(payload, _headers, _session): + async def _fake_post(payload, _headers, _session, **_routing): calls.append(payload) if len(calls) == 1: max_tokens = payload["max_tokens"] @@ -1767,7 +1767,7 @@ def warning(self, message: str, *args: object) -> None: def error(self, message: str, *args: object) -> None: log_records.append((message, args)) - async def _fake_post(payload, _headers, _session): + async def _fake_post(payload, _headers, _session, **_routing): calls.append(payload) if len(calls) == 1: return { @@ -1813,7 +1813,7 @@ async def test_summarize_text_stops_after_all_openrouter_models_return_invalid_h calls: list[dict[str, object]] = [] monkeypatch.setattr(summarization.Config, "OPENROUTER_API_KEY", "openrouter-key", raising=False) - async def _fake_post(payload, _headers, _session): + async def _fake_post(payload, _headers, _session, **_routing): calls.append(payload) response_model = "minimax/minimax-m3" if len(calls) == 1 else "z-ai/glm-5.3-flash-20260826" return { @@ -1847,7 +1847,7 @@ async def test_generate_text_with_openrouter_preserves_raw_text_contract( raw_text = "RAW_TEXT_WITHOUT_SUMMARY_HTML" monkeypatch.setattr(summarization.Config, "OPENROUTER_API_KEY", "openrouter-key", raising=False) - async def _fake_post(payload, _headers, _session): + async def _fake_post(payload, _headers, _session, **_routing): calls.append(payload) return { "model": "minimax/minimax-m3", @@ -1880,7 +1880,7 @@ async def test_summarize_openrouter_retries_empty_selected_model_with_default_fa sessions: list[object] = [] monkeypatch.setattr(summarization.Config, "OPENROUTER_API_KEY", "openrouter-key", raising=False) - async def _fake_post(payload, _headers, session): + async def _fake_post(payload, _headers, session, **_routing): calls.append(payload) sessions.append(session) if len(calls) == 1: @@ -1925,7 +1925,7 @@ async def test_summarize_openrouter_uses_gpt_oss_provider_route_before_default_f calls: list[dict[str, object]] = [] monkeypatch.setattr(summarization.Config, "OPENROUTER_API_KEY", "openrouter-key", raising=False) - async def _fake_post(payload, _headers, _timeout): + async def _fake_post(payload, _headers, _timeout, **_routing): calls.append(payload) if len(calls) == 1: return { @@ -1967,7 +1967,7 @@ async def test_summarize_openrouter_retries_empty_length_response_with_next_mode calls: list[dict[str, object]] = [] monkeypatch.setattr(summarization.Config, "OPENROUTER_API_KEY", "openrouter-key", raising=False) - async def _fake_post(payload, _headers, _timeout): + async def _fake_post(payload, _headers, _timeout, **_routing): calls.append(payload) if len(calls) == 1: return { @@ -2017,7 +2017,7 @@ async def test_summarize_openrouter_retries_partial_length_response_instead_of_s calls: list[dict[str, object]] = [] monkeypatch.setattr(summarization.Config, "OPENROUTER_API_KEY", "openrouter-key", raising=False) - async def _fake_post(payload, _headers, _timeout): + async def _fake_post(payload, _headers, _timeout, **_routing): calls.append(payload) if len(calls) == 1: return { @@ -2066,7 +2066,7 @@ async def test_summarize_openrouter_discards_partial_length_response_at_retry_ca monkeypatch.setattr(summarization.Config, "OPENROUTER_API_KEY", "openrouter-key", raising=False) monkeypatch.setenv("SCRIBER_SUMMARY_OPENROUTER_RETRY_MAX_TOKENS", "900") - async def _fake_post(_payload, _headers, _timeout): + async def _fake_post(_payload, _headers, _timeout, **_routing): return { "model": "z-ai/glm-5.3-flash-20260826", "choices": [ @@ -2093,7 +2093,7 @@ async def test_summarize_openrouter_stops_after_larger_partial_retry_without_mod monkeypatch.setattr(summarization.Config, "OPENROUTER_API_KEY", "openrouter-key", raising=False) monkeypatch.setenv("SCRIBER_SUMMARY_OPENROUTER_RETRY_MAX_TOKENS", "8192") - async def _fake_post(payload, _headers, _timeout): + async def _fake_post(payload, _headers, _timeout, **_routing): calls.append(payload) return { "model": "minimax/minimax-m3", @@ -2131,7 +2131,7 @@ async def test_summarize_openrouter_tries_alternate_model_after_larger_partial_r monkeypatch.setattr(summarization.Config, "OPENROUTER_API_KEY", "openrouter-key", raising=False) monkeypatch.setenv("SCRIBER_SUMMARY_OPENROUTER_RETRY_MAX_TOKENS", "8192") - async def _fake_post(payload, _headers, _timeout): + async def _fake_post(payload, _headers, _timeout, **_routing): calls.append(payload) if len(calls) < 3: return { @@ -2182,7 +2182,7 @@ async def test_summarize_openrouter_retries_initial_model_at_full_budget_after_o monkeypatch.setattr(summarization.Config, "OPENROUTER_API_KEY", "openrouter-key", raising=False) monkeypatch.setenv("SCRIBER_SUMMARY_OPENROUTER_RETRY_MAX_TOKENS", "8192") - async def _fake_post(payload, _headers, _timeout): + async def _fake_post(payload, _headers, _timeout, **_routing): calls.append(payload) if len(calls) == 1: model = "minimax/minimax-m3" @@ -2245,7 +2245,7 @@ async def test_meeting_analysis_tries_complete_alternate_without_scriber_token_c async def _fake_gemini(_prompt: str, _model: str, _max_output_tokens: int) -> str: raise RuntimeError("Gemini API error 503: UNAVAILABLE") - async def _fake_post(payload, _headers, _timeout): + async def _fake_post(payload, _headers, _timeout, **_routing): calls.append(payload) if len(calls) == 1: return { @@ -2421,7 +2421,7 @@ async def test_summarize_gemini_never_sends_a_scriber_output_token_limit(monkeyp monkeypatch.setattr(summarization.Config, "GOOGLE_API_KEY", "test-key") monkeypatch.setenv("SCRIBER_SUMMARY_GEMINI_THINKING_LEVEL", "high") - async def _fake_post(_session, _url, payload, *, retries): + async def _fake_post(_session, _url, payload, *, retries, **_routing): calls.append(payload) assert payload["generationConfig"]["thinkingConfig"] == {"thinkingLevel": "HIGH"} return { @@ -2447,7 +2447,7 @@ async def _fake_post(_session, _url, payload, *, retries): async def test_summarize_gemini_discards_partial_at_native_max_tokens(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(summarization.Config, "GOOGLE_API_KEY", "test-key") - async def _fake_post(_session, _url, payload, *, retries): + async def _fake_post(_session, _url, payload, *, retries, **_routing): assert "maxOutputTokens" not in payload["generationConfig"] return { "candidates": [ @@ -2551,7 +2551,7 @@ async def test_summarize_gemini_without_scriber_cap_still_discards_native_trunca calls: list[dict[str, object]] = [] monkeypatch.setattr(summarization.Config, "GOOGLE_API_KEY", "test-key") - async def _fake_post(_session, _url, payload, *, retries): + async def _fake_post(_session, _url, payload, *, retries, **_routing): calls.append(payload) return { "candidates": [ diff --git a/tests/test_summary_retry_api.py b/tests/test_summary_retry_api.py index 00da604b..ed5699f8 100644 --- a/tests/test_summary_retry_api.py +++ b/tests/test_summary_retry_api.py @@ -6,6 +6,7 @@ from aiohttp.test_utils import TestClient, TestServer from src import summarization, web_api +from src.core.provider_errors import provider_transport_error from src.data.job_store import JobStore from src.web_api import ScriberWebController, TranscriptRecord @@ -97,9 +98,11 @@ async def summarize(content: str, model: str, *, duration: str | None = None) -> @pytest.mark.asyncio +@pytest.mark.parametrize("region", [None, "eu", "us"]) async def test_summary_retry_failure_keeps_transcript_completed_and_persists_error( monkeypatch: pytest.MonkeyPatch, tmp_path, + region, ): monkeypatch.delenv("SCRIBER_SESSION_TOKEN", raising=False) controller = ScriberWebController( @@ -108,9 +111,15 @@ async def test_summary_retry_failure_keeps_transcript_completed_and_persists_err ) record = _failed_summary_record() controller._add_to_history(record) + error = ( + provider_transport_error("openrouter", "summarization", status=404, region=region) + if region + else RuntimeError("summary provider timed out") + ) + expected = str(error) if region else "Could not create the summary. Please try again." async def fail_summary(*_args, **_kwargs) -> str: - raise RuntimeError("summary provider timed out") + raise error save_state = AsyncMock() broadcast = AsyncMock() @@ -127,11 +136,11 @@ async def fail_summary(*_args, **_kwargs) -> str: await client.close() assert response.status == 500 - assert payload == {"message": "Could not create the summary. Please try again."} + assert payload == {"message": expected} assert record.status == "completed" assert record.content.startswith("This durable transcript") assert record.summary_status == "failed" - assert record.summary_error == "Could not create the summary. Please try again." + assert record.summary_error == expected assert "provider timed out" not in payload["message"] assert record.summary == "## Previous summary" assert record.summary_format == "markdown" diff --git a/tests/test_web_api_provider_audio.py b/tests/test_web_api_provider_audio.py index f2fdb8cc..a21bad84 100644 --- a/tests/test_web_api_provider_audio.py +++ b/tests/test_web_api_provider_audio.py @@ -260,6 +260,7 @@ async def test_openrouter_mai_freezes_exact_model_route_and_endpoint(monkeypatch snapshot_options = route.snapshot_draft().request_options assert route.model == "microsoft/mai-transcribe-2" + assert route.provider_region == "eu" assert route.provider_route == "audio_transcriptions" assert route.response_shape == "final_text" assert route.timestamp_mode == "estimated" @@ -267,10 +268,11 @@ async def test_openrouter_mai_freezes_exact_model_route_and_endpoint(monkeypatch assert route.provider_audio_capability_id == ("openrouter_stt:audio_transcriptions:microsoft/mai-transcribe-2") assert ( route.provider_endpoint_sha256 - == hashlib.sha256(b"https://openrouter.ai/api/v1/audio/transcriptions").hexdigest() + == hashlib.sha256(b"https://eu.openrouter.ai/api/v1/audio/transcriptions").hexdigest() ) assert route.custom_vocab == "" assert persisted["customVocabularyPresent"] is False + assert persisted["providerRegion"] == "eu" assert persisted["customVocabularyCount"] == 0 assert persisted["customVocabularySha256"] is None assert snapshot_options["customVocabularyPresent"] is False @@ -279,6 +281,45 @@ async def test_openrouter_mai_freezes_exact_model_route_and_endpoint(monkeypatch assert controller._persisted_endpoint_evidence_complete(persisted) is True +@pytest.mark.asyncio +@pytest.mark.parametrize("region", ["eu", "us", "global"]) +@pytest.mark.parametrize("workload", ["file", "youtube", "meeting"]) +async def test_openrouter_frozen_region_survives_settings_changes(monkeypatch, region, workload): + monkeypatch.setattr(Config, "OPENROUTER_REGION", region) + controller = ScriberWebController(asyncio.get_running_loop()) + route = controller._freeze_background_provider_route(workload=workload, provider="openrouter_stt", language="de") + monkeypatch.setattr(Config, "OPENROUTER_REGION", "us" if region == "eu" else "eu") + restored = controller._freeze_background_provider_route( + workload=workload, + provider="openrouter_stt", + language="de", + provider_region=route.provider_region, + provider_endpoint_sha256=route.provider_endpoint_sha256, + ) + assert restored.provider_region == region + assert restored.provider_endpoint_sha256 == route.provider_endpoint_sha256 + with pytest.raises(TranscriptPersistenceError, match="endpoint no longer matches"): + controller._freeze_background_provider_route( + workload=workload, + provider="openrouter_stt", + language="de", + provider_endpoint_sha256=route.provider_endpoint_sha256, + ) + + +@pytest.mark.asyncio +async def test_legacy_frozen_global_job_cannot_silently_escape_eu_default(monkeypatch): + monkeypatch.setattr(Config, "OPENROUTER_REGION", "eu") + controller = ScriberWebController(asyncio.get_running_loop()) + with pytest.raises(TranscriptPersistenceError, match="endpoint no longer matches"): + controller._freeze_background_provider_route( + workload="file", + provider="openrouter_stt", + language="de", + provider_endpoint_sha256=hashlib.sha256(b"https://openrouter.ai/api/v1/audio/transcriptions").hexdigest(), + ) + + @pytest.mark.asyncio @pytest.mark.parametrize("workload", ["file", "youtube", "meeting"]) async def test_explicit_legacy_azure_override_freezes_verified_route(monkeypatch, workload):