Unify AI provider selection across all features

Every LLM-backed feature (Post-Processing, Proofreading, Title
Generation, Property Filler) now reads from one CHAT_PROVIDERS
registry instead of 5 hand-duplicated provider lists, so they always
offer the exact same providers in the exact same order.

- Add Groq as a 5th chat-completion provider (was transcription-only
  despite its OpenAI-compatible API and an already-collected key).
- Provider dropdowns disable options with no API key configured
  (shown as e.g. "Anthropic (no API key)") instead of silently
  failing at runtime when picked.
- Collapse 3 byte-for-byte duplicated resolveProvider() methods
  (Proofreader, TitleGenerator, PropertyFiller) plus AudioHandler's
  getPostProcessingApiKey() into one resolveChatProvider() helper
  (src/ProviderResolver.ts).
- Fix Custom provider: previously Proofreading/Title/Property silently
  reused Post-Processing's postProcessingUrl with no visible field to
  set it. Renamed to a shared customApiUrl field with its own UI
  (createCustomProviderFields), now exposed on every tab that offers
  Custom, plus rows on the API Keys tab. Migrates old data.json values
  automatically.
- Update README settings reference and getting-started sections to
  match.

No behavior change for existing single-provider setups; migration
handles the postProcessingUrl -> customApiUrl rename transparently.
This commit is contained in:
2026-09-07 17:14:33 -04:00
parent 3abd9d9b3b
commit 9b7ec36828
9 changed files with 270 additions and 207 deletions
+16 -6
View File
@@ -75,11 +75,15 @@ Open **Settings** → **NibbleAI** and add at least one API key:
| Key | Used For | | Key | Used For |
|-----|----------| |-----|----------|
| **Transcription API Key** | Whisper transcription (OpenAI, Groq, Azure) | | **Groq API Key** | Whisper transcription, and as a chat provider for proofreading/title/property/post-processing |
| **OpenAI API Key** | Proofreading, title generation, property filling, post-processing *(recommended: OpenRouter)* | | **OpenAI API Key** | Whisper transcription, and as a chat provider for proofreading/title/property/post-processing |
| **Anthropic API Key** | Post-processing with Claude models | | **Anthropic API Key** | Claude models for proofreading/title/property/post-processing |
| **OpenRouter API Key** | Access to hundreds of models for proofreading/title/property/post-processing |
| **Custom API Key + URL** | Any OpenAI-compatible endpoint (self-hosted, Azure, etc.) |
> **Tip:** For proofreading, title generation, and property filling, an [OpenRouter](https://openrouter.ai/keys) API key with a free model like `google/gemini-2.5-flash-lite` costs nothing and works great. Every LLM-backed feature (Proofreading, Title Generation, Property Filler, Post-Processing) shows the same provider dropdown — pick any provider that has a key configured above.
> **Tip:** An [OpenRouter](https://openrouter.ai/keys) API key with a free model like `google/gemini-2.5-flash-lite` costs nothing and works great across all four AI features.
### 2. Try the Features ### 2. Try the Features
@@ -172,13 +176,14 @@ Securely stored in your system keychain via Obsidian's SecretStorage. Never writ
| Setting | Default | Description | | Setting | Default | Description |
|---------|---------|-------------| |---------|---------|-------------|
| Enable | Off | Clean up transcripts with an LLM | | Enable | Off | Clean up transcripts with an LLM |
| Provider | Anthropic | Anthropic, OpenAI, or Custom | | Provider | Anthropic | Any configured provider: Anthropic, OpenAI, Groq, OpenRouter, or Custom |
| Model | `claude-sonnet-4-20250514` | LLM for cleanup | | Model | `claude-sonnet-4-20250514` | LLM for cleanup |
| Auto-generate title | Off | Create filenames from content | | Auto-generate title | Off | Create filenames from content |
### Proofreading ### Proofreading
| Setting | Default | Description | | Setting | Default | Description |
|---------|---------|-------------| |---------|---------|-------------|
| Provider | OpenRouter | Any configured provider: Anthropic, OpenAI, Groq, OpenRouter, or Custom |
| Model | `openai/gpt-4o-mini` | Model for proofreading | | Model | `openai/gpt-4o-mini` | Model for proofreading |
| Temperature | `0.2` | Lower = more consistent | | Temperature | `0.2` | Lower = more consistent |
| Max tokens | `2000` | Maximum response length | | Max tokens | `2000` | Maximum response length |
@@ -187,12 +192,16 @@ Securely stored in your system keychain via Obsidian's SecretStorage. Never writ
### Title Generation ### Title Generation
| Setting | Default | Description | | Setting | Default | Description |
|---------|---------|-------------| |---------|---------|-------------|
| Provider | OpenRouter | Any configured provider: Anthropic, OpenAI, Groq, OpenRouter, or Custom |
| Model | `openai/gpt-4o-mini` | Model for generating titles |
| Mode | Rename file | Rename file or set frontmatter property | | Mode | Rename file | Rename file or set frontmatter property |
| Max title length | 8 words | Maximum words in generated title | | Max title length | 8 words | Maximum words in generated title |
### Property Filler ### Property Filler
| Setting | Default | Description | | Setting | Default | Description |
|---------|---------|-------------| |---------|---------|-------------|
| Provider | OpenRouter | Any configured provider: Anthropic, OpenAI, Groq, OpenRouter, or Custom |
| Model | `openai/gpt-4o-mini` | Model for generating property values |
| Custom prompt | *(built-in)* | Template with `{property}` placeholder | | Custom prompt | *(built-in)* | Template with `{property}` placeholder |
| Overwrite existing | Off | Replace existing property values | | Overwrite existing | Off | Replace existing property values |
@@ -205,7 +214,8 @@ NibbleAI/
├── main.ts # Plugin entry point, commands, events ├── main.ts # Plugin entry point, commands, events
├── src/ ├── src/
│ ├── SettingsManager.ts # Settings + SecretStorage management │ ├── SettingsManager.ts # Settings + SecretStorage management
│ ├── AIService.ts # Unified AI client (OpenAI + Anthropic) │ ├── AIService.ts # Unified AI client (OpenAI-compatible + Anthropic-native)
│ ├── ProviderResolver.ts # Shared provider → API key/endpoint resolution
│ ├── ModelBrowser.ts # OpenRouter model suggest modal │ ├── ModelBrowser.ts # OpenRouter model suggest modal
│ ├── PostProcessor.ts # LLM post-processing for transcripts │ ├── PostProcessor.ts # LLM post-processing for transcripts
│ ├── NibbleAISettingsTab.ts # Settings UI │ ├── NibbleAISettingsTab.ts # Settings UI
+1 -1
View File
@@ -5,7 +5,7 @@ export interface ChatCompletionOptions {
apiKey: string; apiKey: string;
model: string; model: string;
endpoint?: string; endpoint?: string;
provider?: "anthropic" | "openai" | "openrouter" | "custom"; provider?: "anthropic" | "openai" | "openrouter" | "groq" | "custom";
temperature?: number; temperature?: number;
maxTokens?: number; maxTokens?: number;
} }
+144 -82
View File
@@ -3,9 +3,8 @@ import { App, PluginSettingTab, Setting, setIcon } from "obsidian";
import { import {
SettingsManager, SettingsManager,
PostProcessingProvider, PostProcessingProvider,
AIProvider,
TranscriptionProvider, TranscriptionProvider,
PROVIDER_URLS, CHAT_PROVIDERS,
PROVIDER_DEFAULT_MODELS, PROVIDER_DEFAULT_MODELS,
} from "./SettingsManager"; } from "./SettingsManager";
import { ModelSuggestModal } from "./ModelBrowser"; import { ModelSuggestModal } from "./ModelBrowser";
@@ -106,10 +105,14 @@ export class NibbleAISettingsTab extends PluginSettingTab {
text: "API keys are stored securely in your system keychain.", text: "API keys are stored securely in your system keychain.",
cls: "setting-item-description", cls: "setting-item-description",
}); });
containerEl.createEl("p", {
text: "Each AI feature below (Proofreading, Title Generation, Property Filler, Post-Processing) lets you pick which of these providers to use, provided its key is set here.",
cls: "setting-item-description",
});
this.createApiKeySetting(containerEl, this.createApiKeySetting(containerEl,
"Groq API Key", "Groq API Key",
"For Whisper transcription via Groq (groq.com)", "For Whisper transcription and Groq chat models (groq.com)",
"gsk_...", "gsk_...",
this.plugin.settings.groqApiKey, this.plugin.settings.groqApiKey,
async (value) => { async (value) => {
@@ -139,7 +142,7 @@ export class NibbleAISettingsTab extends PluginSettingTab {
); );
this.createApiKeySetting(containerEl, this.createApiKeySetting(containerEl,
"OpenRouter API Key", "OpenRouter API Key",
"For OpenRouter models — get one at openrouter.ai/keys. Used for proofreading, title generation, property filling, and post-processing.", "For OpenRouter models — get one at openrouter.ai/keys.",
"sk-or-v1-...", "sk-or-v1-...",
this.plugin.settings.openRouterApiKey, this.plugin.settings.openRouterApiKey,
async (value) => { async (value) => {
@@ -147,6 +150,26 @@ export class NibbleAISettingsTab extends PluginSettingTab {
await this.save(); await this.save();
} }
); );
this.createApiKeySetting(containerEl,
"Custom API Key",
"For a self-hosted or OpenAI-compatible endpoint not listed above",
"sk-...xxxx",
this.plugin.settings.customApiKey,
async (value) => {
this.plugin.settings.customApiKey = value;
await this.save();
}
);
this.createTextSetting(containerEl,
"Custom API URL",
"Chat completions endpoint for the custom provider",
"https://api.example.com/v1/chat/completions",
this.plugin.settings.customApiUrl,
async (value) => {
this.plugin.settings.customApiUrl = value;
await this.save();
}
);
} }
// ── Transcription Tab ── // ── Transcription Tab ──
@@ -328,29 +351,22 @@ export class NibbleAISettingsTab extends PluginSettingTab {
); );
if (this.plugin.settings.postProcessing) { if (this.plugin.settings.postProcessing) {
this.createPostProcessingProviderSetting(containerEl); this.createChatProviderSetting(containerEl,
"Select the LLM provider for cleaning up transcriptions",
this.plugin.settings.postProcessingProvider,
async (provider) => {
this.plugin.settings.postProcessingProvider = provider;
if (provider !== "custom") {
this.plugin.settings.postProcessingModel =
PROVIDER_DEFAULT_MODELS[provider];
}
await this.save();
this.display();
}
);
if (this.plugin.settings.postProcessingProvider === "custom") { if (this.plugin.settings.postProcessingProvider === "custom") {
this.createTextSetting(containerEl, this.createCustomProviderFields(containerEl);
"API URL",
"Custom endpoint for chat completions",
"https://api.example.com/v1/chat/completions",
this.plugin.settings.postProcessingUrl,
async (value) => {
this.plugin.settings.postProcessingUrl = value;
await this.save();
}
);
this.createApiKeySetting(containerEl,
"API Key",
"API key for the custom endpoint",
"sk-...xxxx",
this.plugin.settings.customApiKey,
async (value) => {
this.plugin.settings.customApiKey = value;
await this.save();
}
);
} }
this.createModelSetting(containerEl, this.createModelSetting(containerEl,
@@ -424,15 +440,23 @@ export class NibbleAISettingsTab extends PluginSettingTab {
cls: "setting-item-description", cls: "setting-item-description",
}); });
this.createProviderSetting(containerEl, this.createChatProviderSetting(containerEl,
"Provider",
"Select the AI provider for proofreading", "Select the AI provider for proofreading",
this.plugin.settings.proofreadingProvider, this.plugin.settings.proofreadingProvider,
async (value) => { async (provider) => {
this.plugin.settings.proofreadingProvider = value as AIProvider; this.plugin.settings.proofreadingProvider = provider;
if (provider !== "custom") {
this.plugin.settings.proofreadingModel =
PROVIDER_DEFAULT_MODELS[provider];
}
await this.save(); await this.save();
this.display();
} }
); );
if (this.plugin.settings.proofreadingProvider === "custom") {
this.createCustomProviderFields(containerEl);
}
this.createModelSetting(containerEl, this.createModelSetting(containerEl,
"Model", "Model",
"Model for fixing punctuation and grammar", "Model for fixing punctuation and grammar",
@@ -508,15 +532,23 @@ export class NibbleAISettingsTab extends PluginSettingTab {
cls: "setting-item-description", cls: "setting-item-description",
}); });
this.createProviderSetting(containerEl, this.createChatProviderSetting(containerEl,
"Provider",
"Select the AI provider for title generation", "Select the AI provider for title generation",
this.plugin.settings.titleGenProvider, this.plugin.settings.titleGenProvider,
async (value) => { async (provider) => {
this.plugin.settings.titleGenProvider = value as AIProvider; this.plugin.settings.titleGenProvider = provider;
if (provider !== "custom") {
this.plugin.settings.titleGenModel =
PROVIDER_DEFAULT_MODELS[provider];
}
await this.save(); await this.save();
this.display();
} }
); );
if (this.plugin.settings.titleGenProvider === "custom") {
this.createCustomProviderFields(containerEl);
}
this.createModelSetting(containerEl, this.createModelSetting(containerEl,
"Model", "Model",
"Model for generating titles", "Model for generating titles",
@@ -577,15 +609,23 @@ export class NibbleAISettingsTab extends PluginSettingTab {
cls: "setting-item-description", cls: "setting-item-description",
}); });
this.createProviderSetting(containerEl, this.createChatProviderSetting(containerEl,
"Provider",
"Select the AI provider for property generation", "Select the AI provider for property generation",
this.plugin.settings.propertyGenProvider, this.plugin.settings.propertyGenProvider,
async (value) => { async (provider) => {
this.plugin.settings.propertyGenProvider = value as AIProvider; this.plugin.settings.propertyGenProvider = provider;
if (provider !== "custom") {
this.plugin.settings.propertyGenModel =
PROVIDER_DEFAULT_MODELS[provider];
}
await this.save(); await this.save();
this.display();
} }
); );
if (this.plugin.settings.propertyGenProvider === "custom") {
this.createCustomProviderFields(containerEl);
}
this.createModelSetting(containerEl, this.createModelSetting(containerEl,
"Model", "Model",
"Model for generating property values", "Model for generating property values",
@@ -748,30 +788,53 @@ export class NibbleAISettingsTab extends PluginSettingTab {
infoEl.style.flex = "1 1 100%"; infoEl.style.flex = "1 1 100%";
} }
private createProviderSetting( /**
* Shared provider dropdown for every LLM-backed feature (Post-Processing,
* Proofreading, Title Generation, Property Filler). Reads from the single
* CHAT_PROVIDERS registry so all four features always offer the exact
* same providers, and disables any provider whose API key isn't set yet.
*/
private createChatProviderSetting(
containerEl: HTMLElement, containerEl: HTMLElement,
name: string,
desc: string, desc: string,
value: string, currentProvider: PostProcessingProvider,
onChange: (value: string) => Promise<void> onChange: (provider: PostProcessingProvider) => Promise<void>
): void { ): void {
const providers: Record<string, string> = { const configured = new Set(
openrouter: "OpenRouter", CHAT_PROVIDERS.filter((p) => !!this.plugin.settings[p.apiKeyField]).map(
openai: "OpenAI", (p) => p.id
anthropic: "Anthropic", )
custom: "Custom", );
};
new Setting(containerEl) new Setting(containerEl)
.setName(name) .setName("Provider")
.setDesc(desc) .setDesc(desc)
.addDropdown((dropdown) => { .addDropdown((dropdown) => {
for (const [val, label] of Object.entries(providers)) { for (const p of CHAT_PROVIDERS) {
dropdown.addOption(val, label); dropdown.addOption(
p.id,
configured.has(p.id) ? p.label : `${p.label} (no API key)`
);
}
dropdown.setValue(currentProvider || "openrouter");
dropdown.onChange(async (value) => {
await onChange(value as PostProcessingProvider);
});
for (const p of CHAT_PROVIDERS) {
if (configured.has(p.id)) continue;
const optionEl = dropdown.selectEl.querySelector(
`option[value="${p.id}"]`
) as HTMLOptionElement | null;
if (optionEl) optionEl.disabled = true;
} }
dropdown.setValue(value || "openrouter");
dropdown.onChange(async (val) => await onChange(val));
}); });
if (configured.size === 0) {
containerEl.createEl("p", {
text: "⚠️ No AI provider configured yet — add an API key in the API Keys tab.",
cls: "setting-item-description",
});
}
} }
private createModelSetting( private createModelSetting(
@@ -860,36 +923,35 @@ export class NibbleAISettingsTab extends PluginSettingTab {
); );
} }
private createPostProcessingProviderSetting(containerEl: HTMLElement): void { /**
const providers: Record<PostProcessingProvider, string> = { * Custom endpoint + key fields, shown whenever a feature's provider is
anthropic: "Anthropic", * set to "Custom". This slot is shared across all four AI features.
openai: "OpenAI", */
openrouter: "OpenRouter", private createCustomProviderFields(containerEl: HTMLElement): void {
custom: "Custom", containerEl.createEl("p", {
}; text: "Custom provider connection \u2014 shared across Post-Processing, Proofreading, Title Generation, and Property Filler.",
cls: "setting-item-description",
new Setting(containerEl) });
.setName("Provider") this.createTextSetting(containerEl,
.setDesc("Select the LLM provider") "Custom API URL",
.addDropdown((dropdown) => { "OpenAI-compatible chat completions endpoint",
for (const [value, label] of Object.entries(providers)) { "https://api.example.com/v1/chat/completions",
dropdown.addOption(value, label); this.plugin.settings.customApiUrl,
} async (value) => {
dropdown this.plugin.settings.customApiUrl = value;
.setValue(this.plugin.settings.postProcessingProvider) await this.save();
.onChange(async (value) => { }
const provider = value as PostProcessingProvider; );
this.plugin.settings.postProcessingProvider = provider; this.createApiKeySetting(containerEl,
if (provider !== "custom") { "Custom API Key",
this.plugin.settings.postProcessingUrl = "API key for the custom endpoint",
PROVIDER_URLS[provider]; "sk-...xxxx",
this.plugin.settings.postProcessingModel = this.plugin.settings.customApiKey,
PROVIDER_DEFAULT_MODELS[provider]; async (value) => {
} this.plugin.settings.customApiKey = value;
await this.save(); await this.save();
this.display(); }
}); );
});
} }
private async createAudioDeviceSetting(containerEl: HTMLElement): Promise<void> { private async createAudioDeviceSetting(containerEl: HTMLElement): Promise<void> {
+27
View File
@@ -0,0 +1,27 @@
import { CHAT_PROVIDERS, PROVIDER_URLS, PluginSettings, PostProcessingProvider } from "./SettingsManager";
export interface ResolvedChatProvider {
apiKey: string;
endpoint: string;
provider: PostProcessingProvider;
}
/**
* Resolves the API key + endpoint for a chat-completion provider selection.
* Shared by every AI feature that talks to an LLM (Post-Processing,
* Proofreading, Title Generation, Property Filler) so they all treat
* providers — including "no key configured" — identically.
*/
export function resolveChatProvider(
settings: PluginSettings,
provider: PostProcessingProvider
): ResolvedChatProvider | null {
const info = CHAT_PROVIDERS.find((p) => p.id === provider);
if (!info) return null;
const apiKey = settings[info.apiKeyField];
const endpoint = provider === "custom" ? settings.customApiUrl : PROVIDER_URLS[provider];
if (!apiKey || !endpoint) return null;
return { apiKey, endpoint, provider };
}
+50 -12
View File
@@ -1,6 +1,6 @@
import { Plugin } from "obsidian"; import { Plugin } from "obsidian";
const SECRET_IDS: Record<keyof ApiKeysSettings, string> = { const SECRET_IDS: Partial<Record<keyof ApiKeysSettings, string>> = {
groqApiKey: "groq-api-key", groqApiKey: "groq-api-key",
openAiApiKey: "openai-api-key", openAiApiKey: "openai-api-key",
anthropicApiKey: "anthropic-api-key", anthropicApiKey: "anthropic-api-key",
@@ -8,7 +8,7 @@ const SECRET_IDS: Record<keyof ApiKeysSettings, string> = {
openRouterApiKey: "openrouter-api-key", openRouterApiKey: "openrouter-api-key",
}; };
export type PostProcessingProvider = "anthropic" | "openai" | "openrouter" | "custom"; export type PostProcessingProvider = "anthropic" | "openai" | "openrouter" | "groq" | "custom";
export type AIProvider = PostProcessingProvider; export type AIProvider = PostProcessingProvider;
@@ -16,6 +16,7 @@ export const PROVIDER_URLS: Record<PostProcessingProvider, string> = {
anthropic: "https://api.anthropic.com/v1/messages", anthropic: "https://api.anthropic.com/v1/messages",
openai: "https://api.openai.com/v1/chat/completions", openai: "https://api.openai.com/v1/chat/completions",
openrouter: "https://openrouter.ai/api/v1/chat/completions", openrouter: "https://openrouter.ai/api/v1/chat/completions",
groq: "https://api.groq.com/openai/v1/chat/completions",
custom: "", custom: "",
}; };
@@ -23,9 +24,30 @@ export const PROVIDER_DEFAULT_MODELS: Record<PostProcessingProvider, string> = {
anthropic: "claude-sonnet-4-20250514", anthropic: "claude-sonnet-4-20250514",
openai: "gpt-4o-mini", openai: "gpt-4o-mini",
openrouter: "openai/gpt-4o-mini", openrouter: "openai/gpt-4o-mini",
groq: "llama-3.3-70b-versatile",
custom: "", custom: "",
}; };
export interface ChatProviderInfo {
id: PostProcessingProvider;
label: string;
apiKeyField: keyof ApiKeysSettings;
}
/**
* Single source of truth for which chat-completion providers exist and which
* API key each one uses. UI dropdowns and provider resolution both read from
* this list so every AI feature (Post-Processing, Proofreading, Title
* Generation, Property Filler) offers the exact same providers.
*/
export const CHAT_PROVIDERS: ChatProviderInfo[] = [
{ id: "anthropic", label: "Anthropic", apiKeyField: "anthropicApiKey" },
{ id: "openai", label: "OpenAI", apiKeyField: "openAiApiKey" },
{ id: "groq", label: "Groq", apiKeyField: "groqApiKey" },
{ id: "openrouter", label: "OpenRouter", apiKeyField: "openRouterApiKey" },
{ id: "custom", label: "Custom", apiKeyField: "customApiKey" },
];
export type TranscriptionProvider = "groq" | "openai" | "azure" | "custom"; export type TranscriptionProvider = "groq" | "openai" | "azure" | "custom";
export const TRANSCRIPTION_PROVIDER_URLS: Record<TranscriptionProvider, string> = { export const TRANSCRIPTION_PROVIDER_URLS: Record<TranscriptionProvider, string> = {
@@ -42,6 +64,7 @@ export interface ApiKeysSettings {
openAiApiKey: string; openAiApiKey: string;
anthropicApiKey: string; anthropicApiKey: string;
customApiKey: string; customApiKey: string;
customApiUrl: string;
openRouterApiKey: string; openRouterApiKey: string;
} }
@@ -74,7 +97,6 @@ export interface OutputSettings {
export interface PostProcessingSettings { export interface PostProcessingSettings {
postProcessing: boolean; postProcessing: boolean;
postProcessingProvider: PostProcessingProvider; postProcessingProvider: PostProcessingProvider;
postProcessingUrl: string;
postProcessingModel: string; postProcessingModel: string;
postProcessingPrompt: string; postProcessingPrompt: string;
autoGenerateTitle: boolean; autoGenerateTitle: boolean;
@@ -128,6 +150,7 @@ export const DEFAULT_API_KEYS: ApiKeysSettings = {
openAiApiKey: "", openAiApiKey: "",
anthropicApiKey: "", anthropicApiKey: "",
customApiKey: "", customApiKey: "",
customApiUrl: "",
openRouterApiKey: "", openRouterApiKey: "",
}; };
@@ -160,7 +183,6 @@ export const DEFAULT_OUTPUT: OutputSettings = {
export const DEFAULT_POST_PROCESSING: PostProcessingSettings = { export const DEFAULT_POST_PROCESSING: PostProcessingSettings = {
postProcessing: false, postProcessing: false,
postProcessingProvider: "anthropic", postProcessingProvider: "anthropic",
postProcessingUrl: "https://api.anthropic.com/v1/messages",
postProcessingModel: "claude-sonnet-4-20250514", postProcessingModel: "claude-sonnet-4-20250514",
postProcessingPrompt: postProcessingPrompt:
'You are a transcription editor. Clean up the following voice transcription: fix grammar, remove filler words (um, uh, like) and repetitions, and improve readability. Format the text in markdown. If there are action items or to-dos, format them as task lists with "[ ]". Preserve the original meaning and language. Return only the polished text, nothing else.', 'You are a transcription editor. Clean up the following voice transcription: fix grammar, remove filler words (um, uh, like) and repetitions, and improve readability. Format the text in markdown. If there are action items or to-dos, format them as task lists with "[ ]". Preserve the original meaning and language. Return only the polished text, nothing else.',
@@ -274,19 +296,31 @@ export class SettingsManager {
private migratePostProcessingProvider(settings: PluginSettings): boolean { private migratePostProcessingProvider(settings: PluginSettings): boolean {
if (settings.postProcessingProvider) return false; if (settings.postProcessingProvider) return false;
const legacyUrl = (settings as PluginSettings & { postProcessingUrl?: string })
.postProcessingUrl;
if (!legacyUrl) return false;
for (const [provider, url] of Object.entries(PROVIDER_URLS)) { for (const [provider, url] of Object.entries(PROVIDER_URLS)) {
if (url && settings.postProcessingUrl === url) { if (url && legacyUrl === url) {
settings.postProcessingProvider = settings.postProcessingProvider = provider as PostProcessingProvider;
provider as PostProcessingProvider;
return true; return true;
} }
} }
if (settings.postProcessingUrl) { settings.postProcessingProvider = "custom";
settings.postProcessingProvider = "custom"; return true;
return true; }
}
return false; private migrateCustomApiUrl(settings: PluginSettings): boolean {
// One-time migration: the old `postProcessingUrl` field is now the
// shared `customApiUrl` used by every feature's "Custom" provider.
if (settings.customApiUrl) return false;
const legacyUrl = (settings as PluginSettings & { postProcessingUrl?: string })
.postProcessingUrl;
if (!legacyUrl) return false;
if (Object.values(PROVIDER_URLS).includes(legacyUrl)) return false;
settings.customApiUrl = legacyUrl;
return true;
} }
async loadSettings(): Promise<PluginSettings> { async loadSettings(): Promise<PluginSettings> {
@@ -300,6 +334,10 @@ export class SettingsManager {
await this.plugin.saveData(settings); await this.plugin.saveData(settings);
} }
if (this.migrateCustomApiUrl(settings)) {
await this.plugin.saveData(settings);
}
if (this.migrateKeysFromDataJson(settings)) { if (this.migrateKeysFromDataJson(settings)) {
await this.plugin.saveData(settings); await this.plugin.saveData(settings);
} }
+5 -29
View File
@@ -1,6 +1,6 @@
import { Editor, Notice } from "obsidian"; import { Editor, Notice } from "obsidian";
import { AIService } from "../AIService"; import { AIService } from "../AIService";
import { AIProvider, PROVIDER_URLS } from "../SettingsManager"; import { resolveChatProvider } from "../ProviderResolver";
import NibbleAI from "main"; import NibbleAI from "main";
export class Proofreader { export class Proofreader {
@@ -10,33 +10,6 @@ export class Proofreader {
this.plugin = plugin; this.plugin = plugin;
} }
private resolveProvider(): { apiKey: string; endpoint: string; provider: AIProvider } | null {
const provider = this.plugin.settings.proofreadingProvider || "openrouter";
let apiKey = "";
let endpoint = PROVIDER_URLS[provider] || "";
switch (provider) {
case "openai":
apiKey = this.plugin.settings.openAiApiKey;
break;
case "anthropic":
apiKey = this.plugin.settings.anthropicApiKey;
break;
case "openrouter":
apiKey = this.plugin.settings.openRouterApiKey;
break;
case "custom":
apiKey = this.plugin.settings.customApiKey;
endpoint = this.plugin.settings.postProcessingUrl || "";
break;
}
if (!apiKey) return null;
if (!endpoint) return null;
return { apiKey, endpoint, provider };
}
async fixSelection(editor: Editor): Promise<void> { async fixSelection(editor: Editor): Promise<void> {
const selection = editor.getSelection(); const selection = editor.getSelection();
if (!selection || selection.trim().length === 0) { if (!selection || selection.trim().length === 0) {
@@ -44,7 +17,10 @@ export class Proofreader {
return; return;
} }
const config = this.resolveProvider(); const config = resolveChatProvider(
this.plugin.settings,
this.plugin.settings.proofreadingProvider || "openrouter"
);
if (!config) { if (!config) {
new Notice( new Notice(
"⚠️ No API key for selected provider. Check Settings \u2192 NibbleAI \u2192 API Keys." "⚠️ No API key for selected provider. Check Settings \u2192 NibbleAI \u2192 API Keys."
+5 -27
View File
@@ -1,6 +1,6 @@
import { Notice, MarkdownView, Modal, App, Setting, TFile } from "obsidian"; import { Notice, MarkdownView, Modal, App, Setting, TFile } from "obsidian";
import { AIService } from "../AIService"; import { AIService } from "../AIService";
import { AIProvider, PROVIDER_URLS } from "../SettingsManager"; import { resolveChatProvider } from "../ProviderResolver";
import NibbleAI from "main"; import NibbleAI from "main";
class PropertyPromptModal extends Modal { class PropertyPromptModal extends Modal {
@@ -80,31 +80,6 @@ export class PropertyFiller {
this.plugin = plugin; this.plugin = plugin;
} }
private resolveProvider(): { apiKey: string; endpoint: string; provider: AIProvider } | null {
const provider = this.plugin.settings.propertyGenProvider || "openrouter";
let apiKey = "";
let endpoint = PROVIDER_URLS[provider] || "";
switch (provider) {
case "openai":
apiKey = this.plugin.settings.openAiApiKey;
break;
case "anthropic":
apiKey = this.plugin.settings.anthropicApiKey;
break;
case "openrouter":
apiKey = this.plugin.settings.openRouterApiKey;
break;
case "custom":
apiKey = this.plugin.settings.customApiKey;
endpoint = this.plugin.settings.postProcessingUrl || "";
break;
}
if (!apiKey || !endpoint) return null;
return { apiKey, endpoint, provider };
}
async fillProperty(): Promise<void> { async fillProperty(): Promise<void> {
new PropertyPromptModal( new PropertyPromptModal(
this.plugin.app, this.plugin.app,
@@ -139,7 +114,10 @@ export class PropertyFiller {
return; return;
} }
const config = this.resolveProvider(); const config = resolveChatProvider(
this.plugin.settings,
this.plugin.settings.propertyGenProvider || "openrouter"
);
if (!config) { if (!config) {
new Notice( new Notice(
"⚠️ No API key for selected provider. Check Settings \u2192 NibbleAI \u2192 API Keys." "⚠️ No API key for selected provider. Check Settings \u2192 NibbleAI \u2192 API Keys."
+5 -27
View File
@@ -1,6 +1,6 @@
import { Notice, TFile, MarkdownView } from "obsidian"; import { Notice, TFile, MarkdownView } from "obsidian";
import { AIService } from "../AIService"; import { AIService } from "../AIService";
import { AIProvider, PROVIDER_URLS } from "../SettingsManager"; import { resolveChatProvider } from "../ProviderResolver";
import NibbleAI from "main"; import NibbleAI from "main";
export class TitleGenerator { export class TitleGenerator {
@@ -10,31 +10,6 @@ export class TitleGenerator {
this.plugin = plugin; this.plugin = plugin;
} }
private resolveProvider(): { apiKey: string; endpoint: string; provider: AIProvider } | null {
const provider = this.plugin.settings.titleGenProvider || "openrouter";
let apiKey = "";
let endpoint = PROVIDER_URLS[provider] || "";
switch (provider) {
case "openai":
apiKey = this.plugin.settings.openAiApiKey;
break;
case "anthropic":
apiKey = this.plugin.settings.anthropicApiKey;
break;
case "openrouter":
apiKey = this.plugin.settings.openRouterApiKey;
break;
case "custom":
apiKey = this.plugin.settings.customApiKey;
endpoint = this.plugin.settings.postProcessingUrl || "";
break;
}
if (!apiKey || !endpoint) return null;
return { apiKey, endpoint, provider };
}
async generateTitle(): Promise<void> { async generateTitle(): Promise<void> {
const activeView = const activeView =
this.plugin.app.workspace.getActiveViewOfType(MarkdownView); this.plugin.app.workspace.getActiveViewOfType(MarkdownView);
@@ -55,7 +30,10 @@ export class TitleGenerator {
return; return;
} }
const config = this.resolveProvider(); const config = resolveChatProvider(
this.plugin.settings,
this.plugin.settings.titleGenProvider || "openrouter"
);
if (!config) { if (!config) {
new Notice( new Notice(
"⚠️ No API key for selected provider. Check Settings \u2192 NibbleAI \u2192 API Keys." "⚠️ No API key for selected provider. Check Settings \u2192 NibbleAI \u2192 API Keys."
+17 -23
View File
@@ -8,6 +8,7 @@ import {
resolveTemplate, resolveTemplate,
} from "./utils"; } from "./utils";
import { PostProcessor } from "../PostProcessor"; import { PostProcessor } from "../PostProcessor";
import { resolveChatProvider } from "../ProviderResolver";
export class AudioHandler { export class AudioHandler {
private plugin: NibbleAI; private plugin: NibbleAI;
@@ -16,19 +17,6 @@ export class AudioHandler {
this.plugin = plugin; this.plugin = plugin;
} }
private getPostProcessingApiKey(): string {
switch (this.plugin.settings.postProcessingProvider) {
case "anthropic":
return this.plugin.settings.anthropicApiKey;
case "openai":
return this.plugin.settings.openAiApiKey;
case "openrouter":
return this.plugin.settings.openRouterApiKey;
case "custom":
return this.plugin.settings.customApiKey;
}
}
private getTranscriptionUrl(): string { private getTranscriptionUrl(): string {
switch (this.plugin.settings.transcriptionProvider) { switch (this.plugin.settings.transcriptionProvider) {
case "groq": case "groq":
@@ -173,8 +161,11 @@ export class AudioHandler {
// Post-process with LLM if enabled // Post-process with LLM if enabled
if (this.plugin.settings.postProcessing) { if (this.plugin.settings.postProcessing) {
const ppApiKey = this.getPostProcessingApiKey(); const resolved = resolveChatProvider(
if (!ppApiKey) { this.plugin.settings,
this.plugin.settings.postProcessingProvider
);
if (!resolved) {
new Notice( new Notice(
"✘ Add your post-processing API key in settings" "✘ Add your post-processing API key in settings"
); );
@@ -185,10 +176,10 @@ export class AudioHandler {
new Notice("Post-processing..."); new Notice("Post-processing...");
} }
const processor = new PostProcessor({ const processor = new PostProcessor({
apiKey: ppApiKey, apiKey: resolved.apiKey,
model: this.plugin.settings.postProcessingModel, model: this.plugin.settings.postProcessingModel,
url: this.plugin.settings.postProcessingUrl, url: resolved.endpoint,
provider: this.plugin.settings.postProcessingProvider, provider: resolved.provider,
}); });
finalText = await processor.process( finalText = await processor.process(
originalText, originalText,
@@ -209,14 +200,17 @@ export class AudioHandler {
this.plugin.settings.autoGenerateTitle && this.plugin.settings.autoGenerateTitle &&
this.plugin.settings.createNoteFile this.plugin.settings.createNoteFile
) { ) {
const ppApiKey = this.getPostProcessingApiKey(); const resolved = resolveChatProvider(
if (ppApiKey) { this.plugin.settings,
this.plugin.settings.postProcessingProvider
);
if (resolved) {
try { try {
const processor = new PostProcessor({ const processor = new PostProcessor({
apiKey: ppApiKey, apiKey: resolved.apiKey,
model: this.plugin.settings.postProcessingModel, model: this.plugin.settings.postProcessingModel,
url: this.plugin.settings.postProcessingUrl, url: resolved.endpoint,
provider: this.plugin.settings.postProcessingProvider, provider: resolved.provider,
}); });
const title = await processor.process( const title = await processor.process(
finalText, finalText,