feat(models): Implement model visibility management with hidden models configuration

This commit is contained in:
Willie Zutz 2025-07-13 11:50:51 -06:00
parent e47307d1d4
commit 18fdb192d8
8 changed files with 459 additions and 22 deletions

View file

@ -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

View file

@ -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"
```

View file

@ -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 = ""

View file

@ -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(

View file

@ -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) => {

View file

@ -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<HTMLInputElement> {
@ -245,6 +248,14 @@ export default function SettingsPage() {
);
const [isAddingNewPrompt, setIsAddingNewPrompt] = useState(false);
// Model visibility state variables
const [allModels, setAllModels] = useState<{
chat: Record<string, Record<string, any>>;
embedding: Record<string, Record<string, any>>;
}>({ chat: {}, embedding: {} });
const [hiddenModels, setHiddenModels] = useState<string[]>([]);
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(new Set());
// Default Search Settings state variables
const [searchOptimizationMode, setSearchOptimizationMode] =
useState<string>('');
@ -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() {
)}
</SettingsSection>
<SettingsSection
title="Model Visibility"
tooltip="Hide models from the API to prevent them from appearing in model lists.\nHidden models will not be available for selection in the interface.\nThis allows server admins to disable models that may incur large costs or won't work with the application."
>
<div className="flex flex-col space-y-3">
{/* Unified Models List */}
{(() => {
// Combine all models from both chat and embedding providers
const allProviders: Record<string, Record<string, any>> = {};
// 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 (
<div
key={providerId}
className="border border-light-200 dark:border-dark-200 rounded-lg overflow-hidden"
>
<button
onClick={() => toggleProviderExpansion(providerId)}
className="w-full p-3 bg-light-secondary dark:bg-dark-secondary hover:bg-light-200 dark:hover:bg-dark-200 transition-colors flex items-center justify-between"
>
<div className="flex items-center space-x-3">
{isExpanded ? (
<ChevronDown size={16} className="text-black/70 dark:text-white/70" />
) : (
<ChevronRight size={16} className="text-black/70 dark:text-white/70" />
)}
<h4 className="text-sm font-medium text-black/80 dark:text-white/80">
{(PROVIDER_METADATA as any)[provider]?.displayName ||
provider.charAt(0).toUpperCase() + provider.slice(1)}
</h4>
</div>
<div className="flex items-center space-x-2 text-xs text-black/60 dark:text-white/60">
<span>{totalCount - hiddenCount} visible</span>
{hiddenCount > 0 && (
<span className="px-2 py-1 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded">
{hiddenCount} hidden
</span>
)}
</div>
</button>
{isExpanded && (
<div className="p-3 bg-light-100 dark:bg-dark-100 border-t border-light-200 dark:border-dark-200">
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{modelEntries.map(([modelKey, model]) => (
<div
key={`${provider}-${modelKey}`}
className="flex items-center justify-between p-2 bg-white dark:bg-dark-secondary rounded-md"
>
<span className="text-sm text-black/90 dark:text-white/90">
{model.displayName || modelKey}
</span>
<Switch
checked={!hiddenModels.includes(modelKey)}
onChange={(checked) => {
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',
)}
>
<span
className={cn(
!hiddenModels.includes(modelKey)
? 'translate-x-5'
: 'translate-x-1',
'inline-block h-3 w-3 transform rounded-full bg-white transition-transform',
)}
/>
</Switch>
</div>
))}
</div>
</div>
)}
</div>
);
})
) : (
<p className="text-sm text-black/60 dark:text-white/60 italic">
No models available
</p>
);
})()}
</div>
</SettingsSection>
<SettingsSection
title="API Keys"
tooltip="API Key values can be viewed in the config.toml file"

View file

@ -16,6 +16,7 @@ interface Config {
SIMILARITY_MEASURE: string;
KEEP_ALIVE: string;
BASE_URL?: string;
HIDDEN_MODELS: string[];
};
MODELS: {
OPENAI: {
@ -57,9 +58,35 @@ type RecursivePartial<T> = {
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) {

View file

@ -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<string, Record<string, ChatModel>> = {};
for (const provider in chatModelProviders) {
@ -111,9 +115,9 @@ export const getAvailableChatModelProviders = async () => {
const customOpenAiApiUrl = getCustomOpenaiApiUrl();
const customOpenAiModelName = getCustomOpenaiModelName();
// Only add custom_openai provider if all required fields are configured
if (customOpenAiApiKey && customOpenAiApiUrl && customOpenAiModelName) {
models['custom_openai'] = {
...(customOpenAiApiKey && customOpenAiApiUrl && customOpenAiModelName
? {
[customOpenAiModelName]: {
displayName: customOpenAiModelName,
model: new ChatOpenAI({
@ -125,14 +129,34 @@ export const getAvailableChatModelProviders = async () => {
},
}) 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<string, Record<string, EmbeddingModel>> = {};
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;
};