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):