diff --git a/apps/desktop/main.cjs b/apps/desktop/main.cjs index 6f9aa6d..a0c732f 100644 --- a/apps/desktop/main.cjs +++ b/apps/desktop/main.cjs @@ -125,6 +125,12 @@ const SUMMARY_PROVIDER_DEFAULTS = { baseURL: "https://api.openai.com/v1", apiKey: "", }, + openrouter: { + provider: "openrouter", + model: "openai/gpt-4o-mini", + baseURL: "https://openrouter.ai/api/v1", + apiKey: "", + }, ollama: { provider: "ollama", model: "", @@ -180,7 +186,20 @@ function notesDatabasePath() { } function normalizeSummaryProvider(value) { - return value === "ollama" || value === "lmstudio" ? value : "openai"; + return value === "openrouter" || value === "ollama" || value === "lmstudio" ? value : "openai"; +} + +function summaryProviderRequiresAPIKey(provider) { + return provider === "openai" || provider === "openrouter"; +} + +function summaryProviderDisplayName(provider) { + return { + openai: "OpenAI", + openrouter: "OpenRouter", + ollama: "Ollama", + lmstudio: "LM Studio", + }[provider] || "OpenAI"; } function builtInSummaryTemplates() { @@ -334,7 +353,7 @@ function normalizeSummaryProviderConfig(provider, value = {}, legacy = {}) { (useLegacy ? legacy.baseURL : "") || defaults.baseURL, ).trim().replace(/\/+$/, "") || defaults.baseURL, - apiKey: provider === "openai" + apiKey: summaryProviderRequiresAPIKey(provider) ? String(value.apiKey || value.api_key || (useLegacy ? legacy.apiKey : "") || "").trim() : String(value.apiKey || value.api_key || (useLegacy ? legacy.apiKey : "") || defaults.apiKey).trim(), }; @@ -452,11 +471,23 @@ function ollamaTagsURL(baseURL) { return `${parsed.origin}/api/tags`; } +function summaryProviderRequestHeaders(settings, options = {}) { + const headers = { + Authorization: `Bearer ${settings.apiKey || "local"}`, + }; + if (options.includeContentType !== false) { + headers["Content-Type"] = "application/json"; + } + if (settings.provider === "openrouter") { + headers["HTTP-Referer"] = "https://github.com/qyinm/MirrorNote"; + headers["X-Title"] = APP_DISPLAY_NAME; + } + return headers; +} + async function listOpenAICompatibleModels(settings) { const data = await fetchJSON(openAICompatibleModelListURL(settings.baseURL), { - headers: { - Authorization: `Bearer ${settings.apiKey || "local"}`, - }, + headers: summaryProviderRequestHeaders(settings, { includeContentType: false }), }); const rawModels = Array.isArray(data?.data) ? data.data : []; return sortSummaryModels(rawModels.map(normalizeModelOption)); @@ -479,10 +510,10 @@ async function listSummaryModels(providerOverride) { ? persistedSettings.summary?.model : SUMMARY_PROVIDER_DEFAULTS[provider].model, }); - if (provider === "openai" && !summary.apiKey) { + if (summaryProviderRequiresAPIKey(provider) && !summary.apiKey) { return { models: summary.model ? [{ id: summary.model, name: summary.model }] : [], - message: "Add an OpenAI API key to load available models.", + message: `Add an ${summaryProviderDisplayName(provider)} API key to load available models.`, }; } @@ -508,8 +539,8 @@ async function listSummaryModels(providerOverride) { let requestChatCompletion = async function requestChatCompletion(summarySettings, messages, options = {}) { const settings = normalizeSummarySettings(summarySettings); - if (settings.provider === "openai" && !settings.apiKey) { - throw new Error("Add an OpenAI API key in Settings before generating a summary."); + if (summaryProviderRequiresAPIKey(settings.provider) && !settings.apiKey) { + throw new Error(`Add an ${summaryProviderDisplayName(settings.provider)} API key in Settings before generating a summary.`); } const body = { @@ -524,10 +555,7 @@ let requestChatCompletion = async function requestChatCompletion(summarySettings const response = await fetch(`${settings.baseURL}/chat/completions`, { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${settings.apiKey || "local"}`, - }, + headers: summaryProviderRequestHeaders(settings), body: JSON.stringify(body), }); const text = await response.text(); @@ -928,13 +956,13 @@ function publicSummarySettings(settings) { configs[provider] = { model: summary.providerConfigs[provider].model, baseURL: summary.providerConfigs[provider].baseURL, - apiKeyConfigured: provider === "openai" + apiKeyConfigured: summaryProviderRequiresAPIKey(provider) ? summary.providerConfigs[provider].apiKey.length > 0 : summary.providerConfigs[provider].baseURL.length > 0, }; return configs; }, {}), - apiKeyConfigured: summary.provider === "openai" ? summary.apiKey.length > 0 : true, + apiKeyConfigured: summaryProviderRequiresAPIKey(summary.provider) ? summary.apiKey.length > 0 : true, }; } @@ -3209,7 +3237,7 @@ async function generateNoteSummary(noteID, templateID) { } const responseFormat = - summarySettings.provider === "openai" + summarySettings.provider === "openai" || summarySettings.provider === "openrouter" ? { type: "json_schema", json_schema: summaryJSONSchemaForTemplate(template) } : null; @@ -5056,6 +5084,7 @@ if (process.env.MIRROR_NOTE_TEST_EXPORTS === "1") { normalizeTranscriptSegments, parseSTTChunkLedgerJSONL, parseTranscriptJSONL, + publicSummarySettings, parseSummaryResultJSON, readNote, replaceGeneratedSummaryBlockInMarkdown, @@ -5071,6 +5100,7 @@ if (process.env.MIRROR_NOTE_TEST_EXPORTS === "1") { saveTranscript, sanitizeSummaryMarkdown, summaryJSONSchemaForTemplate, + summaryProviderRequestHeaders, summaryTemplateById, summaryTemplatesForSettings, serializeTranscriptJSONL, diff --git a/apps/desktop/main/native-launch.node-test.cjs b/apps/desktop/main/native-launch.node-test.cjs index c0794d8..4287ae7 100644 --- a/apps/desktop/main/native-launch.node-test.cjs +++ b/apps/desktop/main/native-launch.node-test.cjs @@ -19,6 +19,9 @@ const { noteHasCompletedTranscriptForSourceContext, parseSTTChunkLedgerJSONL, parseTranscriptJSONL, + publicSummarySettings, + normalizeSummarySettings, + summaryProviderRequestHeaders, serializeTranscriptJSONL, sourceContextStatusFromMetadata, } = require("../main.cjs"); @@ -159,6 +162,31 @@ assert.equal(noteHasCompletedTranscriptForSourceContext({ }), false); assert.equal(normalizeSourceContextStatus({ state: "failed", detail: "Add key.", retryable: true }).retryable, true); +const openRouterSummarySettings = normalizeSummarySettings({ + provider: "openrouter", + providerConfigs: { + openrouter: { + model: "anthropic/claude-sonnet-4.5", + baseURL: "https://openrouter.ai/api/v1/", + apiKey: "sk-or-test", + }, + }, +}); +assert.equal(openRouterSummarySettings.provider, "openrouter"); +assert.equal(openRouterSummarySettings.model, "anthropic/claude-sonnet-4.5"); +assert.equal(openRouterSummarySettings.baseURL, "https://openrouter.ai/api/v1"); +assert.equal(openRouterSummarySettings.apiKey, "sk-or-test"); +const publicOpenRouterSummarySettings = publicSummarySettings({ summary: openRouterSummarySettings }); +assert.equal(publicOpenRouterSummarySettings.apiKeyConfigured, true); +assert.equal(publicOpenRouterSummarySettings.providerConfigs.openrouter.apiKeyConfigured, true); +assert.equal(publicOpenRouterSummarySettings.providerConfigs.openrouter.apiKey, undefined); +assert.deepEqual(summaryProviderRequestHeaders(openRouterSummarySettings), { + "Content-Type": "application/json", + Authorization: "Bearer sk-or-test", + "HTTP-Referer": "https://github.com/qyinm/MirrorNote", + "X-Title": "MirrorNote", +}); + const contextTempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "mirrornote-source-context-")); const generatedContextPaths = contextPacketPaths(contextTempDirectory); fs.mkdirSync(generatedContextPaths.contextDirectoryPath, { recursive: true }); diff --git a/apps/desktop/src/components/ProviderBrandIcon.tsx b/apps/desktop/src/components/ProviderBrandIcon.tsx index 323c7a8..7de4f1c 100644 --- a/apps/desktop/src/components/ProviderBrandIcon.tsx +++ b/apps/desktop/src/components/ProviderBrandIcon.tsx @@ -10,6 +10,8 @@ export function ProviderBrandIcon({ provider, ...props }: ProviderBrandIconProps {provider === "openai" ? ( + ) : provider === "openrouter" ? ( + ) : provider === "ollama" ? ( ) : ( diff --git a/apps/desktop/src/components/SettingsPanel.tsx b/apps/desktop/src/components/SettingsPanel.tsx index 1efe50c..ebc95bc 100644 --- a/apps/desktop/src/components/SettingsPanel.tsx +++ b/apps/desktop/src/components/SettingsPanel.tsx @@ -15,12 +15,14 @@ import { ProviderBrandIcon } from "./ProviderBrandIcon"; const SUMMARY_PROVIDER_DEFAULTS = { openai: { model: "gpt-4o-mini", baseURL: "https://api.openai.com/v1" }, + openrouter: { model: "openai/gpt-4o-mini", baseURL: "https://openrouter.ai/api/v1" }, ollama: { model: "", baseURL: "http://localhost:11434/v1" }, lmstudio: { model: "", baseURL: "http://localhost:1234/v1" }, } as const; const SUMMARY_PROVIDER_OPTIONS: { value: SummaryProvider; label: string; badge?: string }[] = [ { value: "openai", label: "OpenAI API" }, + { value: "openrouter", label: "OpenRouter" }, { value: "ollama", label: "Ollama", badge: "Local" }, { value: "lmstudio", label: "LM Studio", badge: "Local" }, ]; @@ -211,10 +213,10 @@ export function SettingsPanel({ function isSummaryProviderReady(provider: SummaryProvider) { const providerConfig = summaryProviderConfig(provider); - if (provider === "openai") { + if (summaryProviderRequiresAPIKey(provider)) { return Boolean( providerConfig.apiKeyConfigured || - summaryProviderConfigDrafts.openai.apiKey.trim().length > 0, + summaryProviderConfigDrafts[provider]?.apiKey.trim().length > 0, ); } const draftBaseURL = summaryProviderConfigDrafts[provider]?.baseURL; @@ -574,13 +576,13 @@ export function SettingsPanel({ {expanded ? (
- {provider.value === "openai" ? ( + {summaryProviderRequiresAPIKey(provider.value) ? (