mirror of
https://github.com/arc53/DocsGPT.git
synced 2026-05-06 16:25:04 +00:00
Merge pull request #2320 from Alex-wuhu/novita-integration
feat: complete Novita AI provider integration
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7,6 +7,7 @@ class LLMHandlerCreator:
|
||||
handlers = {
|
||||
"openai": OpenAILLMHandler,
|
||||
"google": GoogleLLMHandler,
|
||||
"novita": OpenAILLMHandler, # Novita uses OpenAI-compatible API
|
||||
"default": OpenAILLMHandler,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
2
setup.sh
2
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
|
||||
|
||||
@@ -71,6 +71,7 @@ class TestLLMHandlerCreator:
|
||||
expected_handlers = {
|
||||
"openai": OpenAILLMHandler,
|
||||
"google": GoogleLLMHandler,
|
||||
"novita": OpenAILLMHandler,
|
||||
"default": OpenAILLMHandler,
|
||||
}
|
||||
|
||||
|
||||
165
tests/llm/test_novita_llm.py
Normal file
165
tests/llm/test_novita_llm.py
Normal 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)
|
||||
Reference in New Issue
Block a user