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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<LocaleProvider>
<OpenRouterRegionPicker value="eu" onValueChange={change} />
</LocaleProvider>,
);
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(
<LocaleProvider>
<OpenRouterRegionPicker value="us" onValueChange={vi.fn()} />
</LocaleProvider>,
);
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();
});
});
42 changes: 42 additions & 0 deletions Frontend/client/src/components/settings/OpenRouterRegionPicker.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-2.5">
<label htmlFor={id} className="text-[13px] font-semibold text-slate-950 dark:text-slate-100">
{t("OpenRouter data processing region")}
</label>
<select
id={id}
value={value}
aria-describedby={`${id}-scope ${id}-availability`}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-[12px] outline-none focus-visible:ring-2 focus-visible:ring-ring"
onChange={(event) => onValueChange(event.target.value as OpenRouterRegion)}
>
<option value="eu">{t("European Union (default)")}</option>
<option value="us">{t("United States")}</option>
<option value="global">{t("Global (no region restriction)")}</option>
</select>
<p id={`${id}-scope`} className="text-[12px] leading-4 text-slate-600 dark:text-slate-400">
{t(
"Applies to OpenRouter transcription, summaries, meeting analysis, and cloud cleanup, including OpenRouter fallbacks. Other providers keep their own settings.",
)}
</p>
<p id={`${id}-availability`} className="text-[12px] leading-4 text-slate-600 dark:text-slate-400">
{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.",
)}
</p>
</div>
);
}
23 changes: 19 additions & 4 deletions Frontend/client/src/i18n/translations/de/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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":
Expand Down
3 changes: 3 additions & 0 deletions Frontend/client/src/lib/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, string>;
Expand Down Expand Up @@ -1153,6 +1155,7 @@ export interface SettingsUpdatePayload {
defaultSttService?: string;
sonioxMode?: "realtime" | "async";
sonioxRegion?: "us" | "eu";
openrouterRegion?: OpenRouterRegion;
sonioxAsyncModel?: string;
language?: string;
micDevice?: string;
Expand Down
12 changes: 12 additions & 0 deletions Frontend/client/src/lib/meeting-error-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
19 changes: 13 additions & 6 deletions Frontend/client/src/lib/meeting-error-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
};
}
24 changes: 18 additions & 6 deletions Frontend/client/src/pages/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -86,6 +87,7 @@ import type {
OnnxModelInfo,
OnnxModelsResponse,
OverlayVisualizerStyle,
OpenRouterRegion,
OutlookCalendarStatus,
OutlookCalendarSyncResponse,
PostProcessingEngine,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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<OpenRouterRegion>("eu");
const [modulateKey, setModulateKey] = useState("");
const [mistralKey, setMistralKey] = useState("");
const [smallestKey, setSmallestKey] = useState("");
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -3128,6 +3132,7 @@ export default function Settings() {
await updateSettings({
apiKeys,
...(provider === "Soniox" ? { sonioxRegion } : {}),
...(provider === "OpenRouter" ? { openrouterRegion } : {}),
});

const credentialReady = (() => {
Expand Down Expand Up @@ -6020,9 +6025,7 @@ export default function Settings() {
</div>
<SettingLine
label={t("Clean up meeting audio")}
description={t(
"Reduces speaker echo, background noise, and reverberation locally on your computer.",
)}
description={t("Reduces speaker echo, background noise, and reverberation locally on your computer.")}
>
<Switch
checked={meetingAecEnabled}
Expand Down Expand Up @@ -6813,10 +6816,19 @@ export default function Settings() {
saved={savedKeys.OpenRouter === true}
onSave={() => 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")}
/>
>
<OpenRouterRegionPicker
value={openrouterRegion}
onValueChange={(region) => {
setOpenrouterRegion(region);
setSavedKeys((prev) => ({ ...prev, OpenRouter: false }));
setCredentialReadyKeys((prev) => ({ ...prev, OpenRouter: false }));
}}
/>
</ApiCredentialRow>
<ApiCredentialRow
provider="Cerebras"
icon="cerebras"
Expand Down
Loading
Loading