diff --git a/.env-template b/.env-template index 13575fc3..4e00b75b 100644 --- a/.env-template +++ b/.env-template @@ -3,6 +3,14 @@ LLM_NAME=docsgpt VITE_API_STREAMING=true INTERNAL_KEY= +# Provider-specific API keys (optional - use these to enable multiple providers) +# OPENAI_API_KEY= +# ANTHROPIC_API_KEY= +# GOOGLE_API_KEY= +# GROQ_API_KEY= +# NOVITA_API_KEY= +# OPEN_ROUTER_API_KEY= + # Remote Embeddings (Optional - for using a remote embeddings API instead of local SentenceTransformer) # When set, the app will use the remote API and won't load SentenceTransformer (saves RAM) EMBEDDINGS_BASE_URL= diff --git a/application/core/model_configs.py b/application/core/model_configs.py index 841be925..f8e70759 100644 --- a/application/core/model_configs.py +++ b/application/core/model_configs.py @@ -27,6 +27,8 @@ ANTHROPIC_ATTACHMENTS = IMAGE_ATTACHMENTS OPENROUTER_ATTACHMENTS = IMAGE_ATTACHMENTS +NOVITA_ATTACHMENTS = IMAGE_ATTACHMENTS + OPENAI_MODELS = [ AvailableModel( @@ -193,6 +195,46 @@ OPENROUTER_MODELS = [ ), ] +NOVITA_MODELS = [ + AvailableModel( + id="moonshotai/kimi-k2.5", + provider=ModelProvider.NOVITA, + display_name="Kimi K2.5", + description="MoE model with function calling, structured output, reasoning, and vision", + capabilities=ModelCapabilities( + supports_tools=True, + supports_structured_output=True, + supported_attachment_types=NOVITA_ATTACHMENTS, + context_window=262144, + ), + ), + AvailableModel( + id="zai-org/glm-5", + provider=ModelProvider.NOVITA, + display_name="GLM-5", + description="MoE model with function calling, structured output, and reasoning", + capabilities=ModelCapabilities( + supports_tools=True, + supports_structured_output=True, + supported_attachment_types=[], + context_window=202800, + ), + ), + AvailableModel( + id="minimax/minimax-m2.5", + provider=ModelProvider.NOVITA, + display_name="MiniMax M2.5", + description="MoE model with function calling, structured output, and reasoning", + capabilities=ModelCapabilities( + supports_tools=True, + supports_structured_output=True, + supported_attachment_types=[], + context_window=204800, + ), + ), +] + + AZURE_OPENAI_MODELS = [ AvailableModel( id="azure-gpt-4", diff --git a/application/core/model_settings.py b/application/core/model_settings.py index 475045d1..044c426b 100644 --- a/application/core/model_settings.py +++ b/application/core/model_settings.py @@ -114,6 +114,10 @@ class ModelRegistry: settings.LLM_PROVIDER == "openrouter" and settings.API_KEY ): self._add_openrouter_models(settings) + if settings.NOVITA_API_KEY or ( + settings.LLM_PROVIDER == "novita" and settings.API_KEY + ): + self._add_novita_models(settings) if settings.HUGGINGFACE_API_KEY or ( settings.LLM_PROVIDER == "huggingface" and settings.API_KEY ): @@ -245,6 +249,21 @@ class ModelRegistry: for model in OPENROUTER_MODELS: self.models[model.id] = model + def _add_novita_models(self, settings): + from application.core.model_configs import NOVITA_MODELS + + if settings.NOVITA_API_KEY: + for model in NOVITA_MODELS: + self.models[model.id] = model + return + if settings.LLM_PROVIDER == "novita" and settings.LLM_NAME: + for model in NOVITA_MODELS: + if model.id == settings.LLM_NAME: + self.models[model.id] = model + return + for model in NOVITA_MODELS: + self.models[model.id] = model + def _add_docsgpt_models(self, settings): model_id = "docsgpt-local" model = AvailableModel( diff --git a/application/core/model_utils.py b/application/core/model_utils.py index 94dc8973..95a6a0d7 100644 --- a/application/core/model_utils.py +++ b/application/core/model_utils.py @@ -10,6 +10,7 @@ def get_api_key_for_provider(provider: str) -> Optional[str]: provider_key_map = { "openai": settings.OPENAI_API_KEY, "openrouter": settings.OPEN_ROUTER_API_KEY, + "novita": settings.NOVITA_API_KEY, "anthropic": settings.ANTHROPIC_API_KEY, "google": settings.GOOGLE_API_KEY, "groq": settings.GROQ_API_KEY, diff --git a/application/core/settings.py b/application/core/settings.py index 6d260f77..9dbc1584 100644 --- a/application/core/settings.py +++ b/application/core/settings.py @@ -5,9 +5,7 @@ from typing import Optional from pydantic import field_validator from pydantic_settings import BaseSettings, SettingsConfigDict -current_dir = os.path.dirname( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -) +current_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) class Settings(BaseSettings): @@ -15,15 +13,11 @@ class Settings(BaseSettings): AUTH_TYPE: Optional[str] = None # simple_jwt, session_jwt, or None LLM_PROVIDER: str = "docsgpt" - LLM_NAME: Optional[str] = ( - None # if LLM_PROVIDER is openai, LLM_NAME can be gpt-4 or gpt-3.5-turbo - ) + LLM_NAME: Optional[str] = None # if LLM_PROVIDER is openai, LLM_NAME can be gpt-4 or gpt-3.5-turbo EMBEDDINGS_NAME: str = "huggingface_sentence-transformers/all-mpnet-base-v2" EMBEDDINGS_BASE_URL: Optional[str] = None # Remote embeddings API URL (OpenAI-compatible) - EMBEDDINGS_KEY: Optional[str] = ( - None # api key for embeddings (if using openai, just copy API_KEY) - ) - + EMBEDDINGS_KEY: Optional[str] = None # api key for embeddings (if using openai, just copy API_KEY) + CELERY_BROKER_URL: str = "redis://localhost:6379/0" CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1" MONGO_URI: str = "mongodb://localhost:27017/docsgpt" @@ -45,9 +39,7 @@ class Settings(BaseSettings): PARSE_IMAGE_REMOTE: bool = False DOCLING_OCR_ENABLED: bool = False # Enable OCR for docling parsers (PDF, images) DOCLING_OCR_ATTACHMENTS_ENABLED: bool = False # Enable OCR for docling when parsing attachments - VECTOR_STORE: str = ( - "faiss" # "faiss" or "elasticsearch" or "qdrant" or "milvus" or "lancedb" or "pgvector" - ) + VECTOR_STORE: str = "faiss" # "faiss" or "elasticsearch" or "qdrant" or "milvus" or "lancedb" or "pgvector" RETRIEVERS_ENABLED: list = ["classic_rag"] AGENT_NAME: str = "classic" FALLBACK_LLM_PROVIDER: Optional[str] = None # provider for fallback llm @@ -55,12 +47,8 @@ class Settings(BaseSettings): FALLBACK_LLM_API_KEY: Optional[str] = None # api key for fallback llm # Google Drive integration - GOOGLE_CLIENT_ID: Optional[str] = ( - None # Replace with your actual Google OAuth client ID - ) - GOOGLE_CLIENT_SECRET: Optional[str] = ( - None # Replace with your actual Google OAuth client secret - ) + GOOGLE_CLIENT_ID: Optional[str] = None # Replace with your actual Google OAuth client ID + GOOGLE_CLIENT_SECRET: Optional[str] = None # Replace with your actual Google OAuth client secret CONNECTOR_REDIRECT_BASE_URI: Optional[str] = ( "http://127.0.0.1:7091/api/connectors/callback" ##add redirect url as it is to your provider's console(gcp) ) @@ -72,7 +60,7 @@ class Settings(BaseSettings): MICROSOFT_AUTHORITY: Optional[str] = None # e.g., "https://login.microsoftonline.com/{tenant_id}" # GitHub source - GITHUB_ACCESS_TOKEN: Optional[str] = None # PAT token with read repo access + GITHUB_ACCESS_TOKEN: Optional[str] = None # PAT token with read repo access # LLM Cache CACHE_REDIS_URL: str = "redis://localhost:6379/2" @@ -90,16 +78,13 @@ class Settings(BaseSettings): GROQ_API_KEY: Optional[str] = None HUGGINGFACE_API_KEY: Optional[str] = None OPEN_ROUTER_API_KEY: Optional[str] = None + NOVITA_API_KEY: Optional[str] = None OPENAI_API_BASE: Optional[str] = None # azure openai api base url OPENAI_API_VERSION: Optional[str] = None # azure openai api version AZURE_DEPLOYMENT_NAME: Optional[str] = None # azure deployment name for answering - AZURE_EMBEDDINGS_DEPLOYMENT_NAME: Optional[str] = ( - None # azure deployment name for embeddings - ) - OPENAI_BASE_URL: Optional[str] = ( - None # openai base url for open ai compatable models - ) + AZURE_EMBEDDINGS_DEPLOYMENT_NAME: Optional[str] = None # azure deployment name for embeddings + OPENAI_BASE_URL: Optional[str] = None # openai base url for open ai compatable models # elasticsearch ELASTIC_CLOUD_ID: Optional[str] = None # cloud id for elasticsearch @@ -141,9 +126,7 @@ class Settings(BaseSettings): # LanceDB vectorstore config LANCEDB_PATH: str = "./data/lancedb" # Path where LanceDB stores its local data - LANCEDB_TABLE_NAME: Optional[str] = ( - "docsgpts" # Name of the table to use for storing vectors - ) + LANCEDB_TABLE_NAME: Optional[str] = "docsgpts" # Name of the table to use for storing vectors FLASK_DEBUG_MODE: bool = False STORAGE_TYPE: str = "local" # local or s3 @@ -180,6 +163,7 @@ class Settings(BaseSettings): "GOOGLE_API_KEY", "GROQ_API_KEY", "HUGGINGFACE_API_KEY", + "NOVITA_API_KEY", "EMBEDDINGS_KEY", "FALLBACK_LLM_API_KEY", "QDRANT_API_KEY", diff --git a/application/llm/handlers/handler_creator.py b/application/llm/handlers/handler_creator.py index 1560eea1..78409ded 100644 --- a/application/llm/handlers/handler_creator.py +++ b/application/llm/handlers/handler_creator.py @@ -7,6 +7,7 @@ class LLMHandlerCreator: handlers = { "openai": OpenAILLMHandler, "google": GoogleLLMHandler, + "novita": OpenAILLMHandler, # Novita uses OpenAI-compatible API "default": OpenAILLMHandler, } diff --git a/application/llm/novita.py b/application/llm/novita.py index b741c4f3..6a5de742 100644 --- a/application/llm/novita.py +++ b/application/llm/novita.py @@ -1,13 +1,13 @@ from application.core.settings import settings from application.llm.openai import OpenAILLM -NOVITA_BASE_URL = "https://api.novita.ai/v3/openai" +NOVITA_BASE_URL = "https://api.novita.ai/openai" class NovitaLLM(OpenAILLM): def __init__(self, api_key=None, user_api_key=None, base_url=None, *args, **kwargs): super().__init__( - api_key=api_key or settings.API_KEY, + api_key=api_key or settings.NOVITA_API_KEY or settings.API_KEY, user_api_key=user_api_key, base_url=base_url or NOVITA_BASE_URL, *args, diff --git a/extensions/slack-bot/requirements.txt b/extensions/slack-bot/requirements.txt index 45139d5d..0c588b43 100644 --- a/extensions/slack-bot/requirements.txt +++ b/extensions/slack-bot/requirements.txt @@ -1,6 +1,6 @@ aiohttp>=3,<4 certifi==2024.7.4 -h11==0.16.0 +h11==0.14.0 httpcore==1.0.5 httpx==0.27.0 idna==3.7 diff --git a/setup.ps1 b/setup.ps1 index 5da9d233..04bf5a39 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -977,8 +977,8 @@ function Connect-CloudAPIProvider { } "7" { # Novita $script:provider_name = "Novita" - $script:llm_name = "novita" - $script:model_name = "deepseek/deepseek-r1" + $script:llm_provider = "novita" + $script:model_name = "moonshotai/kimi-k2.5" Get-APIKey break } diff --git a/setup.sh b/setup.sh index 807a1b37..a9c2688c 100755 --- a/setup.sh +++ b/setup.sh @@ -704,7 +704,7 @@ connect_cloud_api_provider() { 7) # Novita provider_name="Novita" llm_provider="novita" - model_name="deepseek/deepseek-r1" + model_name="moonshotai/kimi-k2.5" get_api_key break ;; b|B) clear; return 1 ;; # Clear screen and Back to Main Menu diff --git a/tests/llm/handlers/test_handler_creator.py b/tests/llm/handlers/test_handler_creator.py index eea098df..afd321aa 100644 --- a/tests/llm/handlers/test_handler_creator.py +++ b/tests/llm/handlers/test_handler_creator.py @@ -71,6 +71,7 @@ class TestLLMHandlerCreator: expected_handlers = { "openai": OpenAILLMHandler, "google": GoogleLLMHandler, + "novita": OpenAILLMHandler, "default": OpenAILLMHandler, } diff --git a/tests/llm/test_novita_llm.py b/tests/llm/test_novita_llm.py new file mode 100644 index 00000000..2b4accdb --- /dev/null +++ b/tests/llm/test_novita_llm.py @@ -0,0 +1,165 @@ +"""Tests for the Novita LLM provider. + +Novita uses an OpenAI-compatible API, so NovitaLLM extends OpenAILLM. +These tests verify the Novita-specific configuration is applied correctly. +""" + +import types +from unittest.mock import patch + +import pytest +from application.llm.novita import NOVITA_BASE_URL, NovitaLLM + + +class FakeChatCompletions: + """Fake OpenAI chat completions for testing.""" + + def __init__(self): + self.last_kwargs = None + + class _Msg: + def __init__(self, content=None): + self.content = content + + class _Delta: + def __init__(self, content=None): + self.content = content + + class _Choice: + def __init__(self, content=None, delta=None): + self.message = FakeChatCompletions._Msg(content=content) + self.delta = FakeChatCompletions._Delta(content=delta) + + class _StreamChunk: + def __init__(self, choice): + self.choices = [choice] + + class _Response: + def __init__(self, choices=None, lines=None): + self._choices = choices or [] + self._lines = lines or [] + + @property + def choices(self): + return self._choices + + def __iter__(self): + for line in self._lines: + yield line + + def create(self, **kwargs): + self.last_kwargs = kwargs + if not kwargs.get("stream"): + return FakeChatCompletions._Response(choices=[FakeChatCompletions._Choice(content="novita response")]) + return FakeChatCompletions._Response( + lines=[ + FakeChatCompletions._StreamChunk(FakeChatCompletions._Choice(delta="part1")), + FakeChatCompletions._StreamChunk(FakeChatCompletions._Choice(delta="part2")), + ] + ) + + +class FakeClient: + """Fake OpenAI client for testing.""" + + def __init__(self): + self.chat = types.SimpleNamespace(completions=FakeChatCompletions()) + + +@pytest.mark.unit +def test_novita_base_url_constant(): + """Verify the Novita base URL is correctly defined.""" + assert NOVITA_BASE_URL == "https://api.novita.ai/openai" + + +@pytest.mark.unit +def test_novita_llm_uses_novita_base_url(): + """Verify NovitaLLM uses the Novita API endpoint.""" + llm = NovitaLLM(api_key="test-key", user_api_key=None) + # The client should be configured with Novita's base URL + assert str(llm.client.base_url) == NOVITA_BASE_URL + "/" + + +@pytest.mark.unit +def test_novita_llm_uses_novita_api_key(): + """Verify NovitaLLM prioritizes NOVITA_API_KEY from settings.""" + with patch("application.llm.novita.settings") as mock_settings: + mock_settings.NOVITA_API_KEY = "novita-test-key" + mock_settings.API_KEY = "fallback-key" + mock_settings.OPENAI_BASE_URL = None + + llm = NovitaLLM(api_key=None, user_api_key=None) + assert llm.api_key == "novita-test-key" + + +@pytest.mark.unit +def test_novita_llm_falls_back_to_api_key(): + """Verify NovitaLLM falls back to API_KEY when NOVITA_API_KEY is not set.""" + with patch("application.llm.novita.settings") as mock_settings: + mock_settings.NOVITA_API_KEY = None + mock_settings.API_KEY = "fallback-key" + mock_settings.OPENAI_BASE_URL = None + + llm = NovitaLLM(api_key=None, user_api_key=None) + assert llm.api_key == "fallback-key" + + +@pytest.mark.unit +def test_novita_llm_explicit_api_key_takes_precedence(): + """Verify explicitly passed API key takes precedence over settings.""" + with patch("application.llm.novita.settings") as mock_settings: + mock_settings.NOVITA_API_KEY = "settings-key" + mock_settings.API_KEY = "fallback-key" + mock_settings.OPENAI_BASE_URL = None + + llm = NovitaLLM(api_key="explicit-key", user_api_key=None) + assert llm.api_key == "explicit-key" + + +@pytest.mark.unit +def test_novita_llm_custom_base_url(): + """Verify custom base_url can override the default Novita URL.""" + custom_url = "https://custom.novita.endpoint/v1" + llm = NovitaLLM(api_key="test-key", user_api_key=None, base_url=custom_url) + assert str(llm.client.base_url) == custom_url + "/" + + +@pytest.mark.unit +def test_novita_llm_supports_tools(): + """Verify NovitaLLM supports function calling/tools.""" + llm = NovitaLLM(api_key="test-key", user_api_key=None) + assert llm.supports_tools() is True + + +@pytest.mark.unit +def test_novita_llm_supports_structured_output(): + """Verify NovitaLLM supports structured output.""" + llm = NovitaLLM(api_key="test-key", user_api_key=None) + assert llm.supports_structured_output() is True + + +@pytest.mark.unit +def test_novita_llm_gen_calls_client(monkeypatch): + """Verify NovitaLLM.gen calls the OpenAI-compatible client correctly.""" + llm = NovitaLLM(api_key="test-key", user_api_key=None) + llm.client = FakeClient() + + msgs = [{"role": "user", "content": "hello"}] + result = llm._raw_gen(llm, model="moonshotai/kimi-k2.5", messages=msgs, stream=False) + + assert result == "novita response" + assert llm.client.chat.completions.last_kwargs["model"] == "moonshotai/kimi-k2.5" + + +@pytest.mark.unit +def test_novita_llm_gen_stream_yields_chunks(monkeypatch): + """Verify NovitaLLM streaming yields chunks correctly.""" + llm = NovitaLLM(api_key="test-key", user_api_key=None) + llm.client = FakeClient() + + msgs = [{"role": "user", "content": "hi"}] + gen = llm._raw_gen_stream(llm, model="moonshotai/kimi-k2.5", messages=msgs, stream=True) + chunks = list(gen) + + assert "part1" in "".join(chunks) + assert "part2" in "".join(chunks)