feat: migrated to surfsense deep agent

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2025-12-23 01:16:25 -08:00
parent b14283e300
commit 4a0c3e368a
90 changed files with 5337 additions and 6029 deletions

View file

@ -0,0 +1,114 @@
"""Add NewLLMConfig table for configurable LLM + prompt settings
Revision ID: 51
Revises: 50
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "51"
down_revision: str | None = "50"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""
Add the new_llm_configs table that combines LLM model settings with prompt configuration.
This table includes:
- LLM model configuration (provider, model_name, api_key, etc.)
- Configurable system instructions
- Citation toggle
"""
# Create new_llm_configs table only if it doesn't already exist
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'new_llm_configs'
) THEN
CREATE TABLE new_llm_configs (
id SERIAL PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
-- Basic info
name VARCHAR(100) NOT NULL,
description VARCHAR(500),
-- LLM Model Configuration (same as llm_configs, excluding language)
provider litellmprovider NOT NULL,
custom_provider VARCHAR(100),
model_name VARCHAR(100) NOT NULL,
api_key TEXT NOT NULL,
api_base VARCHAR(500),
litellm_params JSONB DEFAULT '{}',
-- Prompt Configuration
system_instructions TEXT NOT NULL DEFAULT '',
use_default_system_instructions BOOLEAN NOT NULL DEFAULT TRUE,
citations_enabled BOOLEAN NOT NULL DEFAULT TRUE,
-- Default flag
is_default BOOLEAN NOT NULL DEFAULT FALSE,
-- Foreign key to search space
search_space_id INTEGER NOT NULL REFERENCES searchspaces(id) ON DELETE CASCADE
);
END IF;
END$$;
"""
)
# Create indexes if they don't exist
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'new_llm_configs' AND indexname = 'ix_new_llm_configs_id'
) THEN
CREATE INDEX ix_new_llm_configs_id ON new_llm_configs(id);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'new_llm_configs' AND indexname = 'ix_new_llm_configs_created_at'
) THEN
CREATE INDEX ix_new_llm_configs_created_at ON new_llm_configs(created_at);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'new_llm_configs' AND indexname = 'ix_new_llm_configs_name'
) THEN
CREATE INDEX ix_new_llm_configs_name ON new_llm_configs(name);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'new_llm_configs' AND indexname = 'ix_new_llm_configs_search_space_id'
) THEN
CREATE INDEX ix_new_llm_configs_search_space_id ON new_llm_configs(search_space_id);
END IF;
END$$;
"""
)
def downgrade() -> None:
"""Remove the new_llm_configs table."""
# Drop indexes
op.execute("DROP INDEX IF EXISTS ix_new_llm_configs_search_space_id")
op.execute("DROP INDEX IF EXISTS ix_new_llm_configs_name")
op.execute("DROP INDEX IF EXISTS ix_new_llm_configs_created_at")
op.execute("DROP INDEX IF EXISTS ix_new_llm_configs_id")
# Drop table
op.execute("DROP TABLE IF EXISTS new_llm_configs")

View file

@ -0,0 +1,130 @@
"""Rename LLM preference columns in searchspaces table
Revision ID: 52
Revises: 51
Create Date: 2024-12-22
This migration renames the LLM preference columns:
- fast_llm_id -> agent_llm_id
- long_context_llm_id -> document_summary_llm_id
- strategic_llm_id is removed (data migrated to document_summary_llm_id)
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "52"
down_revision = "51"
branch_labels = None
depends_on = None
def upgrade():
# First, migrate any strategic_llm_id values to document_summary_llm_id
# (only if document_summary_llm_id/long_context_llm_id is NULL)
# Use IF EXISTS check to handle case where column might not exist
op.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'searchspaces' AND column_name = 'strategic_llm_id'
) THEN
UPDATE searchspaces
SET long_context_llm_id = strategic_llm_id
WHERE long_context_llm_id IS NULL AND strategic_llm_id IS NOT NULL;
END IF;
END$$;
"""
)
# Rename columns (only if they exist with old names)
op.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'searchspaces' AND column_name = 'fast_llm_id'
) THEN
ALTER TABLE searchspaces RENAME COLUMN fast_llm_id TO agent_llm_id;
END IF;
END$$;
"""
)
op.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'searchspaces' AND column_name = 'long_context_llm_id'
) THEN
ALTER TABLE searchspaces RENAME COLUMN long_context_llm_id TO document_summary_llm_id;
END IF;
END$$;
"""
)
# Drop the strategic_llm_id column if it exists
op.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'searchspaces' AND column_name = 'strategic_llm_id'
) THEN
ALTER TABLE searchspaces DROP COLUMN strategic_llm_id;
END IF;
END$$;
"""
)
def downgrade():
# Add back the strategic_llm_id column
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'searchspaces' AND column_name = 'strategic_llm_id'
) THEN
ALTER TABLE searchspaces ADD COLUMN strategic_llm_id INTEGER;
END IF;
END$$;
"""
)
# Rename columns back
op.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'searchspaces' AND column_name = 'agent_llm_id'
) THEN
ALTER TABLE searchspaces RENAME COLUMN agent_llm_id TO fast_llm_id;
END IF;
END$$;
"""
)
op.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'searchspaces' AND column_name = 'document_summary_llm_id'
) THEN
ALTER TABLE searchspaces RENAME COLUMN document_summary_llm_id TO long_context_llm_id;
END IF;
END$$;
"""
)

View file

@ -0,0 +1,244 @@
"""Migrate data from old llm_configs to new_llm_configs and cleanup
Revision ID: 53
Revises: 52
Create Date: 2024-12-22
This migration:
1. Migrates data from old llm_configs table to new_llm_configs (preserving user configs)
2. Drops the old llm_configs table (no longer used)
3. Removes the is_default column from new_llm_configs (roles now determine which config to use)
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "53"
down_revision = "52"
branch_labels = None
depends_on = None
def upgrade():
# STEP 1: Migrate data from old llm_configs to new_llm_configs
# This preserves any user-created configurations
op.execute(
"""
DO $$
BEGIN
-- Only migrate if both tables exist
IF EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'llm_configs'
) AND EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'new_llm_configs'
) THEN
-- Insert old configs into new table (skipping duplicates by name+search_space_id)
INSERT INTO new_llm_configs (
name,
description,
provider,
custom_provider,
model_name,
api_key,
api_base,
litellm_params,
system_instructions,
use_default_system_instructions,
citations_enabled,
is_default,
search_space_id,
created_at
)
SELECT
lc.name,
NULL as description, -- Old table didn't have description
lc.provider,
lc.custom_provider,
lc.model_name,
lc.api_key,
lc.api_base,
COALESCE(lc.litellm_params, '{}'::jsonb),
'' as system_instructions, -- Use defaults
TRUE as use_default_system_instructions,
TRUE as citations_enabled,
FALSE as is_default,
lc.search_space_id,
COALESCE(lc.created_at, NOW())
FROM llm_configs lc
WHERE lc.search_space_id IS NOT NULL
AND NOT EXISTS (
-- Skip if a config with same name already exists in new_llm_configs for this search space
SELECT 1 FROM new_llm_configs nlc
WHERE nlc.name = lc.name
AND nlc.search_space_id = lc.search_space_id
);
-- Log how many configs were migrated
RAISE NOTICE 'Migrated % configs from llm_configs to new_llm_configs',
(SELECT COUNT(*) FROM llm_configs WHERE search_space_id IS NOT NULL);
END IF;
END$$;
"""
)
# STEP 2: Update searchspaces to point to new_llm_configs for their agent LLM
# If a search space had an agent_llm_id pointing to old llm_configs,
# try to find the corresponding config in new_llm_configs
op.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'llm_configs'
) THEN
-- Update agent_llm_id to point to migrated config in new_llm_configs
UPDATE searchspaces ss
SET agent_llm_id = (
SELECT nlc.id
FROM new_llm_configs nlc
JOIN llm_configs lc ON lc.name = nlc.name AND lc.search_space_id = nlc.search_space_id
WHERE lc.id = ss.agent_llm_id
AND nlc.search_space_id = ss.id
LIMIT 1
)
WHERE ss.agent_llm_id IS NOT NULL
AND ss.agent_llm_id > 0 -- Only positive IDs (not global configs)
AND EXISTS (
SELECT 1 FROM llm_configs lc WHERE lc.id = ss.agent_llm_id
);
-- Update document_summary_llm_id similarly
UPDATE searchspaces ss
SET document_summary_llm_id = (
SELECT nlc.id
FROM new_llm_configs nlc
JOIN llm_configs lc ON lc.name = nlc.name AND lc.search_space_id = nlc.search_space_id
WHERE lc.id = ss.document_summary_llm_id
AND nlc.search_space_id = ss.id
LIMIT 1
)
WHERE ss.document_summary_llm_id IS NOT NULL
AND ss.document_summary_llm_id > 0 -- Only positive IDs (not global configs)
AND EXISTS (
SELECT 1 FROM llm_configs lc WHERE lc.id = ss.document_summary_llm_id
);
END IF;
END$$;
"""
)
# STEP 3: Drop the is_default column from new_llm_configs
# (role assignments now determine which config to use)
op.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'new_llm_configs' AND column_name = 'is_default'
) THEN
ALTER TABLE new_llm_configs DROP COLUMN is_default;
END IF;
END$$;
"""
)
# STEP 4: Drop the old llm_configs table (data has been migrated)
op.execute("DROP TABLE IF EXISTS llm_configs CASCADE")
def downgrade():
# Recreate the old llm_configs table
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'llm_configs'
) THEN
CREATE TABLE llm_configs (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
provider litellmprovider NOT NULL,
custom_provider VARCHAR(100),
model_name VARCHAR(100) NOT NULL,
api_key TEXT NOT NULL,
api_base VARCHAR(500),
language VARCHAR(50),
litellm_params JSONB DEFAULT '{}',
search_space_id INTEGER REFERENCES searchspaces(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE
);
-- Create indexes
CREATE INDEX IF NOT EXISTS ix_llm_configs_id ON llm_configs(id);
CREATE INDEX IF NOT EXISTS ix_llm_configs_name ON llm_configs(name);
CREATE INDEX IF NOT EXISTS ix_llm_configs_created_at ON llm_configs(created_at);
END IF;
END$$;
"""
)
# Migrate data back from new_llm_configs to llm_configs
op.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'new_llm_configs'
) THEN
INSERT INTO llm_configs (
name,
provider,
custom_provider,
model_name,
api_key,
api_base,
language,
litellm_params,
search_space_id,
created_at
)
SELECT
nlc.name,
nlc.provider,
nlc.custom_provider,
nlc.model_name,
nlc.api_key,
nlc.api_base,
'English' as language, -- Default language
COALESCE(nlc.litellm_params, '{}'::jsonb),
nlc.search_space_id,
nlc.created_at
FROM new_llm_configs nlc
WHERE nlc.search_space_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM llm_configs lc
WHERE lc.name = nlc.name
AND lc.search_space_id = nlc.search_space_id
);
END IF;
END$$;
"""
)
# Add back the is_default column to new_llm_configs
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'new_llm_configs' AND column_name = 'is_default'
) THEN
ALTER TABLE new_llm_configs ADD COLUMN is_default BOOLEAN NOT NULL DEFAULT FALSE;
END IF;
END$$;
"""
)

View file

@ -31,56 +31,50 @@ from .system_prompt import (
)
# Tools - registry exports
# Tools - factory exports (for direct use)
# Tools - knowledge base utilities
from .tools import (
BUILTIN_TOOLS,
ToolDefinition,
build_tools,
get_all_tool_names,
get_default_enabled_tools,
get_tool_by_name,
)
# Tools - factory exports (for direct use)
from .tools import (
create_display_image_tool,
create_generate_podcast_tool,
create_link_preview_tool,
create_scrape_webpage_tool,
create_search_knowledge_base_tool,
)
# Tools - knowledge base utilities
from .tools import (
format_documents_for_context,
get_all_tool_names,
get_default_enabled_tools,
get_tool_by_name,
search_knowledge_base_async,
)
__all__ = [
# Agent factory
"create_surfsense_deep_agent",
# Context
"SurfSenseContextSchema",
# LLM config
"create_chat_litellm_from_config",
"load_llm_config_from_yaml",
# Tools registry
"BUILTIN_TOOLS",
# System prompt
"SURFSENSE_CITATION_INSTRUCTIONS",
"SURFSENSE_SYSTEM_PROMPT",
"build_surfsense_system_prompt",
# Tools registry
"BUILTIN_TOOLS",
# Context
"SurfSenseContextSchema",
"ToolDefinition",
"build_surfsense_system_prompt",
"build_tools",
"get_all_tool_names",
"get_default_enabled_tools",
"get_tool_by_name",
# LLM config
"create_chat_litellm_from_config",
# Tool factories
"create_display_image_tool",
"create_generate_podcast_tool",
"create_link_preview_tool",
"create_scrape_webpage_tool",
"create_search_knowledge_base_tool",
# Agent factory
"create_surfsense_deep_agent",
# Knowledge base utilities
"format_documents_for_context",
"get_all_tool_names",
"get_default_enabled_tools",
"get_tool_by_name",
"load_llm_config_from_yaml",
"search_knowledge_base_async",
]

View file

@ -2,7 +2,8 @@
SurfSense deep agent implementation.
This module provides the factory function for creating SurfSense deep agents
with configurable tools via the tools registry.
with configurable tools via the tools registry and configurable prompts
via NewLLMConfig.
"""
from collections.abc import Sequence
@ -14,7 +15,11 @@ from langgraph.types import Checkpointer
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.context import SurfSenseContextSchema
from app.agents.new_chat.system_prompt import build_surfsense_system_prompt
from app.agents.new_chat.llm_config import AgentConfig
from app.agents.new_chat.system_prompt import (
build_configurable_system_prompt,
build_surfsense_system_prompt,
)
from app.agents.new_chat.tools import build_tools
from app.services.connector_service import ConnectorService
@ -29,13 +34,14 @@ def create_surfsense_deep_agent(
db_session: AsyncSession,
connector_service: ConnectorService,
checkpointer: Checkpointer,
agent_config: AgentConfig | None = None,
enabled_tools: list[str] | None = None,
disabled_tools: list[str] | None = None,
additional_tools: Sequence[BaseTool] | None = None,
firecrawl_api_key: str | None = None,
):
"""
Create a SurfSense deep agent with configurable tools.
Create a SurfSense deep agent with configurable tools and prompts.
The agent comes with built-in tools that can be configured:
- search_knowledge_base: Search the user's personal knowledge base
@ -44,6 +50,10 @@ def create_surfsense_deep_agent(
- display_image: Display images in chat
- scrape_webpage: Extract content from webpages
The system prompt can be configured via agent_config:
- Custom system instructions (or use defaults)
- Citation toggle (enable/disable citation requirements)
Args:
llm: ChatLiteLLM instance for the agent's language model
search_space_id: The user's search space ID
@ -51,6 +61,8 @@ def create_surfsense_deep_agent(
connector_service: Initialized connector service for knowledge base search
checkpointer: LangGraph checkpointer for conversation state persistence.
Use AsyncPostgresSaver for production or MemorySaver for testing.
agent_config: Optional AgentConfig from NewLLMConfig for prompt configuration.
If None, uses default system prompt with citations enabled.
enabled_tools: Explicit list of tool names to enable. If None, all default tools
are enabled. Use this to limit which tools are available.
disabled_tools: List of tool names to disable. Applied after enabled_tools.
@ -64,9 +76,21 @@ def create_surfsense_deep_agent(
CompiledStateGraph: The configured deep agent
Examples:
# Create agent with all default tools
# Create agent with all default tools and default prompt
agent = create_surfsense_deep_agent(llm, search_space_id, db_session, ...)
# Create agent with custom prompt configuration
agent = create_surfsense_deep_agent(
llm, search_space_id, db_session, ...,
agent_config=AgentConfig(
provider="OPENAI",
model_name="gpt-4",
api_key="...",
system_instructions="Custom instructions...",
citations_enabled=False,
)
)
# Create agent with only specific tools
agent = create_surfsense_deep_agent(
llm, search_space_id, db_session, ...,
@ -101,11 +125,23 @@ def create_surfsense_deep_agent(
additional_tools=list(additional_tools) if additional_tools else None,
)
# Build system prompt based on agent_config
if agent_config is not None:
# Use configurable prompt with settings from NewLLMConfig
system_prompt = build_configurable_system_prompt(
custom_system_instructions=agent_config.system_instructions,
use_default_system_instructions=agent_config.use_default_system_instructions,
citations_enabled=agent_config.citations_enabled,
)
else:
# Use default prompt (with citations enabled)
system_prompt = build_surfsense_system_prompt()
# Create the deep agent with system prompt and checkpointer
agent = create_deep_agent(
model=llm,
tools=tools,
system_prompt=build_surfsense_system_prompt(),
system_prompt=system_prompt,
context_schema=SurfSenseContextSchema,
checkpointer=checkpointer,
)

View file

@ -1,14 +1,144 @@
"""
LLM configuration utilities for SurfSense agents.
This module provides functions for loading LLM configurations from YAML files
and creating ChatLiteLLM instances from configuration dictionaries.
This module provides functions for loading LLM configurations from:
1. YAML files (global configs with negative IDs)
2. Database NewLLMConfig table (user-created configs with positive IDs)
It also provides utilities for creating ChatLiteLLM instances and
managing prompt configurations.
"""
from dataclasses import dataclass
from pathlib import Path
import yaml
from langchain_litellm import ChatLiteLLM
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
# Provider mapping for LiteLLM model string construction
PROVIDER_MAP = {
"OPENAI": "openai",
"ANTHROPIC": "anthropic",
"GROQ": "groq",
"COHERE": "cohere",
"GOOGLE": "gemini",
"OLLAMA": "ollama",
"MISTRAL": "mistral",
"AZURE_OPENAI": "azure",
"OPENROUTER": "openrouter",
"XAI": "xai",
"BEDROCK": "bedrock",
"VERTEX_AI": "vertex_ai",
"TOGETHER_AI": "together_ai",
"FIREWORKS_AI": "fireworks_ai",
"DEEPSEEK": "openai",
"ALIBABA_QWEN": "openai",
"MOONSHOT": "openai",
"ZHIPU": "openai",
"REPLICATE": "replicate",
"PERPLEXITY": "perplexity",
"ANYSCALE": "anyscale",
"DEEPINFRA": "deepinfra",
"CEREBRAS": "cerebras",
"SAMBANOVA": "sambanova",
"AI21": "ai21",
"CLOUDFLARE": "cloudflare",
"DATABRICKS": "databricks",
"COMETAPI": "cometapi",
"HUGGINGFACE": "huggingface",
"CUSTOM": "custom",
}
@dataclass
class AgentConfig:
"""
Complete configuration for the SurfSense agent.
This combines LLM settings with prompt configuration from NewLLMConfig.
"""
# LLM Model Settings
provider: str
model_name: str
api_key: str
api_base: str | None = None
custom_provider: str | None = None
litellm_params: dict | None = None
# Prompt Configuration
system_instructions: str | None = None
use_default_system_instructions: bool = True
citations_enabled: bool = True
# Metadata
config_id: int | None = None
config_name: str | None = None
@classmethod
def from_new_llm_config(cls, config) -> "AgentConfig":
"""
Create an AgentConfig from a NewLLMConfig database model.
Args:
config: NewLLMConfig database model instance
Returns:
AgentConfig instance
"""
return cls(
provider=config.provider.value
if hasattr(config.provider, "value")
else str(config.provider),
model_name=config.model_name,
api_key=config.api_key,
api_base=config.api_base,
custom_provider=config.custom_provider,
litellm_params=config.litellm_params,
system_instructions=config.system_instructions,
use_default_system_instructions=config.use_default_system_instructions,
citations_enabled=config.citations_enabled,
config_id=config.id,
config_name=config.name,
)
@classmethod
def from_yaml_config(cls, yaml_config: dict) -> "AgentConfig":
"""
Create an AgentConfig from a YAML configuration dictionary.
YAML configs now support the same prompt configuration fields as NewLLMConfig:
- system_instructions: Custom system instructions (empty string uses defaults)
- use_default_system_instructions: Whether to use default instructions
- citations_enabled: Whether citations are enabled
Args:
yaml_config: Configuration dictionary from YAML file
Returns:
AgentConfig instance
"""
# Get system instructions from YAML, default to empty string
system_instructions = yaml_config.get("system_instructions", "")
return cls(
provider=yaml_config.get("provider", "").upper(),
model_name=yaml_config.get("model_name", ""),
api_key=yaml_config.get("api_key", ""),
api_base=yaml_config.get("api_base"),
custom_provider=yaml_config.get("custom_provider"),
litellm_params=yaml_config.get("litellm_params"),
# Prompt configuration from YAML (with defaults for backwards compatibility)
system_instructions=system_instructions if system_instructions else None,
use_default_system_instructions=yaml_config.get(
"use_default_system_instructions", True
),
citations_enabled=yaml_config.get("citations_enabled", True),
config_id=yaml_config.get("id"),
config_name=yaml_config.get("name"),
)
def load_llm_config_from_yaml(llm_config_id: int = -1) -> dict | None:
@ -47,9 +177,118 @@ def load_llm_config_from_yaml(llm_config_id: int = -1) -> dict | None:
return None
async def load_new_llm_config_from_db(
session: AsyncSession,
config_id: int,
) -> "AgentConfig | None":
"""
Load a NewLLMConfig from the database by ID.
Args:
session: AsyncSession for database access
config_id: The ID of the NewLLMConfig to load
Returns:
AgentConfig instance or None if not found
"""
# Import here to avoid circular imports
from app.db import NewLLMConfig
try:
result = await session.execute(
select(NewLLMConfig).filter(NewLLMConfig.id == config_id)
)
config = result.scalars().first()
if not config:
print(f"Error: NewLLMConfig with id {config_id} not found")
return None
return AgentConfig.from_new_llm_config(config)
except Exception as e:
print(f"Error loading NewLLMConfig from database: {e}")
return None
async def load_agent_llm_config_for_search_space(
session: AsyncSession,
search_space_id: int,
) -> "AgentConfig | None":
"""
Load the agent LLM configuration for a search space.
This loads the LLM config based on the search space's agent_llm_id setting:
- Positive ID: Load from NewLLMConfig database table
- Negative ID: Load from YAML global configs
- None: Falls back to first global config (id=-1)
Args:
session: AsyncSession for database access
search_space_id: The search space ID
Returns:
AgentConfig instance or None if not found
"""
# Import here to avoid circular imports
from app.db import SearchSpace
try:
# Get the search space to check its agent_llm_id preference
result = await session.execute(
select(SearchSpace).filter(SearchSpace.id == search_space_id)
)
search_space = result.scalars().first()
if not search_space:
print(f"Error: SearchSpace with id {search_space_id} not found")
return None
# Use agent_llm_id from search space, fallback to -1 (first global config)
config_id = (
search_space.agent_llm_id if search_space.agent_llm_id is not None else -1
)
# Load the config using the unified loader
return await load_agent_config(session, config_id, search_space_id)
except Exception as e:
print(f"Error loading agent LLM config for search space {search_space_id}: {e}")
return None
async def load_agent_config(
session: AsyncSession,
config_id: int,
search_space_id: int | None = None,
) -> "AgentConfig | None":
"""
Load an agent configuration, supporting both YAML (negative IDs) and database (positive IDs) configs.
This is the main entry point for loading configurations:
- Negative IDs: Load from YAML file (global configs)
- Positive IDs: Load from NewLLMConfig database table
Args:
session: AsyncSession for database access
config_id: The config ID (negative for YAML, positive for database)
search_space_id: Optional search space ID for context
Returns:
AgentConfig instance or None if not found
"""
if config_id < 0:
# Load from YAML (global configs have negative IDs)
yaml_config = load_llm_config_from_yaml(config_id)
if yaml_config:
return AgentConfig.from_yaml_config(yaml_config)
return None
else:
# Load from database (NewLLMConfig)
return await load_new_llm_config_from_db(session, config_id)
def create_chat_litellm_from_config(llm_config: dict) -> ChatLiteLLM | None:
"""
Create a ChatLiteLLM instance from a global LLM config.
Create a ChatLiteLLM instance from a global LLM config dictionary.
Args:
llm_config: LLM configuration dictionary from YAML
@ -57,34 +296,12 @@ def create_chat_litellm_from_config(llm_config: dict) -> ChatLiteLLM | None:
Returns:
ChatLiteLLM instance or None on error
"""
# Provider mapping (same as in llm_service.py)
provider_map = {
"OPENAI": "openai",
"ANTHROPIC": "anthropic",
"GROQ": "groq",
"COHERE": "cohere",
"GOOGLE": "gemini",
"OLLAMA": "ollama",
"MISTRAL": "mistral",
"AZURE_OPENAI": "azure",
"OPENROUTER": "openrouter",
"XAI": "xai",
"BEDROCK": "bedrock",
"VERTEX_AI": "vertex_ai",
"TOGETHER_AI": "together_ai",
"FIREWORKS_AI": "fireworks_ai",
"DEEPSEEK": "openai",
"ALIBABA_QWEN": "openai",
"MOONSHOT": "openai",
"ZHIPU": "openai",
}
# Build the model string
if llm_config.get("custom_provider"):
model_string = f"{llm_config['custom_provider']}/{llm_config['model_name']}"
else:
provider = llm_config.get("provider", "").upper()
provider_prefix = provider_map.get(provider, provider.lower())
provider_prefix = PROVIDER_MAP.get(provider, provider.lower())
model_string = f"{provider_prefix}/{llm_config['model_name']}"
# Create ChatLiteLLM instance with streaming enabled
@ -103,3 +320,42 @@ def create_chat_litellm_from_config(llm_config: dict) -> ChatLiteLLM | None:
litellm_kwargs.update(llm_config["litellm_params"])
return ChatLiteLLM(**litellm_kwargs)
def create_chat_litellm_from_agent_config(
agent_config: AgentConfig,
) -> ChatLiteLLM | None:
"""
Create a ChatLiteLLM instance from an AgentConfig.
Args:
agent_config: AgentConfig instance
Returns:
ChatLiteLLM instance or None on error
"""
# Build the model string
if agent_config.custom_provider:
model_string = f"{agent_config.custom_provider}/{agent_config.model_name}"
else:
provider_prefix = PROVIDER_MAP.get(
agent_config.provider, agent_config.provider.lower()
)
model_string = f"{provider_prefix}/{agent_config.model_name}"
# Create ChatLiteLLM instance with streaming enabled
litellm_kwargs = {
"model": model_string,
"api_key": agent_config.api_key,
"streaming": True, # Enable streaming for real-time token streaming
}
# Add optional parameters
if agent_config.api_base:
litellm_kwargs["api_base"] = agent_config.api_base
# Add any additional litellm parameters
if agent_config.litellm_params:
litellm_kwargs.update(agent_config.litellm_params)
return ChatLiteLLM(**litellm_kwargs)

View file

@ -3,10 +3,16 @@ System prompt building for SurfSense agents.
This module provides functions and constants for building the SurfSense system prompt
with configurable user instructions and citation support.
The prompt is composed of three parts:
1. System Instructions (configurable via NewLLMConfig)
2. Tools Instructions (always included, not configurable)
3. Citation Instructions (toggleable via NewLLMConfig.citations_enabled)
"""
from datetime import UTC, datetime
# Default system instructions - can be overridden via NewLLMConfig.system_instructions
SURFSENSE_SYSTEM_INSTRUCTIONS = """
<system_instruction>
You are SurfSense, a reasoning and acting AI agent designed to answer user questions using the user's personal knowledge base.
@ -219,12 +225,38 @@ However, from your video learning, it's important to note that asyncio is not su
</citation_instructions>
"""
# Anti-citation prompt - used when citations are disabled
# This explicitly tells the model NOT to include citations
SURFSENSE_NO_CITATION_INSTRUCTIONS = """
<citation_instructions>
IMPORTANT: Citations are DISABLED for this configuration.
DO NOT include any citations in your responses. Specifically:
1. Do NOT use the [citation:chunk_id] format anywhere in your response.
2. Do NOT reference document IDs, chunk IDs, or source IDs.
3. Simply provide the information naturally without any citation markers.
4. Write your response as if you're having a normal conversation, incorporating the information from your knowledge seamlessly.
When answering questions based on documents from the knowledge base:
- Present the information directly and confidently
- Do not mention that information comes from specific documents or chunks
- Integrate facts naturally into your response without attribution markers
Your goal is to provide helpful, informative answers in a clean, readable format without any citation notation.
</citation_instructions>
"""
def build_surfsense_system_prompt(
today: datetime | None = None,
) -> str:
"""
Build the SurfSense system prompt.
Build the SurfSense system prompt with default settings.
This is a convenience function that builds the prompt with:
- Default system instructions
- Tools instructions (always included)
- Citation instructions enabled
Args:
today: Optional datetime for today's date (defaults to current UTC date)
@ -241,4 +273,74 @@ def build_surfsense_system_prompt(
)
def build_configurable_system_prompt(
custom_system_instructions: str | None = None,
use_default_system_instructions: bool = True,
citations_enabled: bool = True,
today: datetime | None = None,
) -> str:
"""
Build a configurable SurfSense system prompt based on NewLLMConfig settings.
The prompt is composed of three parts:
1. System Instructions - either custom or default SURFSENSE_SYSTEM_INSTRUCTIONS
2. Tools Instructions - always included (SURFSENSE_TOOLS_INSTRUCTIONS)
3. Citation Instructions - either SURFSENSE_CITATION_INSTRUCTIONS or SURFSENSE_NO_CITATION_INSTRUCTIONS
Args:
custom_system_instructions: Custom system instructions to use. If empty/None and
use_default_system_instructions is True, defaults to
SURFSENSE_SYSTEM_INSTRUCTIONS.
use_default_system_instructions: Whether to use default instructions when
custom_system_instructions is empty/None.
citations_enabled: Whether to include citation instructions (True) or
anti-citation instructions (False).
today: Optional datetime for today's date (defaults to current UTC date)
Returns:
Complete system prompt string
"""
resolved_today = (today or datetime.now(UTC)).astimezone(UTC).date().isoformat()
# Determine system instructions
if custom_system_instructions and custom_system_instructions.strip():
# Use custom instructions, injecting the date placeholder if present
system_instructions = custom_system_instructions.format(
resolved_today=resolved_today
)
elif use_default_system_instructions:
# Use default instructions
system_instructions = SURFSENSE_SYSTEM_INSTRUCTIONS.format(
resolved_today=resolved_today
)
else:
# No system instructions (edge case)
system_instructions = ""
# Tools instructions are always included
tools_instructions = SURFSENSE_TOOLS_INSTRUCTIONS
# Citation instructions based on toggle
citation_instructions = (
SURFSENSE_CITATION_INSTRUCTIONS
if citations_enabled
else SURFSENSE_NO_CITATION_INSTRUCTIONS
)
return system_instructions + tools_instructions + citation_instructions
def get_default_system_instructions() -> str:
"""
Get the default system instructions template.
This is useful for populating the UI with the default value when
creating a new NewLLMConfig.
Returns:
Default system instructions string (with {resolved_today} placeholder)
"""
return SURFSENSE_SYSTEM_INSTRUCTIONS.strip()
SURFSENSE_SYSTEM_PROMPT = build_surfsense_system_prompt()

View file

@ -13,15 +13,6 @@ Available tools:
"""
# Registry exports
from .registry import (
BUILTIN_TOOLS,
ToolDefinition,
build_tools,
get_all_tool_names,
get_default_enabled_tools,
get_tool_by_name,
)
# Tool factory exports (for direct use)
from .display_image import create_display_image_tool
from .knowledge_base import (
@ -31,6 +22,14 @@ from .knowledge_base import (
)
from .link_preview import create_link_preview_tool
from .podcast import create_generate_podcast_tool
from .registry import (
BUILTIN_TOOLS,
ToolDefinition,
build_tools,
get_all_tool_names,
get_default_enabled_tools,
get_tool_by_name,
)
from .scrape_webpage import create_scrape_webpage_tool
__all__ = [
@ -38,9 +37,6 @@ __all__ = [
"BUILTIN_TOOLS",
"ToolDefinition",
"build_tools",
"get_all_tool_names",
"get_default_enabled_tools",
"get_tool_by_name",
# Tool factories
"create_display_image_tool",
"create_generate_podcast_tool",
@ -49,6 +45,8 @@ __all__ = [
"create_search_knowledge_base_tool",
# Knowledge base utilities
"format_documents_for_context",
"get_all_tool_names",
"get_default_enabled_tools",
"get_tool_by_name",
"search_knowledge_base_async",
]

View file

@ -86,7 +86,9 @@ def create_display_image_tool():
ratio = "16:9" # Default
if "unsplash.com" in src or "pexels.com" in src:
ratio = "16:9"
elif "imgur.com" in src or "github.com" in src or "githubusercontent.com" in src:
elif (
"imgur.com" in src or "github.com" in src or "githubusercontent.com" in src
):
ratio = "auto"
return {
@ -101,4 +103,3 @@ def create_display_image_tool():
}
return display_image

View file

@ -605,4 +605,3 @@ def create_search_knowledge_base_tool(
)
return search_knowledge_base

View file

@ -46,13 +46,17 @@ def extract_og_content(html: str, property_name: str) -> str | None:
def extract_twitter_content(html: str, name: str) -> str | None:
"""Extract Twitter Card meta content from HTML."""
pattern = rf'<meta[^>]+name=["\']twitter:{name}["\'][^>]+content=["\']([^"\']+)["\']'
pattern = (
rf'<meta[^>]+name=["\']twitter:{name}["\'][^>]+content=["\']([^"\']+)["\']'
)
match = re.search(pattern, html, re.IGNORECASE)
if match:
return match.group(1)
# Try content before name
pattern = rf'<meta[^>]+content=["\']([^"\']+)["\'][^>]+name=["\']twitter:{name}["\']'
pattern = (
rf'<meta[^>]+content=["\']([^"\']+)["\'][^>]+name=["\']twitter:{name}["\']'
)
match = re.search(pattern, html, re.IGNORECASE)
if match:
return match.group(1)
@ -289,4 +293,3 @@ def create_link_preview_tool():
}
return link_preview

View file

@ -171,4 +171,3 @@ def create_generate_podcast_tool(
}
return generate_podcast

View file

@ -37,11 +37,18 @@ Example of adding a new tool:
),
"""
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any, Callable
from typing import Any
from langchain_core.tools import BaseTool
from .display_image import create_display_image_tool
from .knowledge_base import create_search_knowledge_base_tool
from .link_preview import create_link_preview_tool
from .podcast import create_generate_podcast_tool
from .scrape_webpage import create_scrape_webpage_tool
# =============================================================================
# Tool Definition
# =============================================================================
@ -71,13 +78,6 @@ class ToolDefinition:
# Built-in Tools Registry
# =============================================================================
# Import tool factory functions
from .display_image import create_display_image_tool
from .knowledge_base import create_search_knowledge_base_tool
from .link_preview import create_link_preview_tool
from .podcast import create_generate_podcast_tool
from .scrape_webpage import create_scrape_webpage_tool
# Registry of all built-in tools
# Contributors: Add your new tools here!
BUILTIN_TOOLS: list[ToolDefinition] = [
@ -228,4 +228,3 @@ def build_tools(
tools.extend(additional_tools)
return tools

View file

@ -156,7 +156,9 @@ def create_scrape_webpage_tool(firecrawl_api_key: str | None = None):
if not description and content:
# Use first paragraph as description
first_para = content.split("\n\n")[0] if content else ""
description = first_para[:300] + "..." if len(first_para) > 300 else first_para
description = (
first_para[:300] + "..." if len(first_para) > 300 else first_para
)
# Truncate content if needed
content, was_truncated = truncate_content(content, max_length)
@ -194,4 +196,3 @@ def create_scrape_webpage_tool(firecrawl_api_key: str | None = None):
}
return scrape_webpage

View file

@ -12,7 +12,7 @@ from litellm import aspeech
from app.config import config as app_config
from app.services.kokoro_tts_service import get_kokoro_tts_service
from app.services.llm_service import get_long_context_llm
from app.services.llm_service import get_document_summary_llm
from .configuration import Configuration
from .prompts import get_podcast_generation_prompt
@ -30,10 +30,12 @@ async def create_podcast_transcript(
search_space_id = configuration.search_space_id
user_prompt = configuration.user_prompt
# Get search space's long context LLM
llm = await get_long_context_llm(state.db_session, search_space_id)
# Get search space's document summary LLM
llm = await get_document_summary_llm(state.db_session, search_space_id)
if not llm:
error_message = f"No long context LLM configured for search space {search_space_id}"
error_message = (
f"No document summary LLM configured for search space {search_space_id}"
)
print(error_message)
raise RuntimeError(error_message)

View file

@ -35,12 +35,6 @@ def load_global_llm_configs():
# Try main config file first
global_config_file = BASE_DIR / "app" / "config" / "global_llm_config.yaml"
# Fall back to example file for testing
# if not global_config_file.exists():
# global_config_file = BASE_DIR / "app" / "config" / "global_llm_config.example.yaml"
# if global_config_file.exists():
# print("Info: Using global_llm_config.example.yaml (copy to global_llm_config.yaml for production)")
if not global_config_file.exists():
# No global configs available
return []

View file

@ -9,72 +9,101 @@
#
# These configurations will be available to all users as a convenient option
# Users can choose to use these global configs or add their own
#
# Structure matches NewLLMConfig:
# - LLM model configuration (provider, model_name, api_key, etc.)
# - Prompt configuration (system_instructions, citations_enabled)
global_llm_configs:
# Example: OpenAI GPT-4 Turbo
# Example: OpenAI GPT-4 Turbo with citations enabled
- id: -1
name: "Global GPT-4 Turbo"
description: "OpenAI's GPT-4 Turbo with default prompts and citations"
provider: "OPENAI"
model_name: "gpt-4-turbo-preview"
api_key: "sk-your-openai-api-key-here"
api_base: ""
language: "English"
litellm_params:
temperature: 0.7
max_tokens: 4000
# Prompt Configuration
system_instructions: "" # Empty = use default SURFSENSE_SYSTEM_INSTRUCTIONS
use_default_system_instructions: true
citations_enabled: true
# Example: Anthropic Claude 3 Opus
- id: -2
name: "Global Claude 3 Opus"
description: "Anthropic's most capable model with citations"
provider: "ANTHROPIC"
model_name: "claude-3-opus-20240229"
api_key: "sk-ant-your-anthropic-api-key-here"
api_base: ""
language: "English"
litellm_params:
temperature: 0.7
max_tokens: 4000
system_instructions: ""
use_default_system_instructions: true
citations_enabled: true
# Example: Fast model - GPT-3.5 Turbo
# Example: Fast model - GPT-3.5 Turbo (citations disabled for speed)
- id: -3
name: "Global GPT-3.5 Turbo"
name: "Global GPT-3.5 Turbo (Fast)"
description: "Fast responses without citations for quick queries"
provider: "OPENAI"
model_name: "gpt-3.5-turbo"
api_key: "sk-your-openai-api-key-here"
api_base: ""
language: "English"
litellm_params:
temperature: 0.5
max_tokens: 2000
system_instructions: ""
use_default_system_instructions: true
citations_enabled: false # Disabled for faster responses
# Example: Chinese LLM - DeepSeek
# Example: Chinese LLM - DeepSeek with custom instructions
- id: -4
name: "Global DeepSeek Chat"
name: "Global DeepSeek Chat (Chinese)"
description: "DeepSeek optimized for Chinese language responses"
provider: "DEEPSEEK"
model_name: "deepseek-chat"
api_key: "your-deepseek-api-key-here"
api_base: "https://api.deepseek.com/v1"
language: "Chinese"
litellm_params:
temperature: 0.7
max_tokens: 4000
# Custom system instructions for Chinese responses
system_instructions: |
<system_instruction>
You are SurfSense, a reasoning and acting AI agent designed to answer user questions using the user's personal knowledge base.
Today's date (UTC): {resolved_today}
IMPORTANT: Please respond in Chinese (简体中文) unless the user specifically requests another language.
</system_instruction>
use_default_system_instructions: false
citations_enabled: true
# Example: Groq - Fast inference
- id: -5
name: "Global Groq Llama 3"
description: "Ultra-fast Llama 3 70B via Groq"
provider: "GROQ"
model_name: "llama3-70b-8192"
api_key: "your-groq-api-key-here"
api_base: ""
language: "English"
litellm_params:
temperature: 0.7
max_tokens: 8000
system_instructions: ""
use_default_system_instructions: true
citations_enabled: true
# Notes:
# - Use negative IDs to distinguish global configs from user configs
# - Use negative IDs to distinguish global configs from user configs (NewLLMConfig in DB)
# - IDs should be unique and sequential (e.g., -1, -2, -3, etc.)
# - The 'api_key' field will not be exposed to users via API
# - Users can select these configs for their long_context, fast, or strategic LLM roles
# - system_instructions: Custom prompt or empty string to use defaults
# - use_default_system_instructions: true = use SURFSENSE_SYSTEM_INSTRUCTIONS when system_instructions is empty
# - citations_enabled: true = include citation instructions, false = include anti-citation instructions
# - All standard LiteLLM providers are supported

View file

@ -452,9 +452,10 @@ class SearchSpace(BaseModel, TimestampMixin):
# Search space-level LLM preferences (shared by all members)
# Note: These can be negative IDs for global configs (from YAML) or positive IDs for custom configs (from DB)
long_context_llm_id = Column(Integer, nullable=True)
fast_llm_id = Column(Integer, nullable=True)
strategic_llm_id = Column(Integer, nullable=True)
agent_llm_id = Column(Integer, nullable=True) # For agent/chat operations
document_summary_llm_id = Column(
Integer, nullable=True
) # For document summarization
user_id = Column(
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
@ -491,10 +492,10 @@ class SearchSpace(BaseModel, TimestampMixin):
order_by="SearchSourceConnector.id",
cascade="all, delete-orphan",
)
llm_configs = relationship(
"LLMConfig",
new_llm_configs = relationship(
"NewLLMConfig",
back_populates="search_space",
order_by="LLMConfig.id",
order_by="NewLLMConfig.id",
cascade="all, delete-orphan",
)
@ -553,10 +554,24 @@ class SearchSourceConnector(BaseModel, TimestampMixin):
)
class LLMConfig(BaseModel, TimestampMixin):
__tablename__ = "llm_configs"
class NewLLMConfig(BaseModel, TimestampMixin):
"""
New LLM configuration table that combines model settings with prompt configuration.
This table provides:
- LLM model configuration (provider, model_name, api_key, etc.)
- Configurable system instructions (defaults to SURFSENSE_SYSTEM_INSTRUCTIONS)
- Citation toggle (enable/disable citation instructions)
Note: SURFSENSE_TOOLS_INSTRUCTIONS is always used and not configurable.
"""
__tablename__ = "new_llm_configs"
name = Column(String(100), nullable=False, index=True)
description = Column(String(500), nullable=True)
# === LLM Model Configuration (from original LLMConfig, excluding 'language') ===
# Provider from the enum
provider = Column(SQLAlchemyEnum(LiteLLMProvider), nullable=False)
# Custom provider name when provider is CUSTOM
@ -566,16 +581,29 @@ class LLMConfig(BaseModel, TimestampMixin):
# API Key should be encrypted before storing
api_key = Column(String, nullable=False)
api_base = Column(String(500), nullable=True)
language = Column(String(50), nullable=True, default="English")
# For any other parameters that litellm supports
litellm_params = Column(JSON, nullable=True, default={})
# === Prompt Configuration ===
# Configurable system instructions (defaults to SURFSENSE_SYSTEM_INSTRUCTIONS)
# Users can customize this from the UI
system_instructions = Column(
Text,
nullable=False,
default="", # Empty string means use default SURFSENSE_SYSTEM_INSTRUCTIONS
)
# Whether to use the default system instructions when system_instructions is empty
use_default_system_instructions = Column(Boolean, nullable=False, default=True)
# Citation toggle - when enabled, SURFSENSE_CITATION_INSTRUCTIONS is injected
# When disabled, an anti-citation prompt is injected instead
citations_enabled = Column(Boolean, nullable=False, default=True)
# === Relationships ===
search_space_id = Column(
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False
)
search_space = relationship("SearchSpace", back_populates="llm_configs")
search_space = relationship("SearchSpace", back_populates="new_llm_configs")
class Log(BaseModel, TimestampMixin):

View file

@ -1,190 +0,0 @@
prompts:
# Developer-focused prompts
- key: ethereum_developer
value: "Imagine you are an experienced Ethereum developer tasked with creating a smart contract for a blockchain messenger. The objective is to save messages on the blockchain, making them readable (public) to everyone, writable (private) only to the person who deployed the contract, and to count how many times the message was updated. Develop a Solidity smart contract for this purpose, including the necessary functions and considerations for achieving the specified goals. Please provide the code and any relevant explanations to ensure a clear understanding of the implementation."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "developer"
- key: linux_terminal
value: "I want you to act as a linux terminal. I will type commands and you will reply with what the terminal should show. I want you to only reply with the terminal output inside one unique code block, and nothing else. do not write explanations. do not type commands unless I instruct you to do so. when i need to tell you something in english, i will do so by putting text inside curly brackets {like this}."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "developer"
- key: javascript_console
value: "I want you to act as a javascript console. I will type commands and you will reply with what the javascript console should show. I want you to only reply with the terminal output inside one unique code block, and nothing else. do not write explanations. do not type commands unless I instruct you to do so. when i need to tell you something in english, i will do so by putting text inside curly brackets {like this}."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "developer"
- key: fullstack_developer
value: "I want you to act as a software developer. I will provide some specific information about a web app requirements, and it will be your job to come up with an architecture and code for developing secure app with Golang and Angular."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "developer"
- key: regex_generator
value: "I want you to act as a regex generator. Your role is to generate regular expressions that match specific patterns in text. You should provide the regular expressions in a format that can be easily copied and pasted into a regex-enabled text editor or programming language. Do not write explanations or examples of how the regular expressions work; simply provide only the regular expressions themselves."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "developer"
- key: senior_frontend_developer
value: "I want you to act as a Senior Frontend developer. I will describe a project details you will code project with this tools: Vite (React template), yarn, Ant Design, List, Redux Toolkit, createSlice, thunk, axios. You should merge files in single index.js file and nothing else. Do not write explanations."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "developer"
- key: code_reviewer
value: "I want you to act as a Code reviewer who is experienced developer in the given code language. I will provide you with the code block or methods or code file along with the code language name, and I would like you to review the code and share the feedback, suggestions and alternative recommended approaches. Please write explanations behind the feedback or suggestions or alternative approaches."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "developer"
- key: machine_learning_engineer
value: "I want you to act as a machine learning engineer. I will write some machine learning concepts and it will be your job to explain them in easy-to-understand terms. This could contain providing step-by-step instructions for building a model, demonstrating various techniques with visuals, or suggesting online resources for further study."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "developer"
- key: sql_terminal
value: "I want you to act as a SQL terminal in front of an example database. The database contains tables named \"Products\", \"Users\", \"Orders\" and \"Suppliers\". I will type queries and you will reply with what the terminal would show. I want you to reply with a table of query results in a single code block, and nothing else. Do not write explanations. Do not type commands unless I instruct you to do so. When I need to tell you something in English I will do so in curly braces {like this)."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "developer"
- key: python_interpreter
value: "Act as a Python interpreter. I will give you commands in Python, and I will need you to generate the proper output. Only say the output. But if there is none, say nothing, and don't give me an explanation. If I need to say something, I will do so through comments."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "developer"
- key: devops_engineer
value: "You are a Senior DevOps engineer working at a Big Company. Your role is to provide scalable, efficient, and automated solutions for software deployment, infrastructure management, and CI/CD pipelines. Suggest the best DevOps practices, including infrastructure setup, deployment strategies, automation tools, and cost-effective scaling solutions."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "developer"
- key: cyber_security_specialist
value: "I want you to act as a cyber security specialist. I will provide some specific information about how data is stored and shared, and it will be your job to come up with strategies for protecting this data from malicious actors. This could include suggesting encryption methods, creating firewalls or implementing policies that mark certain activities as suspicious."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "developer"
# General productivity prompts
- key: english_translator
value: "I want you to act as an English translator, spelling corrector and improver. I will speak to you in any language and you will detect the language, translate it and answer in the corrected and improved version of my text, in English. I want you to replace my simplified A0-level words and sentences with more beautiful and elegant, upper level English words and sentences. Keep the meaning same, but make them more literary. I want you to only reply the correction, the improvements and nothing else, do not write explanations."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "general"
- key: proofreader
value: "I want you act as a proofreader. I will provide you texts and I would like you to review them for any spelling, grammar, or punctuation errors. Once you have finished reviewing the text, provide me with any necessary corrections or suggestions for improve the text."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "general"
- key: note_taking_assistant
value: "I want you to act as a note-taking assistant for a lecture. Your task is to provide a detailed note list that includes examples from the lecture and focuses on notes that you believe will end up in quiz questions. Additionally, please make a separate list for notes that have numbers and data in them and another separated list for the examples that included in this lecture. The notes should be concise and easy to read."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "general"
- key: essay_writer
value: "I want you to act as an essay writer. You will need to research a given topic, formulate a thesis statement, and create a persuasive piece of work that is both informative and engaging."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "general"
- key: career_counselor
value: "I want you to act as a career counselor. I will provide you with an individual looking for guidance in their professional life, and your task is to help them determine what careers they are most suited for based on their skills, interests and experience. You should also conduct research into the various options available, explain the job market trends in different industries and advice on which qualifications would be beneficial for pursuing particular fields."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "general"
- key: life_coach
value: "I want you to act as a life coach. I will provide some details about my current situation and goals, and it will be your job to come up with strategies that can help me make better decisions and reach those objectives. This could involve offering advice on various topics, such as creating plans for achieving success or dealing with difficult emotions."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "general"
- key: motivational_coach
value: "I want you to act as a motivational coach. I will provide you with some information about someone's goals and challenges, and it will be your job to come up with strategies that can help this person achieve their goals. This could involve providing positive affirmations, giving helpful advice or suggesting activities they can do to reach their end goal."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "general"
- key: travel_guide
value: "I want you to act as a travel guide. I will write you my location and you will suggest a place to visit near my location. In some cases, I will also give you the type of places I will visit. You will also suggest me places of similar type that are close to my first location."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "general"
# Creative prompts
- key: storyteller
value: "I want you to act as a storyteller. You will come up with entertaining stories that are engaging, imaginative and captivating for the audience. It can be fairy tales, educational stories or any other type of stories which has the potential to capture people's attention and imagination. Depending on the target audience, you may choose specific themes or topics for your storytelling session e.g., if it's children then you can talk about animals; If it's adults then history-based tales might engage them better etc."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "creative"
- key: screenwriter
value: "I want you to act as a screenwriter. You will develop an engaging and creative script for either a feature length film, or a Web Series that can captivate its viewers. Start with coming up with interesting characters, the setting of the story, dialogues between the characters etc. Once your character development is complete - create an exciting storyline filled with twists and turns that keeps the viewers in suspense until the end."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "creative"
- key: novelist
value: "I want you to act as a novelist. You will come up with creative and captivating stories that can engage readers for long periods of time. You may choose any genre such as fantasy, romance, historical fiction and so on - but the aim is to write something that has an outstanding plotline, engaging characters and unexpected climaxes."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "creative"
- key: poet
value: "I want you to act as a poet. You will create poems that evoke emotions and have the power to stir people's soul. Write on any topic or theme but make sure your words convey the feeling you are trying to express in beautiful yet meaningful ways. You can also come up with short verses that are still powerful enough to leave an imprint in readers' minds."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "creative"
- key: rapper
value: "I want you to act as a rapper. You will come up with powerful and meaningful lyrics, beats and rhythm that can 'wow' the audience. Your lyrics should have an intriguing meaning and message which people can relate too. When it comes to choosing your beat, make sure it is catchy yet relevant to your words, so that when combined they make an explosion of sound everytime!"
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "creative"
- key: composer
value: "I want you to act as a composer. I will provide the lyrics to a song and you will create music for it. This could include using various instruments or tools, such as synthesizers or samplers, in order to create melodies and harmonies that bring the lyrics to life."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "creative"
# Educational prompts
- key: math_teacher
value: "I want you to act as a math teacher. I will provide some mathematical equations or concepts, and it will be your job to explain them in easy-to-understand terms. This could include providing step-by-step instructions for solving a problem, demonstrating various techniques with visuals or suggesting online resources for further study."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "educational"
- key: philosophy_teacher
value: "I want you to act as a philosophy teacher. I will provide some topics related to the study of philosophy, and it will be your job to explain these concepts in an easy-to-understand manner. This could include providing examples, posing questions or breaking down complex ideas into smaller pieces that are easier to comprehend."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "educational"
- key: historian
value: "I want you to act as a historian. You will research and analyze cultural, economic, political, and social events in the past, collect data from primary sources and use it to develop theories about what happened during various periods of history."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "educational"
- key: debater
value: "I want you to act as a debater. I will provide you with some topics related to current events and your task is to research both sides of the debates, present valid arguments for each side, refute opposing points of view, and draw persuasive conclusions based on evidence. Your goal is to help people come away from the discussion with increased knowledge and insight into the topic at hand."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "educational"
- key: explainer_with_analogies
value: "I want you to act as an explainer who uses analogies to clarify complex topics. When I give you a subject (technical, philosophical or scientific), you'll follow this structure: 1. Ask me 1-2 quick questions to assess my current level of understanding. 2. Based on my answer, create three analogies to explain the topic: one that a 10-year-old would understand, one for a high-school student, and one for a college-level person. 3. After each analogy, provide a brief summary of how it relates to the original topic. 4. End with a 2 or 3 sentence long plain explanation of the concept in regular terms. Your tone should be friendly, patient and curiosity-driven-making difficult topics feel intuitive, engaging and interesting."
author: "awesome-chatgpt-prompts"
link: "https://github.com/f/awesome-chatgpt-prompts"
category: "educational"

View file

@ -11,10 +11,10 @@ from .google_calendar_add_connector_route import (
from .google_gmail_add_connector_route import (
router as google_gmail_add_connector_router,
)
from .llm_config_routes import router as llm_config_router
from .logs_routes import router as logs_router
from .luma_add_connector_route import router as luma_add_connector_router
from .new_chat_routes import router as new_chat_router
from .new_llm_config_routes import router as new_llm_config_router
from .notes_routes import router as notes_router
from .podcasts_routes import router as podcasts_router
from .rbac_routes import router as rbac_router
@ -35,5 +35,5 @@ router.include_router(google_calendar_add_connector_router)
router.include_router(google_gmail_add_connector_router)
router.include_router(airtable_add_connector_router)
router.include_router(luma_add_connector_router)
router.include_router(llm_config_router)
router.include_router(new_llm_config_router) # LLM configs with prompt configuration
router.include_router(logs_router)

View file

@ -1,576 +0,0 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.config import config
from app.db import (
LLMConfig,
Permission,
SearchSpace,
User,
get_async_session,
)
from app.schemas import LLMConfigCreate, LLMConfigRead, LLMConfigUpdate
from app.services.llm_service import validate_llm_config
from app.users import current_active_user
from app.utils.rbac import check_permission
router = APIRouter()
logger = logging.getLogger(__name__)
class LLMPreferencesUpdate(BaseModel):
"""Schema for updating search space LLM preferences"""
long_context_llm_id: int | None = None
fast_llm_id: int | None = None
strategic_llm_id: int | None = None
class LLMPreferencesRead(BaseModel):
"""Schema for reading search space LLM preferences"""
long_context_llm_id: int | None = None
fast_llm_id: int | None = None
strategic_llm_id: int | None = None
long_context_llm: LLMConfigRead | None = None
fast_llm: LLMConfigRead | None = None
strategic_llm: LLMConfigRead | None = None
class GlobalLLMConfigRead(BaseModel):
"""Schema for reading global LLM configs (without API key)"""
id: int
name: str
provider: str
custom_provider: str | None = None
model_name: str
api_base: str | None = None
language: str | None = None
litellm_params: dict | None = None
is_global: bool = True
# Global LLM Config endpoints
@router.get("/global-llm-configs", response_model=list[GlobalLLMConfigRead])
async def get_global_llm_configs(
user: User = Depends(current_active_user),
):
"""
Get all available global LLM configurations.
These are pre-configured by the system administrator and available to all users.
API keys are not exposed through this endpoint.
"""
try:
global_configs = config.GLOBAL_LLM_CONFIGS
# Remove API keys from response
safe_configs = []
for cfg in global_configs:
safe_config = {
"id": cfg.get("id"),
"name": cfg.get("name"),
"provider": cfg.get("provider"),
"custom_provider": cfg.get("custom_provider"),
"model_name": cfg.get("model_name"),
"api_base": cfg.get("api_base"),
"language": cfg.get("language"),
"litellm_params": cfg.get("litellm_params", {}),
"is_global": True,
}
safe_configs.append(safe_config)
return safe_configs
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to fetch global LLM configs: {e!s}"
) from e
@router.post("/llm-configs", response_model=LLMConfigRead)
async def create_llm_config(
llm_config: LLMConfigCreate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Create a new LLM configuration for a search space.
Requires LLM_CONFIGS_CREATE permission.
"""
try:
# Verify user has permission to create LLM configs
await check_permission(
session,
user,
llm_config.search_space_id,
Permission.LLM_CONFIGS_CREATE.value,
"You don't have permission to create LLM configurations in this search space",
)
# Validate the LLM configuration by making a test API call
is_valid, error_message = await validate_llm_config(
provider=llm_config.provider.value,
model_name=llm_config.model_name,
api_key=llm_config.api_key,
api_base=llm_config.api_base,
custom_provider=llm_config.custom_provider,
litellm_params=llm_config.litellm_params,
)
if not is_valid:
raise HTTPException(
status_code=400,
detail=f"Invalid LLM configuration: {error_message}",
)
db_llm_config = LLMConfig(**llm_config.model_dump())
session.add(db_llm_config)
await session.commit()
await session.refresh(db_llm_config)
return db_llm_config
except HTTPException:
raise
except Exception as e:
await session.rollback()
raise HTTPException(
status_code=500, detail=f"Failed to create LLM configuration: {e!s}"
) from e
@router.get("/llm-configs", response_model=list[LLMConfigRead])
async def read_llm_configs(
search_space_id: int,
skip: int = 0,
limit: int = 200,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Get all LLM configurations for a search space.
Requires LLM_CONFIGS_READ permission.
"""
try:
# Verify user has permission to read LLM configs
await check_permission(
session,
user,
search_space_id,
Permission.LLM_CONFIGS_READ.value,
"You don't have permission to view LLM configurations in this search space",
)
result = await session.execute(
select(LLMConfig)
.filter(LLMConfig.search_space_id == search_space_id)
.offset(skip)
.limit(limit)
)
return result.scalars().all()
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to fetch LLM configurations: {e!s}"
) from e
@router.get("/llm-configs/{llm_config_id}", response_model=LLMConfigRead)
async def read_llm_config(
llm_config_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Get a specific LLM configuration by ID.
Requires LLM_CONFIGS_READ permission.
"""
try:
# Get the LLM config
result = await session.execute(
select(LLMConfig).filter(LLMConfig.id == llm_config_id)
)
llm_config = result.scalars().first()
if not llm_config:
raise HTTPException(status_code=404, detail="LLM configuration not found")
# Verify user has permission to read LLM configs
await check_permission(
session,
user,
llm_config.search_space_id,
Permission.LLM_CONFIGS_READ.value,
"You don't have permission to view LLM configurations in this search space",
)
return llm_config
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to fetch LLM configuration: {e!s}"
) from e
@router.put("/llm-configs/{llm_config_id}", response_model=LLMConfigRead)
async def update_llm_config(
llm_config_id: int,
llm_config_update: LLMConfigUpdate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Update an existing LLM configuration.
Requires LLM_CONFIGS_UPDATE permission.
"""
try:
# Get the LLM config
result = await session.execute(
select(LLMConfig).filter(LLMConfig.id == llm_config_id)
)
db_llm_config = result.scalars().first()
if not db_llm_config:
raise HTTPException(status_code=404, detail="LLM configuration not found")
# Verify user has permission to update LLM configs
await check_permission(
session,
user,
db_llm_config.search_space_id,
Permission.LLM_CONFIGS_UPDATE.value,
"You don't have permission to update LLM configurations in this search space",
)
update_data = llm_config_update.model_dump(exclude_unset=True)
# Apply updates to a temporary copy for validation
temp_config = {
"provider": update_data.get("provider", db_llm_config.provider.value),
"model_name": update_data.get("model_name", db_llm_config.model_name),
"api_key": update_data.get("api_key", db_llm_config.api_key),
"api_base": update_data.get("api_base", db_llm_config.api_base),
"custom_provider": update_data.get(
"custom_provider", db_llm_config.custom_provider
),
"litellm_params": update_data.get(
"litellm_params", db_llm_config.litellm_params
),
}
# Validate the updated configuration
is_valid, error_message = await validate_llm_config(
provider=temp_config["provider"],
model_name=temp_config["model_name"],
api_key=temp_config["api_key"],
api_base=temp_config["api_base"],
custom_provider=temp_config["custom_provider"],
litellm_params=temp_config["litellm_params"],
)
if not is_valid:
raise HTTPException(
status_code=400,
detail=f"Invalid LLM configuration: {error_message}",
)
# Apply updates to the database object
for key, value in update_data.items():
setattr(db_llm_config, key, value)
await session.commit()
await session.refresh(db_llm_config)
return db_llm_config
except HTTPException:
raise
except Exception as e:
await session.rollback()
raise HTTPException(
status_code=500, detail=f"Failed to update LLM configuration: {e!s}"
) from e
@router.delete("/llm-configs/{llm_config_id}", response_model=dict)
async def delete_llm_config(
llm_config_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Delete an LLM configuration.
Requires LLM_CONFIGS_DELETE permission.
"""
try:
# Get the LLM config
result = await session.execute(
select(LLMConfig).filter(LLMConfig.id == llm_config_id)
)
db_llm_config = result.scalars().first()
if not db_llm_config:
raise HTTPException(status_code=404, detail="LLM configuration not found")
# Verify user has permission to delete LLM configs
await check_permission(
session,
user,
db_llm_config.search_space_id,
Permission.LLM_CONFIGS_DELETE.value,
"You don't have permission to delete LLM configurations in this search space",
)
await session.delete(db_llm_config)
await session.commit()
return {"message": "LLM configuration deleted successfully"}
except HTTPException:
raise
except Exception as e:
await session.rollback()
raise HTTPException(
status_code=500, detail=f"Failed to delete LLM configuration: {e!s}"
) from e
# Search Space LLM Preferences endpoints
@router.get(
"/search-spaces/{search_space_id}/llm-preferences",
response_model=LLMPreferencesRead,
)
async def get_llm_preferences(
search_space_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Get the LLM preferences for a specific search space.
LLM preferences are shared by all members of the search space.
Requires LLM_CONFIGS_READ permission.
"""
try:
# Verify user has permission to read LLM configs
await check_permission(
session,
user,
search_space_id,
Permission.LLM_CONFIGS_READ.value,
"You don't have permission to view LLM preferences in this search space",
)
# Get the search space
result = await session.execute(
select(SearchSpace).filter(SearchSpace.id == search_space_id)
)
search_space = result.scalars().first()
if not search_space:
raise HTTPException(status_code=404, detail="Search space not found")
# Helper function to get config (global or custom)
async def get_config_for_id(config_id):
if config_id is None:
return None
# Check if it's a global config (negative ID)
if config_id < 0:
for cfg in config.GLOBAL_LLM_CONFIGS:
if cfg.get("id") == config_id:
# Return as LLMConfigRead-compatible dict
return {
"id": cfg.get("id"),
"name": cfg.get("name"),
"provider": cfg.get("provider"),
"custom_provider": cfg.get("custom_provider"),
"model_name": cfg.get("model_name"),
"api_key": "***GLOBAL***", # Don't expose the actual key
"api_base": cfg.get("api_base"),
"language": cfg.get("language"),
"litellm_params": cfg.get("litellm_params"),
"created_at": None,
"search_space_id": search_space_id,
}
return None
# It's a custom config, fetch from database
result = await session.execute(
select(LLMConfig).filter(LLMConfig.id == config_id)
)
return result.scalars().first()
# Get the configs (from DB for custom, or constructed for global)
long_context_llm = await get_config_for_id(search_space.long_context_llm_id)
fast_llm = await get_config_for_id(search_space.fast_llm_id)
strategic_llm = await get_config_for_id(search_space.strategic_llm_id)
return {
"long_context_llm_id": search_space.long_context_llm_id,
"fast_llm_id": search_space.fast_llm_id,
"strategic_llm_id": search_space.strategic_llm_id,
"long_context_llm": long_context_llm,
"fast_llm": fast_llm,
"strategic_llm": strategic_llm,
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to fetch LLM preferences: {e!s}"
) from e
@router.put(
"/search-spaces/{search_space_id}/llm-preferences",
response_model=LLMPreferencesRead,
)
async def update_llm_preferences(
search_space_id: int,
preferences: LLMPreferencesUpdate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Update the LLM preferences for a specific search space.
LLM preferences are shared by all members of the search space.
Requires SETTINGS_UPDATE permission (only users with settings access can change).
"""
try:
# Verify user has permission to update settings (not just LLM configs)
# This ensures only users with settings access can change shared LLM preferences
await check_permission(
session,
user,
search_space_id,
Permission.SETTINGS_UPDATE.value,
"You don't have permission to update LLM preferences in this search space",
)
# Get the search space
result = await session.execute(
select(SearchSpace).filter(SearchSpace.id == search_space_id)
)
search_space = result.scalars().first()
if not search_space:
raise HTTPException(status_code=404, detail="Search space not found")
# Validate that all provided LLM config IDs belong to the search space
update_data = preferences.model_dump(exclude_unset=True)
# Store language from configs to validate consistency
languages = set()
for _key, llm_config_id in update_data.items():
if llm_config_id is not None:
# Check if this is a global config (negative ID)
if llm_config_id < 0:
# Validate global config exists
global_config = None
for cfg in config.GLOBAL_LLM_CONFIGS:
if cfg.get("id") == llm_config_id:
global_config = cfg
break
if not global_config:
raise HTTPException(
status_code=404,
detail=f"Global LLM configuration {llm_config_id} not found",
)
# Collect language for consistency check (if explicitly set)
lang = global_config.get("language")
if lang and lang.strip(): # Only add non-empty languages
languages.add(lang.strip())
else:
# Verify the LLM config belongs to the search space (custom config)
result = await session.execute(
select(LLMConfig).filter(
LLMConfig.id == llm_config_id,
LLMConfig.search_space_id == search_space_id,
)
)
llm_config = result.scalars().first()
if not llm_config:
raise HTTPException(
status_code=404,
detail=f"LLM configuration {llm_config_id} not found in this search space",
)
# Collect language for consistency check (if explicitly set)
if llm_config.language and llm_config.language.strip():
languages.add(llm_config.language.strip())
# Language consistency check - only warn if there are multiple explicit languages
# Allow mixing configs with and without language settings
if len(languages) > 1:
# Log warning but allow the operation
logger.warning(
f"Multiple languages detected in LLM selection for search_space {search_space_id}: {languages}. "
"This may affect response quality."
)
# Update search space LLM preferences
for key, value in update_data.items():
setattr(search_space, key, value)
await session.commit()
await session.refresh(search_space)
# Helper function to get config (global or custom)
async def get_config_for_id(config_id):
if config_id is None:
return None
# Check if it's a global config (negative ID)
if config_id < 0:
for cfg in config.GLOBAL_LLM_CONFIGS:
if cfg.get("id") == config_id:
# Return as LLMConfigRead-compatible dict
return {
"id": cfg.get("id"),
"name": cfg.get("name"),
"provider": cfg.get("provider"),
"custom_provider": cfg.get("custom_provider"),
"model_name": cfg.get("model_name"),
"api_key": "***GLOBAL***", # Don't expose the actual key
"api_base": cfg.get("api_base"),
"language": cfg.get("language"),
"litellm_params": cfg.get("litellm_params"),
"created_at": None,
"search_space_id": search_space_id,
}
return None
# It's a custom config, fetch from database
result = await session.execute(
select(LLMConfig).filter(LLMConfig.id == config_id)
)
return result.scalars().first()
# Get the configs (from DB for custom, or constructed for global)
long_context_llm = await get_config_for_id(search_space.long_context_llm_id)
fast_llm = await get_config_for_id(search_space.fast_llm_id)
strategic_llm = await get_config_for_id(search_space.strategic_llm_id)
# Return updated preferences
return {
"long_context_llm_id": search_space.long_context_llm_id,
"fast_llm_id": search_space.fast_llm_id,
"strategic_llm_id": search_space.strategic_llm_id,
"long_context_llm": long_context_llm,
"fast_llm": fast_llm,
"strategic_llm": strategic_llm,
}
except HTTPException:
raise
except Exception as e:
await session.rollback()
raise HTTPException(
status_code=500, detail=f"Failed to update LLM preferences: {e!s}"
) from e

View file

@ -685,8 +685,16 @@ async def handle_new_chat(
)
search_space = search_space_result.scalars().first()
# TODO: Add new llm config arch then complete this
llm_config_id = -1
if not search_space:
raise HTTPException(status_code=404, detail="Search space not found")
# Use agent_llm_id from search space for chat operations
# Positive IDs load from NewLLMConfig database table
# Negative IDs load from YAML global configs
# Falls back to -1 (first global config) if not configured
llm_config_id = (
search_space.agent_llm_id if search_space.agent_llm_id is not None else -1
)
# Return streaming response
return StreamingResponse(
@ -696,7 +704,6 @@ async def handle_new_chat(
chat_id=request.chat_id,
session=session,
llm_config_id=llm_config_id,
messages=request.messages,
attachments=request.attachments,
),
media_type="text/event-stream",

View file

@ -0,0 +1,376 @@
"""
API routes for NewLLMConfig CRUD operations.
NewLLMConfig combines LLM model settings with prompt configuration:
- LLM provider, model, API key, etc.
- Configurable system instructions
- Citation toggle
"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.agents.new_chat.system_prompt import get_default_system_instructions
from app.config import config
from app.db import (
NewLLMConfig,
Permission,
User,
get_async_session,
)
from app.schemas import (
DefaultSystemInstructionsResponse,
GlobalNewLLMConfigRead,
NewLLMConfigCreate,
NewLLMConfigRead,
NewLLMConfigUpdate,
)
from app.services.llm_service import validate_llm_config
from app.users import current_active_user
from app.utils.rbac import check_permission
router = APIRouter()
logger = logging.getLogger(__name__)
# =============================================================================
# Global Configs Routes
# =============================================================================
@router.get("/global-new-llm-configs", response_model=list[GlobalNewLLMConfigRead])
async def get_global_new_llm_configs(
user: User = Depends(current_active_user),
):
"""
Get all available global NewLLMConfig configurations.
These are pre-configured by the system administrator and available to all users.
API keys are not exposed through this endpoint.
Global configs have negative IDs to distinguish from user-created configs.
"""
try:
global_configs = config.GLOBAL_LLM_CONFIGS
# Transform to new structure, hiding API keys
safe_configs = []
for cfg in global_configs:
safe_config = {
"id": cfg.get("id"),
"name": cfg.get("name"),
"description": cfg.get("description"),
"provider": cfg.get("provider"),
"custom_provider": cfg.get("custom_provider"),
"model_name": cfg.get("model_name"),
"api_base": cfg.get("api_base") or None,
"litellm_params": cfg.get("litellm_params", {}),
# New prompt configuration fields
"system_instructions": cfg.get("system_instructions", ""),
"use_default_system_instructions": cfg.get(
"use_default_system_instructions", True
),
"citations_enabled": cfg.get("citations_enabled", True),
"is_global": True,
}
safe_configs.append(safe_config)
return safe_configs
except Exception as e:
logger.exception("Failed to fetch global NewLLMConfigs")
raise HTTPException(
status_code=500, detail=f"Failed to fetch global configurations: {e!s}"
) from e
# =============================================================================
# CRUD Routes
# =============================================================================
@router.post("/new-llm-configs", response_model=NewLLMConfigRead)
async def create_new_llm_config(
config_data: NewLLMConfigCreate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Create a new NewLLMConfig for a search space.
Requires LLM_CONFIGS_CREATE permission.
"""
try:
# Verify user has permission
await check_permission(
session,
user,
config_data.search_space_id,
Permission.LLM_CONFIGS_CREATE.value,
"You don't have permission to create LLM configurations in this search space",
)
# Validate the LLM configuration by making a test API call
is_valid, error_message = await validate_llm_config(
provider=config_data.provider.value,
model_name=config_data.model_name,
api_key=config_data.api_key,
api_base=config_data.api_base,
custom_provider=config_data.custom_provider,
litellm_params=config_data.litellm_params,
)
if not is_valid:
raise HTTPException(
status_code=400,
detail=f"Invalid LLM configuration: {error_message}",
)
# Create the config
db_config = NewLLMConfig(**config_data.model_dump())
session.add(db_config)
await session.commit()
await session.refresh(db_config)
return db_config
except HTTPException:
raise
except Exception as e:
await session.rollback()
logger.exception("Failed to create NewLLMConfig")
raise HTTPException(
status_code=500, detail=f"Failed to create configuration: {e!s}"
) from e
@router.get("/new-llm-configs", response_model=list[NewLLMConfigRead])
async def list_new_llm_configs(
search_space_id: int,
skip: int = 0,
limit: int = 100,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Get all NewLLMConfigs for a search space.
Requires LLM_CONFIGS_READ permission.
"""
try:
# Verify user has permission
await check_permission(
session,
user,
search_space_id,
Permission.LLM_CONFIGS_READ.value,
"You don't have permission to view LLM configurations in this search space",
)
result = await session.execute(
select(NewLLMConfig)
.filter(NewLLMConfig.search_space_id == search_space_id)
.order_by(NewLLMConfig.created_at.desc())
.offset(skip)
.limit(limit)
)
return result.scalars().all()
except HTTPException:
raise
except Exception as e:
logger.exception("Failed to list NewLLMConfigs")
raise HTTPException(
status_code=500, detail=f"Failed to fetch configurations: {e!s}"
) from e
@router.get(
"/new-llm-configs/default-system-instructions",
response_model=DefaultSystemInstructionsResponse,
)
async def get_default_system_instructions_endpoint(
user: User = Depends(current_active_user),
):
"""
Get the default SURFSENSE_SYSTEM_INSTRUCTIONS template.
Useful for pre-populating the UI when creating a new configuration.
"""
return DefaultSystemInstructionsResponse(
default_system_instructions=get_default_system_instructions()
)
@router.get("/new-llm-configs/{config_id}", response_model=NewLLMConfigRead)
async def get_new_llm_config(
config_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Get a specific NewLLMConfig by ID.
Requires LLM_CONFIGS_READ permission.
"""
try:
result = await session.execute(
select(NewLLMConfig).filter(NewLLMConfig.id == config_id)
)
config = result.scalars().first()
if not config:
raise HTTPException(status_code=404, detail="Configuration not found")
# Verify user has permission
await check_permission(
session,
user,
config.search_space_id,
Permission.LLM_CONFIGS_READ.value,
"You don't have permission to view LLM configurations in this search space",
)
return config
except HTTPException:
raise
except Exception as e:
logger.exception("Failed to get NewLLMConfig")
raise HTTPException(
status_code=500, detail=f"Failed to fetch configuration: {e!s}"
) from e
@router.put("/new-llm-configs/{config_id}", response_model=NewLLMConfigRead)
async def update_new_llm_config(
config_id: int,
update_data: NewLLMConfigUpdate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Update an existing NewLLMConfig.
Requires LLM_CONFIGS_UPDATE permission.
"""
try:
result = await session.execute(
select(NewLLMConfig).filter(NewLLMConfig.id == config_id)
)
config = result.scalars().first()
if not config:
raise HTTPException(status_code=404, detail="Configuration not found")
# Verify user has permission
await check_permission(
session,
user,
config.search_space_id,
Permission.LLM_CONFIGS_UPDATE.value,
"You don't have permission to update LLM configurations in this search space",
)
update_dict = update_data.model_dump(exclude_unset=True)
# If updating LLM settings, validate them
if any(
key in update_dict
for key in [
"provider",
"model_name",
"api_key",
"api_base",
"custom_provider",
"litellm_params",
]
):
# Build the validation config from existing + updates
validation_config = {
"provider": update_dict.get("provider", config.provider).value
if hasattr(update_dict.get("provider", config.provider), "value")
else update_dict.get("provider", config.provider.value),
"model_name": update_dict.get("model_name", config.model_name),
"api_key": update_dict.get("api_key", config.api_key),
"api_base": update_dict.get("api_base", config.api_base),
"custom_provider": update_dict.get(
"custom_provider", config.custom_provider
),
"litellm_params": update_dict.get(
"litellm_params", config.litellm_params
),
}
is_valid, error_message = await validate_llm_config(
provider=validation_config["provider"],
model_name=validation_config["model_name"],
api_key=validation_config["api_key"],
api_base=validation_config["api_base"],
custom_provider=validation_config["custom_provider"],
litellm_params=validation_config["litellm_params"],
)
if not is_valid:
raise HTTPException(
status_code=400,
detail=f"Invalid LLM configuration: {error_message}",
)
# Apply updates
for key, value in update_dict.items():
setattr(config, key, value)
await session.commit()
await session.refresh(config)
return config
except HTTPException:
raise
except Exception as e:
await session.rollback()
logger.exception("Failed to update NewLLMConfig")
raise HTTPException(
status_code=500, detail=f"Failed to update configuration: {e!s}"
) from e
@router.delete("/new-llm-configs/{config_id}", response_model=dict)
async def delete_new_llm_config(
config_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Delete a NewLLMConfig.
Requires LLM_CONFIGS_DELETE permission.
"""
try:
result = await session.execute(
select(NewLLMConfig).filter(NewLLMConfig.id == config_id)
)
config = result.scalars().first()
if not config:
raise HTTPException(status_code=404, detail="Configuration not found")
# Verify user has permission
await check_permission(
session,
user,
config.search_space_id,
Permission.LLM_CONFIGS_DELETE.value,
"You don't have permission to delete LLM configurations in this search space",
)
await session.delete(config)
await session.commit()
return {"message": "Configuration deleted successfully", "id": config_id}
except HTTPException:
raise
except Exception as e:
await session.rollback()
logger.exception("Failed to delete NewLLMConfig")
raise HTTPException(
status_code=500, detail=f"Failed to delete configuration: {e!s}"
) from e

View file

@ -1,13 +1,13 @@
import logging
from pathlib import Path
import yaml
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.config import config
from app.db import (
NewLLMConfig,
Permission,
SearchSpace,
SearchSpaceMembership,
@ -17,6 +17,8 @@ from app.db import (
get_default_roles_config,
)
from app.schemas import (
LLMPreferencesRead,
LLMPreferencesUpdate,
SearchSpaceCreate,
SearchSpaceRead,
SearchSpaceUpdate,
@ -184,37 +186,6 @@ async def read_search_spaces(
) from e
@router.get("/searchspaces/prompts/community")
async def get_community_prompts():
"""
Get community-curated prompts for SearchSpace System Instructions.
This endpoint does not require authentication as it serves public prompts.
"""
try:
# Get the path to the prompts YAML file
prompts_file = (
Path(__file__).parent.parent
/ "prompts"
/ "public_search_space_prompts.yaml"
)
if not prompts_file.exists():
raise HTTPException(
status_code=404, detail="Community prompts file not found"
)
with open(prompts_file, encoding="utf-8") as f:
data = yaml.safe_load(f)
return data.get("prompts", [])
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to load community prompts: {e!s}"
) from e
@router.get("/searchspaces/{search_space_id}", response_model=SearchSpaceRead)
async def read_search_space(
search_space_id: int,
@ -329,3 +300,184 @@ async def delete_search_space(
raise HTTPException(
status_code=500, detail=f"Failed to delete search space: {e!s}"
) from e
# =============================================================================
# LLM Preferences Routes
# =============================================================================
async def _get_llm_config_by_id(
session: AsyncSession, config_id: int | None
) -> dict | None:
"""
Get an LLM config by ID as a dictionary. Returns database config for positive IDs,
global config for negative IDs, or None if ID is None.
"""
if config_id is None:
return None
if config_id < 0:
# Global config - find from YAML
global_configs = config.GLOBAL_LLM_CONFIGS
for cfg in global_configs:
if cfg.get("id") == config_id:
return {
"id": cfg.get("id"),
"name": cfg.get("name"),
"description": cfg.get("description"),
"provider": cfg.get("provider"),
"custom_provider": cfg.get("custom_provider"),
"model_name": cfg.get("model_name"),
"api_base": cfg.get("api_base"),
"litellm_params": cfg.get("litellm_params", {}),
"system_instructions": cfg.get("system_instructions", ""),
"use_default_system_instructions": cfg.get(
"use_default_system_instructions", True
),
"citations_enabled": cfg.get("citations_enabled", True),
"is_global": True,
}
return None
else:
# Database config - convert to dict
result = await session.execute(
select(NewLLMConfig).filter(NewLLMConfig.id == config_id)
)
db_config = result.scalars().first()
if db_config:
return {
"id": db_config.id,
"name": db_config.name,
"description": db_config.description,
"provider": db_config.provider.value if db_config.provider else None,
"custom_provider": db_config.custom_provider,
"model_name": db_config.model_name,
"api_key": db_config.api_key,
"api_base": db_config.api_base,
"litellm_params": db_config.litellm_params or {},
"system_instructions": db_config.system_instructions or "",
"use_default_system_instructions": db_config.use_default_system_instructions,
"citations_enabled": db_config.citations_enabled,
"created_at": db_config.created_at.isoformat()
if db_config.created_at
else None,
"search_space_id": db_config.search_space_id,
}
return None
@router.get(
"/search-spaces/{search_space_id}/llm-preferences",
response_model=LLMPreferencesRead,
)
async def get_llm_preferences(
search_space_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Get LLM preferences (role assignments) for a search space.
Requires LLM_CONFIGS_READ permission.
"""
try:
# Check permission
await check_permission(
session,
user,
search_space_id,
Permission.LLM_CONFIGS_READ.value,
"You don't have permission to view LLM preferences",
)
result = await session.execute(
select(SearchSpace).filter(SearchSpace.id == search_space_id)
)
search_space = result.scalars().first()
if not search_space:
raise HTTPException(status_code=404, detail="Search space not found")
# Get full config objects for each role
agent_llm = await _get_llm_config_by_id(session, search_space.agent_llm_id)
document_summary_llm = await _get_llm_config_by_id(
session, search_space.document_summary_llm_id
)
return LLMPreferencesRead(
agent_llm_id=search_space.agent_llm_id,
document_summary_llm_id=search_space.document_summary_llm_id,
agent_llm=agent_llm,
document_summary_llm=document_summary_llm,
)
except HTTPException:
raise
except Exception as e:
logger.exception("Failed to get LLM preferences")
raise HTTPException(
status_code=500, detail=f"Failed to get LLM preferences: {e!s}"
) from e
@router.put(
"/search-spaces/{search_space_id}/llm-preferences",
response_model=LLMPreferencesRead,
)
async def update_llm_preferences(
search_space_id: int,
preferences: LLMPreferencesUpdate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Update LLM preferences (role assignments) for a search space.
Requires LLM_CONFIGS_UPDATE permission.
"""
try:
# Check permission
await check_permission(
session,
user,
search_space_id,
Permission.LLM_CONFIGS_UPDATE.value,
"You don't have permission to update LLM preferences",
)
result = await session.execute(
select(SearchSpace).filter(SearchSpace.id == search_space_id)
)
search_space = result.scalars().first()
if not search_space:
raise HTTPException(status_code=404, detail="Search space not found")
# Update preferences
update_data = preferences.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(search_space, key, value)
await session.commit()
await session.refresh(search_space)
# Get full config objects for response
agent_llm = await _get_llm_config_by_id(session, search_space.agent_llm_id)
document_summary_llm = await _get_llm_config_by_id(
session, search_space.document_summary_llm_id
)
return LLMPreferencesRead(
agent_llm_id=search_space.agent_llm_id,
document_summary_llm_id=search_space.document_summary_llm_id,
agent_llm=agent_llm,
document_summary_llm=document_summary_llm,
)
except HTTPException:
raise
except Exception as e:
await session.rollback()
logger.exception("Failed to update LLM preferences")
raise HTTPException(
status_code=500, detail=f"Failed to update LLM preferences: {e!s}"
) from e

View file

@ -10,7 +10,6 @@ from .documents import (
ExtensionDocumentMetadata,
PaginatedResponse,
)
from .llm_config import LLMConfigBase, LLMConfigCreate, LLMConfigRead, LLMConfigUpdate
from .logs import LogBase, LogCreate, LogFilter, LogRead, LogUpdate
from .new_chat import (
ChatMessage,
@ -26,6 +25,16 @@ from .new_chat import (
ThreadListItem,
ThreadListResponse,
)
from .new_llm_config import (
DefaultSystemInstructionsResponse,
GlobalNewLLMConfigRead,
LLMPreferencesRead,
LLMPreferencesUpdate,
NewLLMConfigCreate,
NewLLMConfigPublic,
NewLLMConfigRead,
NewLLMConfigUpdate,
)
from .podcasts import PodcastBase, PodcastCreate, PodcastRead, PodcastUpdate
from .rbac_schemas import (
InviteAcceptRequest,
@ -67,6 +76,7 @@ __all__ = [
"ChunkCreate",
"ChunkRead",
"ChunkUpdate",
"DefaultSystemInstructionsResponse",
# Document schemas
"DocumentBase",
"DocumentRead",
@ -75,6 +85,7 @@ __all__ = [
"DocumentsCreate",
"ExtensionDocumentContent",
"ExtensionDocumentMetadata",
"GlobalNewLLMConfigRead",
# Base schemas
"IDModel",
# RBAC schemas
@ -84,11 +95,9 @@ __all__ = [
"InviteInfoResponse",
"InviteRead",
"InviteUpdate",
# LLM Config schemas
"LLMConfigBase",
"LLMConfigCreate",
"LLMConfigRead",
"LLMConfigUpdate",
# LLM Preferences schemas
"LLMPreferencesRead",
"LLMPreferencesUpdate",
# Log schemas
"LogBase",
"LogCreate",
@ -106,6 +115,11 @@ __all__ = [
"NewChatThreadRead",
"NewChatThreadUpdate",
"NewChatThreadWithMessages",
# NewLLMConfig schemas
"NewLLMConfigCreate",
"NewLLMConfigPublic",
"NewLLMConfigRead",
"NewLLMConfigUpdate",
"PaginatedResponse",
"PermissionInfo",
"PermissionsListResponse",

View file

@ -1,72 +0,0 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from app.db import LiteLLMProvider
from .base import IDModel, TimestampModel
class LLMConfigBase(BaseModel):
name: str = Field(
..., max_length=100, description="User-friendly name for the LLM configuration"
)
provider: LiteLLMProvider = Field(..., description="LiteLLM provider type")
custom_provider: str | None = Field(
None, max_length=100, description="Custom provider name when provider is CUSTOM"
)
model_name: str = Field(
..., max_length=100, description="Model name without provider prefix"
)
api_key: str = Field(..., description="API key for the provider")
api_base: str | None = Field(
None, max_length=500, description="Optional API base URL"
)
litellm_params: dict[str, Any] | None = Field(
default=None, description="Additional LiteLLM parameters"
)
language: str | None = Field(
default="English", max_length=50, description="Language for the LLM"
)
class LLMConfigCreate(LLMConfigBase):
search_space_id: int = Field(
..., description="Search space ID to associate the LLM config with"
)
class LLMConfigUpdate(BaseModel):
name: str | None = Field(
None, max_length=100, description="User-friendly name for the LLM configuration"
)
provider: LiteLLMProvider | None = Field(None, description="LiteLLM provider type")
custom_provider: str | None = Field(
None, max_length=100, description="Custom provider name when provider is CUSTOM"
)
model_name: str | None = Field(
None, max_length=100, description="Model name without provider prefix"
)
api_key: str | None = Field(None, description="API key for the provider")
api_base: str | None = Field(
None, max_length=500, description="Optional API base URL"
)
language: str | None = Field(
None, max_length=50, description="Language for the LLM"
)
litellm_params: dict[str, Any] | None = Field(
None, description="Additional LiteLLM parameters"
)
class LLMConfigRead(LLMConfigBase, IDModel, TimestampModel):
id: int
created_at: datetime | None = Field(
None, description="Creation timestamp (None for global configs)"
)
search_space_id: int | None = Field(
None, description="Search space ID (None for global configs)"
)
model_config = ConfigDict(from_attributes=True)

View file

@ -0,0 +1,191 @@
"""
Pydantic schemas for the NewLLMConfig API.
NewLLMConfig combines LLM model settings with prompt configuration:
- LLM provider, model, API key, etc.
- Configurable system instructions
- Citation toggle
"""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from app.db import LiteLLMProvider
class NewLLMConfigBase(BaseModel):
"""Base schema with common fields for NewLLMConfig."""
name: str = Field(
..., max_length=100, description="User-friendly name for the configuration"
)
description: str | None = Field(
None, max_length=500, description="Optional description"
)
# LLM Model Configuration
provider: LiteLLMProvider = Field(..., description="LiteLLM provider type")
custom_provider: str | None = Field(
None, max_length=100, description="Custom provider name when provider is CUSTOM"
)
model_name: str = Field(
..., max_length=100, description="Model name without provider prefix"
)
api_key: str = Field(..., description="API key for the provider")
api_base: str | None = Field(
None, max_length=500, description="Optional API base URL"
)
litellm_params: dict[str, Any] | None = Field(
default=None, description="Additional LiteLLM parameters"
)
# Prompt Configuration
system_instructions: str = Field(
default="",
description="Custom system instructions. Empty string uses default SURFSENSE_SYSTEM_INSTRUCTIONS.",
)
use_default_system_instructions: bool = Field(
default=True,
description="Whether to use default instructions when system_instructions is empty",
)
citations_enabled: bool = Field(
default=True,
description="Whether to include citation instructions in the system prompt",
)
class NewLLMConfigCreate(NewLLMConfigBase):
"""Schema for creating a new NewLLMConfig."""
search_space_id: int = Field(
..., description="Search space ID to associate the config with"
)
class NewLLMConfigUpdate(BaseModel):
"""Schema for updating an existing NewLLMConfig. All fields are optional."""
name: str | None = Field(None, max_length=100)
description: str | None = Field(None, max_length=500)
# LLM Model Configuration
provider: LiteLLMProvider | None = None
custom_provider: str | None = Field(None, max_length=100)
model_name: str | None = Field(None, max_length=100)
api_key: str | None = None
api_base: str | None = Field(None, max_length=500)
litellm_params: dict[str, Any] | None = None
# Prompt Configuration
system_instructions: str | None = None
use_default_system_instructions: bool | None = None
citations_enabled: bool | None = None
class NewLLMConfigRead(NewLLMConfigBase):
"""Schema for reading a NewLLMConfig (includes id and timestamps)."""
id: int
created_at: datetime
search_space_id: int
model_config = ConfigDict(from_attributes=True)
class NewLLMConfigPublic(BaseModel):
"""
Public schema for NewLLMConfig that hides the API key.
Used when returning configs in list views or to users who shouldn't see keys.
"""
id: int
name: str
description: str | None = None
# LLM Model Configuration (no api_key)
provider: LiteLLMProvider
custom_provider: str | None = None
model_name: str
api_base: str | None = None
litellm_params: dict[str, Any] | None = None
# Prompt Configuration
system_instructions: str
use_default_system_instructions: bool
citations_enabled: bool
created_at: datetime
search_space_id: int
model_config = ConfigDict(from_attributes=True)
class DefaultSystemInstructionsResponse(BaseModel):
"""Response schema for getting default system instructions."""
default_system_instructions: str = Field(
..., description="The default SURFSENSE_SYSTEM_INSTRUCTIONS template"
)
class GlobalNewLLMConfigRead(BaseModel):
"""
Schema for reading global LLM configs from YAML.
Global configs have negative IDs and no search_space_id.
API key is hidden for security.
"""
id: int = Field(..., description="Negative ID for global configs")
name: str
description: str | None = None
# LLM Model Configuration (no api_key)
provider: str # String because YAML doesn't enforce enum
custom_provider: str | None = None
model_name: str
api_base: str | None = None
litellm_params: dict[str, Any] | None = None
# Prompt Configuration
system_instructions: str = ""
use_default_system_instructions: bool = True
citations_enabled: bool = True
is_global: bool = True # Always true for global configs
# =============================================================================
# LLM Preferences Schemas (for role assignments)
# =============================================================================
class LLMPreferencesRead(BaseModel):
"""Schema for reading LLM preferences (role assignments) for a search space."""
agent_llm_id: int | None = Field(
None, description="ID of the LLM config to use for agent/chat tasks"
)
document_summary_llm_id: int | None = Field(
None, description="ID of the LLM config to use for document summarization"
)
agent_llm: dict[str, Any] | None = Field(
None, description="Full config for agent LLM"
)
document_summary_llm: dict[str, Any] | None = Field(
None, description="Full config for document summary LLM"
)
model_config = ConfigDict(from_attributes=True)
class LLMPreferencesUpdate(BaseModel):
"""Schema for updating LLM preferences."""
agent_llm_id: int | None = Field(
None, description="ID of the LLM config to use for agent/chat tasks"
)
document_summary_llm_id: int | None = Field(
None, description="ID of the LLM config to use for document summarization"
)

View file

@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.config import config
from app.db import LLMConfig, SearchSpace
from app.db import NewLLMConfig, SearchSpace
# Configure litellm to automatically drop unsupported parameters
litellm.drop_params = True
@ -16,9 +16,8 @@ logger = logging.getLogger(__name__)
class LLMRole:
LONG_CONTEXT = "long_context"
FAST = "fast"
STRATEGIC = "strategic"
AGENT = "agent" # For agent/chat operations
DOCUMENT_SUMMARY = "document_summary" # For document summarization
def get_global_llm_config(llm_config_id: int) -> dict | None:
@ -155,7 +154,7 @@ async def get_search_space_llm_instance(
Args:
session: Database session
search_space_id: Search Space ID
role: LLM role ('long_context', 'fast', or 'strategic')
role: LLM role ('agent' or 'document_summary')
Returns:
ChatLiteLLM instance or None if not found
@ -173,12 +172,10 @@ async def get_search_space_llm_instance(
# Get the appropriate LLM config ID based on role
llm_config_id = None
if role == LLMRole.LONG_CONTEXT:
llm_config_id = search_space.long_context_llm_id
elif role == LLMRole.FAST:
llm_config_id = search_space.fast_llm_id
elif role == LLMRole.STRATEGIC:
llm_config_id = search_space.strategic_llm_id
if role == LLMRole.AGENT:
llm_config_id = search_space.agent_llm_id
elif role == LLMRole.DOCUMENT_SUMMARY:
llm_config_id = search_space.document_summary_llm_id
else:
logger.error(f"Invalid LLM role: {role}")
return None
@ -250,11 +247,11 @@ async def get_search_space_llm_instance(
return ChatLiteLLM(**litellm_kwargs)
# Get the LLM configuration from database (user-specific config)
# Get the LLM configuration from database (NewLLMConfig)
result = await session.execute(
select(LLMConfig).where(
LLMConfig.id == llm_config_id,
LLMConfig.search_space_id == search_space_id,
select(NewLLMConfig).where(
NewLLMConfig.id == llm_config_id,
NewLLMConfig.search_space_id == search_space_id,
)
)
llm_config = result.scalars().first()
@ -265,11 +262,11 @@ async def get_search_space_llm_instance(
)
return None
# Build the model string for litellm / 构建 LiteLLM 的模型字符串
# Build the model string for litellm
if llm_config.custom_provider:
model_string = f"{llm_config.custom_provider}/{llm_config.model_name}"
else:
# Map provider enum to litellm format / 将提供商枚举映射为 LiteLLM 格式
# Map provider enum to litellm format
provider_map = {
"OPENAI": "openai",
"ANTHROPIC": "anthropic",
@ -283,7 +280,7 @@ async def get_search_space_llm_instance(
"COMETAPI": "cometapi",
"XAI": "xai",
"BEDROCK": "bedrock",
"AWS_BEDROCK": "bedrock", # Legacy support (backward compatibility)
"AWS_BEDROCK": "bedrock",
"VERTEX_AI": "vertex_ai",
"TOGETHER_AI": "together_ai",
"FIREWORKS_AI": "fireworks_ai",
@ -296,7 +293,6 @@ async def get_search_space_llm_instance(
"AI21": "ai21",
"CLOUDFLARE": "cloudflare",
"DATABRICKS": "databricks",
# Chinese LLM providers
"DEEPSEEK": "openai",
"ALIBABA_QWEN": "openai",
"MOONSHOT": "openai",
@ -330,28 +326,19 @@ async def get_search_space_llm_instance(
return None
async def get_long_context_llm(
async def get_agent_llm(
session: AsyncSession, search_space_id: int
) -> ChatLiteLLM | None:
"""Get the search space's long context LLM instance."""
"""Get the search space's agent LLM instance for chat operations."""
return await get_search_space_llm_instance(session, search_space_id, LLMRole.AGENT)
async def get_document_summary_llm(
session: AsyncSession, search_space_id: int
) -> ChatLiteLLM | None:
"""Get the search space's document summary LLM instance."""
return await get_search_space_llm_instance(
session, search_space_id, LLMRole.LONG_CONTEXT
)
async def get_fast_llm(
session: AsyncSession, search_space_id: int
) -> ChatLiteLLM | None:
"""Get the search space's fast LLM instance."""
return await get_search_space_llm_instance(session, search_space_id, LLMRole.FAST)
async def get_strategic_llm(
session: AsyncSession, search_space_id: int
) -> ChatLiteLLM | None:
"""Get the search space's strategic LLM instance."""
return await get_search_space_llm_instance(
session, search_space_id, LLMRole.STRATEGIC
session, search_space_id, LLMRole.DOCUMENT_SUMMARY
)
@ -366,22 +353,54 @@ async def get_user_llm_instance(
return await get_search_space_llm_instance(session, search_space_id, role)
# Legacy aliases for backward compatibility
async def get_long_context_llm(
session: AsyncSession, search_space_id: int
) -> ChatLiteLLM | None:
"""Deprecated: Use get_document_summary_llm instead."""
return await get_document_summary_llm(session, search_space_id)
async def get_fast_llm(
session: AsyncSession, search_space_id: int
) -> ChatLiteLLM | None:
"""Deprecated: Use get_agent_llm instead."""
return await get_agent_llm(session, search_space_id)
async def get_strategic_llm(
session: AsyncSession, search_space_id: int
) -> ChatLiteLLM | None:
"""Deprecated: Use get_document_summary_llm instead."""
return await get_document_summary_llm(session, search_space_id)
# User-based legacy aliases (LLM preferences are now per-search-space, not per-user)
async def get_user_long_context_llm(
session: AsyncSession, user_id: str, search_space_id: int
) -> ChatLiteLLM | None:
"""Deprecated: Use get_long_context_llm instead."""
return await get_long_context_llm(session, search_space_id)
"""
Deprecated: Use get_document_summary_llm instead.
The user_id parameter is ignored as LLM preferences are now per-search-space.
"""
return await get_document_summary_llm(session, search_space_id)
async def get_user_fast_llm(
session: AsyncSession, user_id: str, search_space_id: int
) -> ChatLiteLLM | None:
"""Deprecated: Use get_fast_llm instead."""
return await get_fast_llm(session, search_space_id)
"""
Deprecated: Use get_agent_llm instead.
The user_id parameter is ignored as LLM preferences are now per-search-space.
"""
return await get_agent_llm(session, search_space_id)
async def get_user_strategic_llm(
session: AsyncSession, user_id: str, search_space_id: int
) -> ChatLiteLLM | None:
"""Deprecated: Use get_strategic_llm instead."""
return await get_strategic_llm(session, search_space_id)
"""
Deprecated: Use get_document_summary_llm instead.
The user_id parameter is ignored as LLM preferences are now per-search-space.
"""
return await get_document_summary_llm(session, search_space_id)

View file

@ -4,7 +4,7 @@ from typing import Any
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.llm_service import get_strategic_llm
from app.services.llm_service import get_document_summary_llm
class QueryService:
@ -20,7 +20,7 @@ class QueryService:
chat_history_str: str | None = None,
) -> str:
"""
Reformulate the user query using the search space's strategic LLM to make it more
Reformulate the user query using the search space's document summary LLM to make it more
effective for information retrieval and research purposes.
Args:
@ -36,11 +36,11 @@ class QueryService:
return user_query
try:
# Get the search space's strategic LLM instance
llm = await get_strategic_llm(session, search_space_id)
# Get the search space's document summary LLM instance
llm = await get_document_summary_llm(session, search_space_id)
if not llm:
print(
f"Warning: No strategic LLM configured for search space {search_space_id}. Using original query."
f"Warning: No document summary LLM configured for search space {search_space_id}. Using original query."
)
return user_query

View file

@ -1,191 +0,0 @@
import json
from typing import Any
class StreamingService:
def __init__(self):
self.terminal_idx = 1
self.message_annotations = [
{"type": "TERMINAL_INFO", "content": []},
{"type": "SOURCES", "content": []},
{"type": "ANSWER", "content": []},
{"type": "FURTHER_QUESTIONS", "content": []},
]
# DEPRECATED: This sends the full annotation array every time (inefficient)
def _format_annotations(self) -> str:
"""
Format the annotations as a string
DEPRECATED: This method sends the full annotation state every time.
Use the delta formatters instead for optimal streaming.
Returns:
str: The formatted annotations string
"""
return f"8:{json.dumps(self.message_annotations)}\n"
def format_terminal_info_delta(self, text: str, message_type: str = "info") -> str:
"""
Format a single terminal info message as a delta annotation
Args:
text: The terminal message text
message_type: The message type (info, error, success, etc.)
Returns:
str: The formatted annotation delta string
"""
message = {"id": self.terminal_idx, "text": text, "type": message_type}
self.terminal_idx += 1
# Update internal state for reference
self.message_annotations[0]["content"].append(message)
# Return only the delta annotation
annotation = {"type": "TERMINAL_INFO", "data": message}
return f"8:[{json.dumps(annotation)}]\n"
def format_sources_delta(self, sources: list[dict[str, Any]]) -> str:
"""
Format sources as a delta annotation
Args:
sources: List of source objects
Returns:
str: The formatted annotation delta string
"""
# Update internal state
self.message_annotations[1]["content"] = sources
# Return only the delta annotation
nodes = []
for group in sources:
for source in group.get("sources", []):
node = {
"id": str(source.get("id", "")),
"text": source.get("description", "").strip(),
"url": source.get("url", ""),
"metadata": {
"title": source.get("title", ""),
"source_type": group.get("type", ""),
"group_name": group.get("name", ""),
},
}
nodes.append(node)
annotation = {"type": "sources", "data": {"nodes": nodes}}
return f"8:[{json.dumps(annotation)}]\n"
def format_answer_delta(self, answer_chunk: str) -> str:
"""
Format a single answer chunk as a delta annotation
Args:
answer_chunk: The new answer chunk to add
Returns:
str: The formatted annotation delta string
"""
# Update internal state by appending the chunk
if isinstance(self.message_annotations[2]["content"], list):
self.message_annotations[2]["content"].append(answer_chunk)
else:
self.message_annotations[2]["content"] = [answer_chunk]
# Return only the delta annotation with the new chunk
annotation = {"type": "ANSWER", "content": [answer_chunk]}
return f"8:[{json.dumps(annotation)}]\n"
def format_answer_annotation(self, answer_lines: list[str]) -> str:
"""
Format the complete answer as a replacement annotation
Args:
answer_lines: Complete list of answer lines
Returns:
str: The formatted annotation string
"""
# Update internal state
self.message_annotations[2]["content"] = answer_lines
# Return the full answer annotation
annotation = {"type": "ANSWER", "content": answer_lines}
return f"8:[{json.dumps(annotation)}]\n"
def format_further_questions_delta(
self, further_questions: list[dict[str, Any]]
) -> str:
"""
Format further questions as a delta annotation
Args:
further_questions: List of further question objects
Returns:
str: The formatted annotation delta string
"""
# Update internal state
self.message_annotations[3]["content"] = further_questions
# Return only the delta annotation
annotation = {
"type": "FURTHER_QUESTIONS",
"data": [
question.get("question", "")
for question in further_questions
if question.get("question", "") != ""
],
}
return f"8:[{json.dumps(annotation)}]\n"
def format_text_chunk(self, text: str) -> str:
"""
Format a text chunk using the text stream part
Args:
text: The text chunk to stream
Returns:
str: The formatted text part string
"""
return f"0:{json.dumps(text)}\n"
def format_error(self, error_message: str) -> str:
"""
Format an error using the error stream part
Args:
error_message: The error message
Returns:
str: The formatted error part string
"""
return f"3:{json.dumps(error_message)}\n"
def format_completion(
self, prompt_tokens: int = 156, completion_tokens: int = 204
) -> str:
"""
Format a completion message
Args:
prompt_tokens: Number of prompt tokens
completion_tokens: Number of completion tokens
Returns:
str: The formatted completion string
"""
total_tokens = prompt_tokens + completion_tokens
completion_data = {
"finishReason": "stop",
"usage": {
"promptTokens": prompt_tokens,
"completionTokens": completion_tokens,
"totalTokens": total_tokens,
},
}
return f"d:{json.dumps(completion_data)}\n"

View file

@ -3,20 +3,28 @@ Streaming task for the new SurfSense deep agent chat.
This module streams responses from the deep agent using the Vercel AI SDK
Data Stream Protocol (SSE format).
Supports loading LLM configurations from:
- YAML files (negative IDs for global configs)
- NewLLMConfig database table (positive IDs for user-created configs with prompt settings)
"""
import json
from collections.abc import AsyncGenerator
from langchain_core.messages import HumanMessage
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.chat_deepagent import create_surfsense_deep_agent
from app.agents.new_chat.checkpointer import get_checkpointer
from app.agents.new_chat.llm_config import (
AgentConfig,
create_chat_litellm_from_agent_config,
create_chat_litellm_from_config,
load_agent_config,
load_llm_config_from_yaml,
)
from app.schemas.new_chat import ChatAttachment, ChatMessage
from app.schemas.new_chat import ChatAttachment
from app.services.connector_service import ConnectorService
from app.services.new_streaming_service import VercelStreamingService
@ -44,7 +52,6 @@ async def stream_new_chat(
chat_id: int,
session: AsyncSession,
llm_config_id: int = -1,
messages: list[ChatMessage] | None = None,
attachments: list[ChatAttachment] | None = None,
) -> AsyncGenerator[str, None]:
"""
@ -71,17 +78,40 @@ async def stream_new_chat(
current_text_id: str | None = None
try:
# Load LLM config
llm_config = load_llm_config_from_yaml(llm_config_id=llm_config_id)
if not llm_config:
yield streaming_service.format_error(
f"Failed to load LLM config with id {llm_config_id}"
)
yield streaming_service.format_done()
return
# Load LLM config - supports both YAML (negative IDs) and database (positive IDs)
agent_config: AgentConfig | None = None
if llm_config_id >= 0:
# Positive ID: Load from NewLLMConfig database table
agent_config = await load_agent_config(
session=session,
config_id=llm_config_id,
search_space_id=search_space_id,
)
if not agent_config:
yield streaming_service.format_error(
f"Failed to load NewLLMConfig with id {llm_config_id}"
)
yield streaming_service.format_done()
return
# Create ChatLiteLLM from AgentConfig
llm = create_chat_litellm_from_agent_config(agent_config)
else:
# Negative ID: Load from YAML (global configs)
llm_config = load_llm_config_from_yaml(llm_config_id=llm_config_id)
if not llm_config:
yield streaming_service.format_error(
f"Failed to load LLM config with id {llm_config_id}"
)
yield streaming_service.format_done()
return
# Create ChatLiteLLM from YAML config dict
llm = create_chat_litellm_from_config(llm_config)
# Create AgentConfig from YAML for consistency (uses defaults for prompt settings)
agent_config = AgentConfig.from_yaml_config(llm_config)
# Create ChatLiteLLM instance
llm = create_chat_litellm_from_config(llm_config)
if not llm:
yield streaming_service.format_error("Failed to create LLM instance")
yield streaming_service.format_done()
@ -93,13 +123,14 @@ async def stream_new_chat(
# Get the PostgreSQL checkpointer for persistent conversation memory
checkpointer = await get_checkpointer()
# Create the deep agent with checkpointer with podcast capability
# Create the deep agent with checkpointer and configurable prompts
agent = create_surfsense_deep_agent(
llm=llm,
search_space_id=search_space_id,
db_session=session,
connector_service=connector_service,
checkpointer=checkpointer,
agent_config=agent_config, # Pass prompt configuration
)
# Build input with message history from frontend
@ -180,7 +211,9 @@ async def stream_new_chat(
analyze_step_id = next_thinking_step_id()
last_active_step_id = analyze_step_id
last_active_step_title = "Understanding your request"
last_active_step_items = [f"Processing: {user_query[:80]}{'...' if len(user_query) > 80 else ''}"]
last_active_step_items = [
f"Processing: {user_query[:80]}{'...' if len(user_query) > 80 else ''}"
]
yield streaming_service.format_thinking_step(
step_id=analyze_step_id,
title="Understanding your request",
@ -255,7 +288,9 @@ async def stream_new_chat(
else str(tool_input)
)
last_active_step_title = "Searching knowledge base"
last_active_step_items = [f"Query: {query[:100]}{'...' if len(query) > 100 else ''}"]
last_active_step_items = [
f"Query: {query[:100]}{'...' if len(query) > 100 else ''}"
]
yield streaming_service.format_thinking_step(
step_id=tool_step_id,
title="Searching knowledge base",
@ -269,7 +304,9 @@ async def stream_new_chat(
else str(tool_input)
)
last_active_step_title = "Fetching link preview"
last_active_step_items = [f"URL: {url[:80]}{'...' if len(url) > 80 else ''}"]
last_active_step_items = [
f"URL: {url[:80]}{'...' if len(url) > 80 else ''}"
]
yield streaming_service.format_thinking_step(
step_id=tool_step_id,
title="Fetching link preview",
@ -304,7 +341,9 @@ async def stream_new_chat(
else str(tool_input)
)
last_active_step_title = "Scraping webpage"
last_active_step_items = [f"URL: {url[:80]}{'...' if len(url) > 80 else ''}"]
last_active_step_items = [
f"URL: {url[:80]}{'...' if len(url) > 80 else ''}"
]
yield streaming_service.format_thinking_step(
step_id=tool_step_id,
title="Scraping webpage",
@ -441,7 +480,9 @@ async def stream_new_chat(
tool_call_id = f"call_{run_id[:32]}" if run_id else "call_unknown"
# Get the original tool step ID to update it (not create a new one)
original_step_id = tool_step_ids.get(run_id, f"thinking-unknown-{run_id[:8]}")
original_step_id = tool_step_ids.get(
run_id, f"thinking-unknown-{run_id[:8]}"
)
# Mark the tool thinking step as completed using the SAME step ID
# Also add to completed set so we don't try to complete it again
@ -452,7 +493,9 @@ async def stream_new_chat(
if isinstance(tool_output, dict):
result_len = tool_output.get("result_length", 0)
if result_len > 0:
result_info = f"Found relevant information ({result_len} chars)"
result_info = (
f"Found relevant information ({result_len} chars)"
)
# Include original query in completed items
completed_items = [*last_active_step_items, result_info]
yield streaming_service.format_thinking_step(
@ -541,7 +584,7 @@ async def stream_new_chat(
if isinstance(tool_output, dict)
else "Podcast"
)
if podcast_status == "processing":
completed_items = [
f"Title: {podcast_title}",
@ -566,7 +609,7 @@ async def stream_new_chat(
]
else:
completed_items = last_active_step_items
yield streaming_service.format_thinking_step(
step_id=original_step_id,
title="Generating podcast",
@ -652,7 +695,9 @@ async def stream_new_chat(
)
# Send terminal message
if isinstance(tool_output, dict):
title = tool_output.get("title") or tool_output.get("alt", "Image")
title = tool_output.get("title") or tool_output.get(
"alt", "Image"
)
yield streaming_service.format_terminal_info(
f"Image displayed: {title[:40]}{'...' if len(title) > 40 else ''}",
"success",

View file

@ -1,23 +0,0 @@
"""
Legacy podcast task for old chat system.
NOTE: The old Chat model has been removed. This module is kept for backwards
compatibility but the generate_chat_podcast function will raise an error
if called. Use generate_content_podcast_task in celery_tasks/podcast_tasks.py
for new-chat podcast generation instead.
"""
from app.db import Podcast # noqa: F401 - imported for backwards compatibility
async def generate_chat_podcast(*args, **kwargs):
"""
Legacy function for generating podcasts from old chat system.
This function is deprecated as the old Chat model has been removed.
Use generate_content_podcast_task for new-chat podcast generation.
"""
raise NotImplementedError(
"generate_chat_podcast is deprecated. The old Chat model has been removed. "
"Use generate_content_podcast_task for podcast generation from new-chat."
)