diff --git a/src/components/ui/ModelSelector.tsx b/src/components/ui/ModelSelector.tsx index 6a22f6add..cb0c1f94b 100644 --- a/src/components/ui/ModelSelector.tsx +++ b/src/components/ui/ModelSelector.tsx @@ -8,10 +8,15 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { ModelDisplay } from "@/components/ui/model-display"; -import { useSettingsValue, getModelKeyFromModel } from "@/settings/model"; +import { getModelKeyFromModel, useSettingsValue } from "@/settings/model"; import { checkModelApiKey, err2String } from "@/utils"; import { ChevronDown } from "lucide-react"; import { cn } from "@/lib/utils"; +import { + getApiKeyForProvider, + isRequiredChatModel, + providerRequiresApiKey, +} from "@/utils/modelUtils"; interface ModelSelectorProps { disabled?: boolean; @@ -38,6 +43,22 @@ export function ModelSelector({ (model) => model.enabled && getModelKeyFromModel(model) === value ); + // Filter models: show required models, local models, or models with valid API keys + const showModels = settings.activeModels.filter((model) => { + const isRequired = isRequiredChatModel(model); + if (isRequired) { + return true; + } + + // Local providers don't require API keys + if (!providerRequiresApiKey(model.provider)) { + return true; + } + + // Cloud providers need API keys + const hasApiKey = !!getApiKeyForProvider(model.provider, model); + return hasApiKey; + }); return ( @@ -61,7 +82,7 @@ export function ModelSelector({ - {settings.activeModels + {showModels .filter((model) => model.enabled) .map((model) => { const { hasApiKey, errorNotice } = checkModelApiKey(model, settings); @@ -83,7 +104,7 @@ export function ModelSelector({ setModelError(msg); new Notice(msg); // Restore to the last valid model - const lastValidModel = settings.activeModels.find( + const lastValidModel = showModels.find( (m) => m.enabled && getModelKeyFromModel(m) === value ); if (lastValidModel) { diff --git a/src/settings/v2/components/ApiKeyDialog.tsx b/src/settings/v2/components/ApiKeyDialog.tsx index 83467e4d7..1f1ee01bc 100644 --- a/src/settings/v2/components/ApiKeyDialog.tsx +++ b/src/settings/v2/components/ApiKeyDialog.tsx @@ -17,6 +17,7 @@ import { getProviderLabel, safeFetch, } from "@/utils"; +import { getApiKeyForProvider } from "@/utils/modelUtils"; import { ChevronDown, ChevronUp, Loader2 } from "lucide-react"; import { App, Modal, Notice } from "obsidian"; import React, { useEffect, useState } from "react"; @@ -55,17 +56,11 @@ function ApiKeyModalContent({ onClose }: ApiKeyModalContentProps) { setSelectedModel(null); }, []); // Empty dependency array ensures this runs on mount - // Get API key by provider - const getApiKeyByProvider = (provider: SettingKeyProviders): string => { - const settingKey = ProviderSettingsKeyMap[provider]; - return (settings[settingKey] ?? "") as string; - }; - const providers: ProviderKeyItem[] = getNeedSetKeyProvider() .filter((provider) => provider !== ChatModelProviders.AMAZON_BEDROCK) .map((provider) => { const providerKey = provider as SettingKeyProviders; - const apiKey = getApiKeyByProvider(providerKey); + const apiKey = getApiKeyForProvider(providerKey); return { provider: providerKey, @@ -74,7 +69,7 @@ function ApiKeyModalContent({ onClose }: ApiKeyModalContentProps) { }); const handleApiKeyChange = (provider: SettingKeyProviders, value: string) => { - const currentKey = getApiKeyByProvider(provider); + const currentKey = getApiKeyForProvider(provider); if (currentKey !== value) { updateSetting(ProviderSettingsKeyMap[provider], value); // Mark models as needing refresh for this provider @@ -174,7 +169,7 @@ function ApiKeyModalContent({ onClose }: ApiKeyModalContentProps) { let verificationError = ""; try { - const apiKey = getApiKeyByProvider(selectedModel.provider); + const apiKey = getApiKeyForProvider(selectedModel.provider); const customModel: CustomModel = { name: selectedModel.name, provider: selectedModel.provider, diff --git a/src/settings/v2/components/ModelAddDialog.tsx b/src/settings/v2/components/ModelAddDialog.tsx index 2de304b59..f994e70c7 100644 --- a/src/settings/v2/components/ModelAddDialog.tsx +++ b/src/settings/v2/components/ModelAddDialog.tsx @@ -27,15 +27,14 @@ import { EmbeddingModelProviders, MODEL_CAPABILITIES, ModelCapability, - Provider, ProviderMetadata, - ProviderSettingsKeyMap, SettingKeyProviders, } from "@/constants"; import { useTab } from "@/contexts/TabContext"; import { logError } from "@/logger"; import { getSettings } from "@/settings/model"; import { err2String, getProviderInfo, getProviderLabel, omit } from "@/utils"; +import { getApiKeyForProvider } from "@/utils/modelUtils"; import { ChevronDown, Loader2 } from "lucide-react"; import { Notice } from "obsidian"; import React, { useState } from "react"; @@ -136,10 +135,6 @@ export const ModelAddDialog: React.FC = ({ return isValid; }; - const getDefaultApiKey = (provider: Provider): string => { - return (settings[ProviderSettingsKeyMap[provider as SettingKeyProviders]] as string) || ""; - }; - const getInitialModel = (provider = defaultProvider): CustomModel => { const baseModel = { name: "", @@ -147,7 +142,7 @@ export const ModelAddDialog: React.FC = ({ enabled: true, isBuiltIn: false, baseUrl: "", - apiKey: getDefaultApiKey(provider), + apiKey: getApiKeyForProvider(provider as SettingKeyProviders), isEmbeddingModel, capabilities: [], }; @@ -222,7 +217,7 @@ export const ModelAddDialog: React.FC = ({ setModel({ ...model, provider, - apiKey: getDefaultApiKey(provider), + apiKey: getApiKeyForProvider(provider as SettingKeyProviders), ...(provider === ChatModelProviders.OPENAI ? { openAIOrgId: settings.openAIOrgId } : {}), ...(provider === ChatModelProviders.AZURE_OPENAI ? { diff --git a/src/settings/v2/components/ModelEditDialog.tsx b/src/settings/v2/components/ModelEditDialog.tsx index e39fbe04a..77ba12831 100644 --- a/src/settings/v2/components/ModelEditDialog.tsx +++ b/src/settings/v2/components/ModelEditDialog.tsx @@ -12,13 +12,12 @@ import { ChatModelProviders, MODEL_CAPABILITIES, ModelCapability, - Provider, ProviderMetadata, - ProviderSettingsKeyMap, SettingKeyProviders, } from "@/constants"; import { getSettings } from "@/settings/model"; import { debounce, getProviderInfo, getProviderLabel } from "@/utils"; +import { getApiKeyForProvider } from "@/utils/modelUtils"; import { App, Modal, Platform } from "obsidian"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { createRoot, Root } from "react-dom/client"; @@ -47,10 +46,6 @@ export const ModelEditModalContent: React.FC = ({ const settings = getSettings(); const isBedrockProvider = localModel.provider === ChatModelProviders.AMAZON_BEDROCK; - const getDefaultApiKey = (provider: Provider): string => { - return (settings[ProviderSettingsKeyMap[provider as SettingKeyProviders]] as string) || ""; - }; - useEffect(() => { setLocalModel(model); setOriginalModel(model); @@ -120,7 +115,10 @@ export const ModelEditModalContent: React.FC = ({ description, })) as Array<{ id: ModelCapability; label: string; description: string }>; - const displayApiKey = localModel.apiKey || getDefaultApiKey(localModel.provider as Provider); + const displayApiKey = getApiKeyForProvider( + localModel.provider as SettingKeyProviders, + localModel + ); const showOtherParameters = !isEmbeddingModel && localModel.provider !== "copilot-plus-jina"; return ( diff --git a/src/settings/v2/components/ModelTable.tsx b/src/settings/v2/components/ModelTable.tsx index 2d6397785..979badd8a 100644 --- a/src/settings/v2/components/ModelTable.tsx +++ b/src/settings/v2/components/ModelTable.tsx @@ -54,6 +54,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { isRequiredChatModel } from "@/utils/modelUtils"; const CAPABILITY_ICONS: Record< ModelCapability, @@ -291,6 +292,7 @@ const DesktopSortableTableRow: React.FC<{ onUpdateModel({ ...model, enabled: checked })} className="tw-mx-auto" /> diff --git a/src/utils.ts b/src/utils.ts index 59c95985e..1e7620d42 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -6,7 +6,6 @@ import { Provider, ProviderInfo, ProviderMetadata, - ProviderSettingsKeyMap, SettingKeyProviders, USER_SENDER, } from "@/constants"; @@ -20,6 +19,7 @@ import { BaseChain, RetrievalQAChain } from "@langchain/classic/chains"; import moment from "moment"; import { MarkdownView, Notice, TFile, Vault, normalizePath, requestUrl } from "obsidian"; import { CustomModel } from "./aiParams"; +import { getApiKeyForProvider } from "@/utils/modelUtils"; export { err2String } from "@/errorFormat"; // Add custom error type at the top of the file @@ -1019,7 +1019,7 @@ export function getMessageRole( return isOSeriesModel(model) ? "human" : defaultRole; } -export function getNeedSetKeyProvider() { +export function getNeedSetKeyProvider(): Provider[] { // List of providers to exclude const excludeProviders: Provider[] = [ ChatModelProviders.OPENAI_FORMAT, @@ -1030,9 +1030,7 @@ export function getNeedSetKeyProvider() { EmbeddingModelProviders.COPILOT_PLUS_JINA, ]; - return Object.entries(ProviderInfo) - .filter(([key]) => !excludeProviders.includes(key as Provider)) - .map(([key]) => key as Provider); + return (Object.keys(ProviderInfo) as Provider[]).filter((key) => !excludeProviders.includes(key)); } export function checkModelApiKey( @@ -1057,9 +1055,9 @@ export function checkModelApiKey( } const needSetKeyPath = !!getNeedSetKeyProvider().find((provider) => provider === model.provider); - const providerKeyName = ProviderSettingsKeyMap[model.provider as SettingKeyProviders]; - const hasNoApiKey = !model.apiKey && !settings[providerKeyName]; + const hasNoApiKey = !getApiKeyForProvider(model.provider as SettingKeyProviders, model); + // For Providers that require setting a key in the dialog, an inspection is necessary. if (needSetKeyPath && hasNoApiKey) { const notice = `Please configure API Key for ${model.name} in settings first.` + diff --git a/src/utils/modelUtils.ts b/src/utils/modelUtils.ts new file mode 100644 index 000000000..cdb60a269 --- /dev/null +++ b/src/utils/modelUtils.ts @@ -0,0 +1,75 @@ +import { + ChatModelProviders, + ChatModels, + ProviderSettingsKeyMap, + SettingKeyProviders, +} from "@/constants"; +import { getSettings } from "@/settings/model"; +import { CustomModel } from "@/aiParams"; + +/** + * Check if a provider requires an API key. + * Local providers (OLLAMA, LM_STUDIO, OPENAI_FORMAT) don't require API keys. + * + * @param provider - The provider to check + * @returns true if the provider requires an API key, false for local providers + * + * @example + * if (providerRequiresApiKey(model.provider)) { + * // This is a cloud provider, check for API key + * } else { + * // This is a local provider, no API key needed + * } + */ +export function providerRequiresApiKey(provider: string): provider is SettingKeyProviders { + return provider in ProviderSettingsKeyMap; +} + +/** + * Get API key for a provider, with model-specific key taking precedence over global settings. + * + * @param provider - The provider to get the API key for + * @param model - Optional model instance; if provided and has apiKey, it will be used instead of global key + * @returns The API key (model-specific if available, otherwise global provider key, or empty string) + * + * @example + * // Get global API key for OpenAI + * const globalKey = getApiKeyForProvider(ChatModelProviders.OPENAI); + * + * // Get model-specific key (falls back to global if model.apiKey is empty) + * const modelKey = getApiKeyForProvider(ChatModelProviders.OPENAI, customModel); + */ +export function getApiKeyForProvider(provider: SettingKeyProviders, model?: CustomModel): string { + const settings = getSettings(); + return model?.apiKey || (settings[ProviderSettingsKeyMap[provider]] as string | undefined) || ""; +} + +/** + * Get the list of models that are always required and cannot be disabled. + * These models provide essential functionality for the plugin. + * Uses a getter function to avoid circular dependency issues. + */ +function getRequiredModels(): ReadonlyArray<{ name: string; provider: string }> { + return [ + { name: ChatModels.COPILOT_PLUS_FLASH, provider: ChatModelProviders.COPILOT_PLUS }, + { name: ChatModels.OPENROUTER_GEMINI_2_5_FLASH, provider: ChatModelProviders.OPENROUTERAI }, + ]; +} + +/** + * Checks if a model is required and should always be enabled. + * Required models cannot be disabled by users as they provide core plugin functionality. + * + * @param model - The model to check + * @returns true if the model is required and must remain enabled, false otherwise + * + * @example + * if (isRequiredChatModel(model)) { + * // This model cannot be disabled + * } + */ +export function isRequiredChatModel(model: CustomModel): boolean { + return getRequiredModels().some( + (required) => required.name === model.name && required.provider === model.provider + ); +}