Skip to content

Commit d777e8f

Browse files
authored
Add OpenRouter summary provider
Adds OpenRouter as an AI provider for summary and source-context generation, including provider UI, headers, docs, and regression coverage.
1 parent 4b54be1 commit d777e8f

7 files changed

Lines changed: 140 additions & 28 deletions

File tree

apps/desktop/main.cjs

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,12 @@ const SUMMARY_PROVIDER_DEFAULTS = {
125125
baseURL: "https://api.openai.com/v1",
126126
apiKey: "",
127127
},
128+
openrouter: {
129+
provider: "openrouter",
130+
model: "openai/gpt-4o-mini",
131+
baseURL: "https://openrouter.ai/api/v1",
132+
apiKey: "",
133+
},
128134
ollama: {
129135
provider: "ollama",
130136
model: "",
@@ -180,7 +186,20 @@ function notesDatabasePath() {
180186
}
181187

182188
function normalizeSummaryProvider(value) {
183-
return value === "ollama" || value === "lmstudio" ? value : "openai";
189+
return value === "openrouter" || value === "ollama" || value === "lmstudio" ? value : "openai";
190+
}
191+
192+
function summaryProviderRequiresAPIKey(provider) {
193+
return provider === "openai" || provider === "openrouter";
194+
}
195+
196+
function summaryProviderDisplayName(provider) {
197+
return {
198+
openai: "OpenAI",
199+
openrouter: "OpenRouter",
200+
ollama: "Ollama",
201+
lmstudio: "LM Studio",
202+
}[provider] || "OpenAI";
184203
}
185204

186205
function builtInSummaryTemplates() {
@@ -334,7 +353,7 @@ function normalizeSummaryProviderConfig(provider, value = {}, legacy = {}) {
334353
(useLegacy ? legacy.baseURL : "") ||
335354
defaults.baseURL,
336355
).trim().replace(/\/+$/, "") || defaults.baseURL,
337-
apiKey: provider === "openai"
356+
apiKey: summaryProviderRequiresAPIKey(provider)
338357
? String(value.apiKey || value.api_key || (useLegacy ? legacy.apiKey : "") || "").trim()
339358
: String(value.apiKey || value.api_key || (useLegacy ? legacy.apiKey : "") || defaults.apiKey).trim(),
340359
};
@@ -452,11 +471,23 @@ function ollamaTagsURL(baseURL) {
452471
return `${parsed.origin}/api/tags`;
453472
}
454473

474+
function summaryProviderRequestHeaders(settings, options = {}) {
475+
const headers = {
476+
Authorization: `Bearer ${settings.apiKey || "local"}`,
477+
};
478+
if (options.includeContentType !== false) {
479+
headers["Content-Type"] = "application/json";
480+
}
481+
if (settings.provider === "openrouter") {
482+
headers["HTTP-Referer"] = "https://github.com/qyinm/MirrorNote";
483+
headers["X-Title"] = APP_DISPLAY_NAME;
484+
}
485+
return headers;
486+
}
487+
455488
async function listOpenAICompatibleModels(settings) {
456489
const data = await fetchJSON(openAICompatibleModelListURL(settings.baseURL), {
457-
headers: {
458-
Authorization: `Bearer ${settings.apiKey || "local"}`,
459-
},
490+
headers: summaryProviderRequestHeaders(settings, { includeContentType: false }),
460491
});
461492
const rawModels = Array.isArray(data?.data) ? data.data : [];
462493
return sortSummaryModels(rawModels.map(normalizeModelOption));
@@ -479,10 +510,10 @@ async function listSummaryModels(providerOverride) {
479510
? persistedSettings.summary?.model
480511
: SUMMARY_PROVIDER_DEFAULTS[provider].model,
481512
});
482-
if (provider === "openai" && !summary.apiKey) {
513+
if (summaryProviderRequiresAPIKey(provider) && !summary.apiKey) {
483514
return {
484515
models: summary.model ? [{ id: summary.model, name: summary.model }] : [],
485-
message: "Add an OpenAI API key to load available models.",
516+
message: `Add an ${summaryProviderDisplayName(provider)} API key to load available models.`,
486517
};
487518
}
488519

@@ -508,8 +539,8 @@ async function listSummaryModels(providerOverride) {
508539

509540
let requestChatCompletion = async function requestChatCompletion(summarySettings, messages, options = {}) {
510541
const settings = normalizeSummarySettings(summarySettings);
511-
if (settings.provider === "openai" && !settings.apiKey) {
512-
throw new Error("Add an OpenAI API key in Settings before generating a summary.");
542+
if (summaryProviderRequiresAPIKey(settings.provider) && !settings.apiKey) {
543+
throw new Error(`Add an ${summaryProviderDisplayName(settings.provider)} API key in Settings before generating a summary.`);
513544
}
514545

515546
const body = {
@@ -524,10 +555,7 @@ let requestChatCompletion = async function requestChatCompletion(summarySettings
524555

525556
const response = await fetch(`${settings.baseURL}/chat/completions`, {
526557
method: "POST",
527-
headers: {
528-
"Content-Type": "application/json",
529-
Authorization: `Bearer ${settings.apiKey || "local"}`,
530-
},
558+
headers: summaryProviderRequestHeaders(settings),
531559
body: JSON.stringify(body),
532560
});
533561
const text = await response.text();
@@ -928,13 +956,13 @@ function publicSummarySettings(settings) {
928956
configs[provider] = {
929957
model: summary.providerConfigs[provider].model,
930958
baseURL: summary.providerConfigs[provider].baseURL,
931-
apiKeyConfigured: provider === "openai"
959+
apiKeyConfigured: summaryProviderRequiresAPIKey(provider)
932960
? summary.providerConfigs[provider].apiKey.length > 0
933961
: summary.providerConfigs[provider].baseURL.length > 0,
934962
};
935963
return configs;
936964
}, {}),
937-
apiKeyConfigured: summary.provider === "openai" ? summary.apiKey.length > 0 : true,
965+
apiKeyConfigured: summaryProviderRequiresAPIKey(summary.provider) ? summary.apiKey.length > 0 : true,
938966
};
939967
}
940968

@@ -3209,7 +3237,7 @@ async function generateNoteSummary(noteID, templateID) {
32093237
}
32103238

32113239
const responseFormat =
3212-
summarySettings.provider === "openai"
3240+
summarySettings.provider === "openai" || summarySettings.provider === "openrouter"
32133241
? { type: "json_schema", json_schema: summaryJSONSchemaForTemplate(template) }
32143242
: null;
32153243

@@ -5056,6 +5084,7 @@ if (process.env.MIRROR_NOTE_TEST_EXPORTS === "1") {
50565084
normalizeTranscriptSegments,
50575085
parseSTTChunkLedgerJSONL,
50585086
parseTranscriptJSONL,
5087+
publicSummarySettings,
50595088
parseSummaryResultJSON,
50605089
readNote,
50615090
replaceGeneratedSummaryBlockInMarkdown,
@@ -5071,6 +5100,7 @@ if (process.env.MIRROR_NOTE_TEST_EXPORTS === "1") {
50715100
saveTranscript,
50725101
sanitizeSummaryMarkdown,
50735102
summaryJSONSchemaForTemplate,
5103+
summaryProviderRequestHeaders,
50745104
summaryTemplateById,
50755105
summaryTemplatesForSettings,
50765106
serializeTranscriptJSONL,

apps/desktop/main/native-launch.node-test.cjs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ const {
1919
noteHasCompletedTranscriptForSourceContext,
2020
parseSTTChunkLedgerJSONL,
2121
parseTranscriptJSONL,
22+
publicSummarySettings,
23+
normalizeSummarySettings,
24+
summaryProviderRequestHeaders,
2225
serializeTranscriptJSONL,
2326
sourceContextStatusFromMetadata,
2427
} = require("../main.cjs");
@@ -159,6 +162,31 @@ assert.equal(noteHasCompletedTranscriptForSourceContext({
159162
}), false);
160163
assert.equal(normalizeSourceContextStatus({ state: "failed", detail: "Add key.", retryable: true }).retryable, true);
161164

165+
const openRouterSummarySettings = normalizeSummarySettings({
166+
provider: "openrouter",
167+
providerConfigs: {
168+
openrouter: {
169+
model: "anthropic/claude-sonnet-4.5",
170+
baseURL: "https://openrouter.ai/api/v1/",
171+
apiKey: "sk-or-test",
172+
},
173+
},
174+
});
175+
assert.equal(openRouterSummarySettings.provider, "openrouter");
176+
assert.equal(openRouterSummarySettings.model, "anthropic/claude-sonnet-4.5");
177+
assert.equal(openRouterSummarySettings.baseURL, "https://openrouter.ai/api/v1");
178+
assert.equal(openRouterSummarySettings.apiKey, "sk-or-test");
179+
const publicOpenRouterSummarySettings = publicSummarySettings({ summary: openRouterSummarySettings });
180+
assert.equal(publicOpenRouterSummarySettings.apiKeyConfigured, true);
181+
assert.equal(publicOpenRouterSummarySettings.providerConfigs.openrouter.apiKeyConfigured, true);
182+
assert.equal(publicOpenRouterSummarySettings.providerConfigs.openrouter.apiKey, undefined);
183+
assert.deepEqual(summaryProviderRequestHeaders(openRouterSummarySettings), {
184+
"Content-Type": "application/json",
185+
Authorization: "Bearer sk-or-test",
186+
"HTTP-Referer": "https://github.com/qyinm/MirrorNote",
187+
"X-Title": "MirrorNote",
188+
});
189+
162190
const contextTempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "mirrornote-source-context-"));
163191
const generatedContextPaths = contextPacketPaths(contextTempDirectory);
164192
fs.mkdirSync(generatedContextPaths.contextDirectoryPath, { recursive: true });

apps/desktop/src/components/ProviderBrandIcon.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export function ProviderBrandIcon({ provider, ...props }: ProviderBrandIconProps
1010
<svg viewBox="0 0 24 24" fill="currentColor" fillRule="evenodd" xmlns="http://www.w3.org/2000/svg" {...props}>
1111
{provider === "openai" ? (
1212
<path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z" />
13+
) : provider === "openrouter" ? (
14+
<path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z" />
1315
) : provider === "ollama" ? (
1416
<path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z" />
1517
) : (

apps/desktop/src/components/SettingsPanel.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ import { ProviderBrandIcon } from "./ProviderBrandIcon";
1515

1616
const SUMMARY_PROVIDER_DEFAULTS = {
1717
openai: { model: "gpt-4o-mini", baseURL: "https://api.openai.com/v1" },
18+
openrouter: { model: "openai/gpt-4o-mini", baseURL: "https://openrouter.ai/api/v1" },
1819
ollama: { model: "", baseURL: "http://localhost:11434/v1" },
1920
lmstudio: { model: "", baseURL: "http://localhost:1234/v1" },
2021
} as const;
2122

2223
const SUMMARY_PROVIDER_OPTIONS: { value: SummaryProvider; label: string; badge?: string }[] = [
2324
{ value: "openai", label: "OpenAI API" },
25+
{ value: "openrouter", label: "OpenRouter" },
2426
{ value: "ollama", label: "Ollama", badge: "Local" },
2527
{ value: "lmstudio", label: "LM Studio", badge: "Local" },
2628
];
@@ -211,10 +213,10 @@ export function SettingsPanel({
211213

212214
function isSummaryProviderReady(provider: SummaryProvider) {
213215
const providerConfig = summaryProviderConfig(provider);
214-
if (provider === "openai") {
216+
if (summaryProviderRequiresAPIKey(provider)) {
215217
return Boolean(
216218
providerConfig.apiKeyConfigured ||
217-
summaryProviderConfigDrafts.openai.apiKey.trim().length > 0,
219+
summaryProviderConfigDrafts[provider]?.apiKey.trim().length > 0,
218220
);
219221
}
220222
const draftBaseURL = summaryProviderConfigDrafts[provider]?.baseURL;
@@ -574,13 +576,13 @@ export function SettingsPanel({
574576

575577
{expanded ? (
576578
<div className="settings-provider-body">
577-
{provider.value === "openai" ? (
579+
{summaryProviderRequiresAPIKey(provider.value) ? (
578580
<label className="settings-provider-field">
579581
<span>{t("settings.intelligence.apiKey")}</span>
580582
<input
581583
type="password"
582584
value={providerDraft.apiKey}
583-
placeholder={providerConfig.apiKeyConfigured ? t("common.saved") : "sk-..."}
585+
placeholder={providerConfig.apiKeyConfigured ? t("common.saved") : summaryAPIKeyPlaceholder(provider.value)}
584586
onFocus={() => setFocusedSummaryField("apiKey")}
585587
onChange={(event) => {
586588
const nextAPIKey = event.currentTarget.value;
@@ -761,11 +763,20 @@ function ensureDefaultAudioDevice(
761763
function defaultSummaryProviderConfigDrafts(): SummaryProviderConfigDraft {
762764
return {
763765
openai: { baseURL: SUMMARY_PROVIDER_DEFAULTS.openai.baseURL, apiKey: "" },
766+
openrouter: { baseURL: SUMMARY_PROVIDER_DEFAULTS.openrouter.baseURL, apiKey: "" },
764767
ollama: { baseURL: SUMMARY_PROVIDER_DEFAULTS.ollama.baseURL, apiKey: "" },
765768
lmstudio: { baseURL: SUMMARY_PROVIDER_DEFAULTS.lmstudio.baseURL, apiKey: "" },
766769
};
767770
}
768771

772+
function summaryProviderRequiresAPIKey(provider: SummaryProvider) {
773+
return provider === "openai" || provider === "openrouter";
774+
}
775+
776+
function summaryAPIKeyPlaceholder(provider: SummaryProvider) {
777+
return provider === "openrouter" ? "sk-or-..." : "sk-...";
778+
}
779+
769780
function formatAudioDeviceLabel(device: AudioDeviceOption, defaultLabel: string) {
770781
const suffixes = [
771782
device.isDefault ? defaultLabel : "",

apps/desktop/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export type STTChunkStatus = "queued" | "running" | "completed" | "failed";
1313
export type SourceContextState = "idle" | "generating" | "ready" | "failed";
1414
export type SpeakerLabelingMode = "local" | "disabled";
1515
export type AppLanguage = "en" | "ko";
16-
export type SummaryProvider = "openai" | "ollama" | "lmstudio";
16+
export type SummaryProvider = "openai" | "openrouter" | "ollama" | "lmstudio";
1717
export type SummaryTemplateKind = "built-in" | "custom";
1818

1919
export interface SummaryTemplateSection {

docs/summary-generation.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ Use an OpenAI-compatible chat completions endpoint.
9191
| Provider | Base URL | API key | Model |
9292
| --- | --- | --- | --- |
9393
| OpenAI | `https://api.openai.com/v1` | Required OpenAI API key | `gpt-4o-mini` by default |
94+
| OpenRouter | `https://openrouter.ai/api/v1` | Required OpenRouter API key | `openai/gpt-4o-mini` by default |
9495
| Ollama | `http://localhost:11434/v1` | Placeholder value | Pulled local model, for example `llama3.1:8b` |
9596
| LM Studio | `http://localhost:1234/v1` | Placeholder value | Loaded model identifier |
9697

0 commit comments

Comments
 (0)