Merge pull request #2320 from Alex-wuhu/novita-integration

feat: complete Novita AI provider integration
This commit is contained in:
Alex
2026-03-28 13:22:02 +00:00
committed by GitHub
12 changed files with 256 additions and 35 deletions

View File

@@ -3,6 +3,14 @@ LLM_NAME=docsgpt
VITE_API_STREAMING=true
INTERNAL_KEY=<internal key for worker-to-backend authentication>
# Provider-specific API keys (optional - use these to enable multiple providers)
# OPENAI_API_KEY=<your-openai-api-key>
# ANTHROPIC_API_KEY=<your-anthropic-api-key>
# GOOGLE_API_KEY=<your-google-api-key>
# GROQ_API_KEY=<your-groq-api-key>
# NOVITA_API_KEY=<your-novita-api-key>
# OPEN_ROUTER_API_KEY=<your-openrouter-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=

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ class LLMHandlerCreator:
handlers = {
"openai": OpenAILLMHandler,
"google": GoogleLLMHandler,
"novita": OpenAILLMHandler, # Novita uses OpenAI-compatible API
"default": OpenAILLMHandler,
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -71,6 +71,7 @@ class TestLLMHandlerCreator:
expected_handlers = {
"openai": OpenAILLMHandler,
"google": GoogleLLMHandler,
"novita": OpenAILLMHandler,
"default": OpenAILLMHandler,
}

View File

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