From 18fdb192d82a9087e521b0b09a2ddadf538004f0 Mon Sep 17 00:00:00 2001 From: Willie Zutz Date: Sun, 13 Jul 2025 11:50:51 -0600 Subject: [PATCH] feat(models): Implement model visibility management with hidden models configuration --- README.md | 5 + docs/installation/configuration.md | 154 +++++++++++++++++++++++ sample.config.toml | 1 + src/app/api/config/route.ts | 5 + src/app/api/models/route.ts | 7 +- src/app/settings/page.tsx | 189 +++++++++++++++++++++++++++++ src/lib/config.ts | 44 ++++++- src/lib/providers/index.ts | 76 +++++++++--- 8 files changed, 459 insertions(+), 22 deletions(-) create mode 100644 docs/installation/configuration.md diff --git a/README.md b/README.md index eed404f..6544070 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,11 @@ This fork adds several enhancements to the original Perplexica project: - ✅ Toggle for automatic suggestions - ✅ Added support for latest Anthropic models - ✅ Adds support for multiple user-customizable system prompt enhancement and personas so you can tailor output to your needs +- ✅ **Model Visibility Management**: Server administrators can hide specific models from the user interface and API responses + - Hide expensive models to prevent accidental usage and cost overruns + - Remove non-functional or problematic models from user selection + - Configurable via settings UI with collapsible provider interface for better organization + - API support with `include_hidden` parameter for administrative access ### Bug Fixes diff --git a/docs/installation/configuration.md b/docs/installation/configuration.md new file mode 100644 index 0000000..5954d95 --- /dev/null +++ b/docs/installation/configuration.md @@ -0,0 +1,154 @@ +# Configuration Guide + +This guide covers all the configuration options available in Perplexica's `config.toml` file. + +## Configuration File Structure + +Perplexica uses a TOML configuration file to manage settings. Copy `sample.config.toml` to `config.toml` and modify it according to your needs. + +```bash +cp sample.config.toml config.toml +``` + +## Configuration Sections + +### [GENERAL] + +General application settings. + +#### SIMILARITY_MEASURE +- **Type**: String +- **Options**: `"cosine"` or `"dot"` +- **Default**: `"cosine"` +- **Description**: The similarity measure used for embedding comparisons in search results ranking. + +#### KEEP_ALIVE +- **Type**: String +- **Default**: `"5m"` +- **Description**: How long to keep Ollama models loaded into memory. Use time suffixes like `"5m"` for 5 minutes, `"1h"` for 1 hour, or `"-1m"` for indefinite. + +#### BASE_URL +- **Type**: String +- **Default**: `""` (empty) +- **Description**: Optional base URL override. When set, overrides the detected URL for OpenSearch and other public URLs. + +#### HIDDEN_MODELS +- **Type**: Array of Strings +- **Default**: `[]` (empty array) +- **Description**: Array of model names to hide from the user interface and API responses. Hidden models will not appear in model selection lists but can still be used if directly specified. +- **Example**: `["gpt-4", "claude-3-opus", "expensive-model"]` +- **Use Cases**: + - Hide expensive models to prevent accidental usage + - Remove models that don't work well with your configuration + - Simplify the UI by hiding unused models + +### [MODELS] + +Model provider configurations. Each provider has its own subsection. + +#### [MODELS.OPENAI] +- **API_KEY**: Your OpenAI API key + +#### [MODELS.GROQ] +- **API_KEY**: Your Groq API key + +#### [MODELS.ANTHROPIC] +- **API_KEY**: Your Anthropic API key + +#### [MODELS.GEMINI] +- **API_KEY**: Your Google Gemini API key + +#### [MODELS.CUSTOM_OPENAI] +Configuration for OpenAI-compatible APIs (like LMStudio, vLLM, etc.) +- **API_KEY**: API key for the custom endpoint +- **API_URL**: Base URL for the OpenAI-compatible API +- **MODEL_NAME**: Name of the model to use + +#### [MODELS.OLLAMA] +- **API_URL**: Ollama server URL (e.g., `"http://host.docker.internal:11434"`) + +#### [MODELS.DEEPSEEK] +- **API_KEY**: Your DeepSeek API key + +#### [MODELS.LM_STUDIO] +- **API_URL**: LM Studio server URL (e.g., `"http://host.docker.internal:1234"`) + +### [API_ENDPOINTS] + +External service endpoints. + +#### SEARXNG +- **Type**: String +- **Description**: SearxNG API URL for web search functionality +- **Example**: `"http://localhost:32768"` + +## Environment Variables + +Some configurations can also be set via environment variables, which take precedence over the config file: + +- `OPENAI_API_KEY` +- `GROQ_API_KEY` +- `ANTHROPIC_API_KEY` +- `GEMINI_API_KEY` +- And others following the pattern `{PROVIDER}_API_KEY` + +## Model Visibility Management + +The `HIDDEN_MODELS` setting allows server administrators to control which models are visible to users: + +### How It Works +1. Models listed in `HIDDEN_MODELS` are filtered out of API responses +2. The settings UI shows all models (including hidden ones) for management +3. Hidden models can still be used if explicitly specified in API calls + +### Managing Hidden Models +1. **Via Configuration File**: Edit the `HIDDEN_MODELS` array in `config.toml` +2. **Via Settings UI**: Use the "Model Visibility" section in the settings page +3. **Via API**: Use the `/api/config` endpoint to update the configuration + +### API Behavior +- **Default**: `/api/models` returns only visible models +- **Include Hidden**: `/api/models?include_hidden=true` returns all models (for admin use) + +## Security Considerations + +- Store API keys securely and never commit them to version control +- Use environment variables for sensitive configuration in production +- Regularly rotate API keys +- Consider using `HIDDEN_MODELS` to prevent access to expensive or sensitive models + +## Troubleshooting + +### Common Issues + +1. **Models not appearing**: Check if they're listed in `HIDDEN_MODELS` +2. **API errors**: Verify API keys and URLs are correct +3. **Ollama connection issues**: Ensure the Ollama server is running and accessible +4. **SearxNG not working**: Verify the SearxNG endpoint is correct and accessible + +### Configuration Validation + +The application validates configuration on startup and will log errors for: +- Invalid TOML syntax +- Missing required fields +- Invalid URLs or API endpoints +- Unreachable services + +## Example Configuration + +```toml +[GENERAL] +SIMILARITY_MEASURE = "cosine" +KEEP_ALIVE = "5m" +BASE_URL = "" +HIDDEN_MODELS = ["gpt-4", "claude-3-opus"] + +[MODELS.OPENAI] +API_KEY = "sk-your-openai-key-here" + +[MODELS.OLLAMA] +API_URL = "http://localhost:11434" + +[API_ENDPOINTS] +SEARXNG = "http://localhost:32768" +``` diff --git a/sample.config.toml b/sample.config.toml index 03fa407..e7a8dfc 100644 --- a/sample.config.toml +++ b/sample.config.toml @@ -2,6 +2,7 @@ SIMILARITY_MEASURE = "cosine" # "cosine" or "dot" KEEP_ALIVE = "5m" # How long to keep Ollama models loaded into memory. (Instead of using -1 use "-1m") BASE_URL = "" # Optional. When set, overrides detected URL for OpenSearch and other public URLs +HIDDEN_MODELS = [] # Array of model names to hide from the user interface (e.g., ["gpt-4", "claude-3-opus"]) [MODELS.OPENAI] API_KEY = "" diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts index 193ead5..924b5a9 100644 --- a/src/app/api/config/route.ts +++ b/src/app/api/config/route.ts @@ -10,6 +10,7 @@ import { getOpenaiApiKey, getDeepseekApiKey, getLMStudioApiEndpoint, + getHiddenModels, updateConfig, } from '@/lib/config'; import { @@ -70,6 +71,7 @@ export const GET = async (req: Request) => { config['customOpenaiApiUrl'] = getCustomOpenaiApiUrl(); config['customOpenaiModelName'] = getCustomOpenaiModelName(); config['baseUrl'] = getBaseUrl(); + config['hiddenModels'] = getHiddenModels(); return Response.json({ ...config }, { status: 200 }); } catch (err) { @@ -96,6 +98,9 @@ export const POST = async (req: Request) => { }; const updatedConfig = { + GENERAL: { + HIDDEN_MODELS: config.hiddenModels || [], + }, MODELS: { OPENAI: { API_KEY: getUpdatedProtectedValue( diff --git a/src/app/api/models/route.ts b/src/app/api/models/route.ts index 04a6949..3d83627 100644 --- a/src/app/api/models/route.ts +++ b/src/app/api/models/route.ts @@ -5,9 +5,12 @@ import { export const GET = async (req: Request) => { try { + const url = new URL(req.url); + const includeHidden = url.searchParams.get('include_hidden') === 'true'; + const [chatModelProviders, embeddingModelProviders] = await Promise.all([ - getAvailableChatModelProviders(), - getAvailableEmbeddingModelProviders(), + getAvailableChatModelProviders({ includeHidden }), + getAvailableEmbeddingModelProviders({ includeHidden }), ]); Object.keys(chatModelProviders).forEach((provider) => { diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 7211048..9fb8d03 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -11,6 +11,8 @@ import { Save, X, RotateCcw, + ChevronDown, + ChevronRight, } from 'lucide-react'; import { useEffect, useState, useRef } from 'react'; import { cn } from '@/lib/utils'; @@ -40,6 +42,7 @@ interface SettingsType { customOpenaiApiUrl: string; customOpenaiModelName: string; ollamaContextWindow: number; + hiddenModels: string[]; } interface InputProps extends React.InputHTMLAttributes { @@ -245,6 +248,14 @@ export default function SettingsPage() { ); const [isAddingNewPrompt, setIsAddingNewPrompt] = useState(false); + // Model visibility state variables + const [allModels, setAllModels] = useState<{ + chat: Record>; + embedding: Record>; + }>({ chat: {}, embedding: {} }); + const [hiddenModels, setHiddenModels] = useState([]); + const [expandedProviders, setExpandedProviders] = useState>(new Set()); + // Default Search Settings state variables const [searchOptimizationMode, setSearchOptimizationMode] = useState(''); @@ -265,6 +276,9 @@ export default function SettingsPage() { setConfig(data); + // Populate hiddenModels state from config + setHiddenModels(data.hiddenModels || []); + const chatModelProvidersKeys = Object.keys(data.chatModelProviders || {}); const embeddingModelProvidersKeys = Object.keys( data.embeddingModelProviders || {}, @@ -319,7 +333,29 @@ export default function SettingsPage() { setIsLoading(false); }; + const fetchAllModels = async () => { + try { + // Fetch complete model list including hidden models + const res = await fetch(`/api/models?include_hidden=true`, { + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (res.ok) { + const data = await res.json(); + setAllModels({ + chat: data.chatModelProviders || {}, + embedding: data.embeddingModelProviders || {}, + }); + } + } catch (error) { + console.error('Failed to fetch all models:', error); + } + }; + fetchConfig(); + fetchAllModels(); // Load search settings from localStorage const loadSearchSettings = () => { @@ -529,6 +565,42 @@ export default function SettingsPage() { localStorage.setItem(key, value); }; + const handleModelVisibilityToggle = async (modelKey: string, isVisible: boolean) => { + let updatedHiddenModels: string[]; + + if (isVisible) { + // Model should be visible, remove from hidden list + updatedHiddenModels = hiddenModels.filter(m => m !== modelKey); + } else { + // Model should be hidden, add to hidden list + updatedHiddenModels = [...hiddenModels, modelKey]; + } + + // Update local state immediately + setHiddenModels(updatedHiddenModels); + + // Persist changes to backend + try { + await saveConfig('hiddenModels', updatedHiddenModels); + } catch (error) { + console.error('Failed to save hidden models:', error); + // Revert local state on error + setHiddenModels(hiddenModels); + } + }; + + const toggleProviderExpansion = (providerId: string) => { + setExpandedProviders(prev => { + const newSet = new Set(prev); + if (newSet.has(providerId)) { + newSet.delete(providerId); + } else { + newSet.add(providerId); + } + return newSet; + }); + }; + const handleAddOrUpdateSystemPrompt = async () => { const currentPrompt = editingPrompt || { name: newPromptName, @@ -1400,6 +1472,123 @@ export default function SettingsPage() { )} + +
+ {/* Unified Models List */} + {(() => { + // Combine all models from both chat and embedding providers + const allProviders: Record> = {}; + + // Add chat models + Object.entries(allModels.chat).forEach(([provider, models]) => { + if (!allProviders[provider]) { + allProviders[provider] = {}; + } + Object.entries(models).forEach(([modelKey, model]) => { + allProviders[provider][modelKey] = model; + }); + }); + + // Add embedding models + Object.entries(allModels.embedding).forEach(([provider, models]) => { + if (!allProviders[provider]) { + allProviders[provider] = {}; + } + Object.entries(models).forEach(([modelKey, model]) => { + allProviders[provider][modelKey] = model; + }); + }); + + return Object.keys(allProviders).length > 0 ? ( + Object.entries(allProviders).map(([provider, models]) => { + const providerId = `provider-${provider}`; + const isExpanded = expandedProviders.has(providerId); + const modelEntries = Object.entries(models); + const hiddenCount = modelEntries.filter(([modelKey]) => hiddenModels.includes(modelKey)).length; + const totalCount = modelEntries.length; + + return ( +
+ + + {isExpanded && ( +
+
+ {modelEntries.map(([modelKey, model]) => ( +
+ + {model.displayName || modelKey} + + { + handleModelVisibilityToggle(modelKey, checked); + }} + className={cn( + !hiddenModels.includes(modelKey) + ? 'bg-[#24A0ED]' + : 'bg-light-200 dark:bg-dark-200', + 'relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none', + )} + > + + +
+ ))} +
+
+ )} +
+ ); + }) + ) : ( +

+ No models available +

+ ); + })()} +
+
+ = { const loadConfig = () => { // Server-side only if (typeof window === 'undefined') { - return toml.parse( + const config = toml.parse( fs.readFileSync(path.join(process.cwd(), `${configFileName}`), 'utf-8'), ) as any as Config; + + // Ensure GENERAL section exists + if (!config.GENERAL) { + config.GENERAL = {} as any; + } + + // Handle HIDDEN_MODELS - fix malformed table format to proper array + if (!config.GENERAL.HIDDEN_MODELS) { + config.GENERAL.HIDDEN_MODELS = []; + } else if (typeof config.GENERAL.HIDDEN_MODELS === 'object' && !Array.isArray(config.GENERAL.HIDDEN_MODELS)) { + // Convert malformed table format to array + const hiddenModelsObj = config.GENERAL.HIDDEN_MODELS as any; + const hiddenModelsArray: string[] = []; + + // Extract values from numeric keys and sort by key + const keys = Object.keys(hiddenModelsObj).map(k => parseInt(k)).filter(k => !isNaN(k)).sort((a, b) => a - b); + for (const key of keys) { + if (typeof hiddenModelsObj[key] === 'string') { + hiddenModelsArray.push(hiddenModelsObj[key]); + } + } + + config.GENERAL.HIDDEN_MODELS = hiddenModelsArray; + } + + return config; } // Client-side fallback - settings will be loaded via API @@ -73,6 +100,8 @@ export const getKeepAlive = () => loadConfig().GENERAL.KEEP_ALIVE; export const getBaseUrl = () => loadConfig().GENERAL.BASE_URL; +export const getHiddenModels = () => loadConfig().GENERAL.HIDDEN_MODELS; + export const getOpenaiApiKey = () => loadConfig().MODELS.OPENAI.API_KEY; export const getGroqApiKey = () => loadConfig().MODELS.GROQ.API_KEY; @@ -109,17 +138,26 @@ const mergeConfigs = (current: any, update: any): any => { return update; } + // Handle arrays specifically - don't merge them, replace them + if (Array.isArray(update)) { + return update; + } + const result = { ...current }; for (const key in update) { if (Object.prototype.hasOwnProperty.call(update, key)) { const updateValue = update[key]; - if ( + // Handle arrays specifically - don't merge them, replace them + if (Array.isArray(updateValue)) { + result[key] = updateValue; + } else if ( typeof updateValue === 'object' && updateValue !== null && typeof result[key] === 'object' && - result[key] !== null + result[key] !== null && + !Array.isArray(result[key]) ) { result[key] = mergeConfigs(result[key], updateValue); } else if (updateValue !== undefined) { diff --git a/src/lib/providers/index.ts b/src/lib/providers/index.ts index 97b251d..d28cc47 100644 --- a/src/lib/providers/index.ts +++ b/src/lib/providers/index.ts @@ -10,6 +10,7 @@ import { getCustomOpenaiApiKey, getCustomOpenaiApiUrl, getCustomOpenaiModelName, + getHiddenModels, } from '../config'; import { ChatOpenAI } from '@langchain/openai'; import { @@ -90,7 +91,10 @@ export const embeddingModelProviders: Record< lmstudio: loadLMStudioEmbeddingsModels, }; -export const getAvailableChatModelProviders = async () => { +export const getAvailableChatModelProviders = async ( + options: { includeHidden?: boolean } = {} +) => { + const { includeHidden = false } = options; const models: Record> = {}; for (const provider in chatModelProviders) { @@ -111,28 +115,48 @@ export const getAvailableChatModelProviders = async () => { const customOpenAiApiUrl = getCustomOpenaiApiUrl(); const customOpenAiModelName = getCustomOpenaiModelName(); - models['custom_openai'] = { - ...(customOpenAiApiKey && customOpenAiApiUrl && customOpenAiModelName - ? { - [customOpenAiModelName]: { - displayName: customOpenAiModelName, - model: new ChatOpenAI({ - openAIApiKey: customOpenAiApiKey, - modelName: customOpenAiModelName, - // temperature: 0.7, - configuration: { - baseURL: customOpenAiApiUrl, - }, - }) as unknown as BaseChatModel, + // Only add custom_openai provider if all required fields are configured + if (customOpenAiApiKey && customOpenAiApiUrl && customOpenAiModelName) { + models['custom_openai'] = { + [customOpenAiModelName]: { + displayName: customOpenAiModelName, + model: new ChatOpenAI({ + openAIApiKey: customOpenAiApiKey, + modelName: customOpenAiModelName, + // temperature: 0.7, + configuration: { + baseURL: customOpenAiApiUrl, }, + }) as unknown as BaseChatModel, + }, + }; + } + + // Filter out hidden models if includeHidden is false + if (!includeHidden) { + const hiddenModels = getHiddenModels(); + if (hiddenModels.length > 0) { + for (const provider in models) { + for (const modelKey in models[provider]) { + if (hiddenModels.includes(modelKey)) { + delete models[provider][modelKey]; + } } - : {}), - }; + // Remove provider if all models are hidden + if (Object.keys(models[provider]).length === 0) { + delete models[provider]; + } + } + } + } return models; }; -export const getAvailableEmbeddingModelProviders = async () => { +export const getAvailableEmbeddingModelProviders = async ( + options: { includeHidden?: boolean } = {} +) => { + const { includeHidden = false } = options; const models: Record> = {}; for (const provider in embeddingModelProviders) { @@ -149,5 +173,23 @@ export const getAvailableEmbeddingModelProviders = async () => { } } + // Filter out hidden models if includeHidden is false + if (!includeHidden) { + const hiddenModels = getHiddenModels(); + if (hiddenModels.length > 0) { + for (const provider in models) { + for (const modelKey in models[provider]) { + if (hiddenModels.includes(modelKey)) { + delete models[provider][modelKey]; + } + } + // Remove provider if all models are hidden + if (Object.keys(models[provider]).length === 0) { + delete models[provider]; + } + } + } + } + return models; };