Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/consts/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ class ProviderModelRequest(BaseModel):
provider: str
model_type: str
api_key: Optional[str] = ''
base_url: Optional[str] = ''


class BatchCreateModelsRequest(BaseModel):
Expand Down
7 changes: 5 additions & 2 deletions backend/services/model_health_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,14 +195,17 @@ async def verify_model_config_connectivity(model_config: dict):
connectivity = await _perform_connectivity_check(
model_name, model_type, model_base_url, model_api_key, ssl_verify
)

if not connectivity and ssl_verify:
connectivity = await _perform_connectivity_check(
model_name, model_type, model_base_url, model_api_key, False
)
Comment thread
Zhi-a marked this conversation as resolved.
if not connectivity:
return {
"connectivity": False,
"model_name": model_name,
"error": f"Failed to connect to model '{model_name}' at {model_base_url}. Please verify the URL, API key, and network connection."
}

return {
"connectivity": True,
"model_name": model_name,
Expand Down
14 changes: 8 additions & 6 deletions backend/services/model_management_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ async def create_model_for_tenant(user_id: str, tenant_id: str, model_data: Dict
model_base_url.replace(LOCALHOST_NAME, DOCKER_INTERNAL_HOST)
.replace(LOCALHOST_IP, DOCKER_INTERNAL_HOST)
)

model_data['ssl_verify'] = True
if "open/router" in model_base_url:
Comment thread
Zhi-a marked this conversation as resolved.
model_data['ssl_verify'] = False
# Split model_name into repo and name
model_repo, model_name = split_repo_name(
model_data["model_name"]) if model_data.get("model_name") else ("", "")
Expand Down Expand Up @@ -286,7 +288,7 @@ async def delete_model_for_tenant(user_id: str, tenant_id: str, display_name: st
raise LookupError(f"Model not found: {display_name}")

deleted_types: List[str] = []

# Check if any of the models is multi_embedding (which means we have both types)
has_multi_embedding = any(
m.get("model_type") == "multi_embedding" for m in models
Expand Down Expand Up @@ -343,24 +345,24 @@ async def list_models_for_tenant(tenant_id: str):
try:
records = get_model_records(None, tenant_id)
result: List[Dict[str, Any]] = []

# Type mapping for backwards compatibility (chat -> llm for frontend)
type_map = {
"chat": "llm",
}

for record in records:
record["model_name"] = add_repo_to_name(
model_repo=record["model_repo"],
model_name=record["model_name"],
)
record["connect_status"] = ModelConnectStatusEnum.get_value(
record.get("connect_status"))

# Map model_type if necessary (for ModelEngine compatibility)
if record.get("model_type") in type_map:
record["model_type"] = type_map[record["model_type"]]

result.append(record)

logging.debug("Successfully retrieved model list")
Expand Down
18 changes: 10 additions & 8 deletions backend/services/model_provider_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,19 +85,22 @@ async def get_models(self, provider_config: Dict) -> List[Dict]:
List of models with canonical fields
"""
try:
if not MODEL_ENGINE_HOST or not MODEL_ENGINE_APIKEY:
logger.warning("ModelEngine environment variables not configured")
model_type: str = provider_config.get("model_type", "")
host = provider_config.get("base_url")
api_key = provider_config.get("api_key")

if not host or not api_key:
logger.warning("ModelEngine host or api key not configured")
return []

model_type: str = provider_config.get("model_type", "")
headers = {"Authorization": f"Bearer {MODEL_ENGINE_APIKEY}"}
headers = {"Authorization": f"Bearer {api_key}"}

async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30),
connector=aiohttp.TCPConnector(ssl=False)
) as session:
async with session.get(
f"{MODEL_ENGINE_HOST}/open/router/v1/models",
f"{host.rstrip('/')}/open/router/v1/models",
headers=headers
) as response:
response.raise_for_status()
Expand Down Expand Up @@ -130,9 +133,8 @@ async def get_models(self, provider_config: Dict) -> List[Dict]:
"model_type": internal_type,
"model_tag": me_type,
"max_tokens": DEFAULT_LLM_MAX_TOKENS if internal_type in ("llm", "vlm") else 0,
# ModelEngine models will get base_url and api_key from environment
"base_url": MODEL_ENGINE_HOST,
"api_key": MODEL_ENGINE_APIKEY,
"base_url": host,
"api_key": api_key,
})

return filtered_models
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import { useTranslation } from "react-i18next";
import { Modal, Select, Input, Button, Switch, Tooltip, App } from "antd";
import { InfoCircleFilled } from "@ant-design/icons";
import {
LoaderCircle,
ChevronRight,
ChevronDown,
Settings
LoaderCircle,
ChevronRight,
ChevronDown,
Settings
} from "lucide-react";

import { useConfig } from "@/hooks/useConfig";
Expand Down Expand Up @@ -169,6 +169,7 @@ export const ModelAddDialog = ({
// Whether to import multiple models at once
isBatchImport: false,
provider: "modelengine",
modelEngineUrl: "",
vectorDimension: "1024",
// Default chunk size range for embedding models
chunkSizeRange: [
Expand Down Expand Up @@ -306,6 +307,14 @@ export const ModelAddDialog = ({
// Check if the form is valid
const isFormValid = () => {
if (form.isBatchImport) {
// If provider is ModelEngine, require the ModelEngine URL as well.
if (form.provider === "modelengine") {
return (
form.provider.trim() !== "" &&
form.apiKey.trim() !== "" &&
((form as any).modelEngineUrl || "").toString().trim() !== ""
);
}
return form.provider.trim() !== "" && form.apiKey.trim() !== "";
}
if (form.type === MODEL_TYPES.EMBEDDING) {
Expand Down Expand Up @@ -602,6 +611,7 @@ export const ModelAddDialog = ({
isMultimodal: false,
isBatchImport: false,
provider: "silicon",
modelEngineUrl: "",
vectorDimension: "1024",
chunkSizeRange: [
DEFAULT_EXPECTED_CHUNK_SIZE,
Expand Down Expand Up @@ -675,6 +685,21 @@ export const ModelAddDialog = ({
<Option value="modelengine">{t("model.provider.modelengine")}</Option>
<Option value="silicon">{t("model.provider.silicon")}</Option>
</Select>
{/* ModelEngine URL input (only when provider is ModelEngine) */}
{form.provider === "modelengine" && (
<div className="mt-3">
<label className="block mb-1 text-sm font-medium text-gray-700">
ModelEngine URL
</label>
<Input
placeholder={t("model.dialog.placeholder.modelEngineUrl")}
value={(form as any).modelEngineUrl}
onChange={(e) =>
handleFormChange("modelEngineUrl", e.target.value)
}
/>
</div>
)}
</div>
)}

Expand Down
27 changes: 20 additions & 7 deletions frontend/hooks/model/useSiliconModelList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,18 @@ export const useSiliconModelList = ({
const getModelList = async () => {
setShowModelList(true)
setLoadingModelList(true)
const modelType = form.type === "embedding" && form.isMultimodal ?
"multi_embedding" as ModelType :
const modelType = form.type === "embedding" && form.isMultimodal ?
"multi_embedding" as ModelType :
form.type
try {
const result = await modelService.addProviderModel({
provider: form.provider,
type: modelType,
apiKey: form.apiKey.trim() === "" ? "sk-no-api-key" : form.apiKey
apiKey: form.apiKey.trim() === "" ? "sk-no-api-key" : form.apiKey,
baseUrl:
form.provider === "modelengine" && form.apiKey.trim() !== ""
? (form as any).modelEngineUrl || ""
: undefined,
})
// Ensure each model has a default max_tokens value
const modelsWithDefaults = result.map((model: any) => ({
Expand Down Expand Up @@ -68,20 +72,29 @@ export const useSiliconModelList = ({
}

const getProviderSelectedModalList = async () => {
const modelType = form.type === "embedding" && form.isMultimodal ?
"multi_embedding" as ModelType :
const modelType = form.type === "embedding" && form.isMultimodal ?
"multi_embedding" as ModelType :
form.type
const result = await modelService.getProviderSelectedModalList({
provider: form.provider,
type: modelType,
api_key: form.apiKey.trim() === "" ? "sk-no-api-key" : form.apiKey
api_key: form.apiKey.trim() === "" ? "sk-no-api-key" : form.apiKey,
baseUrl:
form.provider === "modelengine" && form.apiKey.trim() !== ""
? (form as any).modelEngineUrl || ""
: undefined,
})
return result
}

// Auto-fetch model list when batch import is enabled and API key is provided
useEffect(() => {
if (form.isBatchImport && form.apiKey.trim() !== "") {
const requiresUrl =
form.provider === "modelengine"
? ((form as any).modelEngineUrl || "").toString().trim() !== ""
: true;

if (form.isBatchImport && form.apiKey.trim() !== "" && requiresUrl) {
getModelList()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down
1 change: 1 addition & 0 deletions frontend/public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,7 @@
"model.dialog.placeholder.name": "Enter model name as in request body",
"model.dialog.placeholder.displayName": "Enter display name for the model",
"model.dialog.placeholder.url": "Enter model URL, e.g. https://api.openai.com/v1",
"model.dialog.placeholder.modelEngineUrl": "Enter ModelEngine host URL, e.g. https://120.253.225.102:50001",
"model.dialog.placeholder.url.embedding": "Enter model URL, e.g. https://api.openai.com/v1/embeddings",
"model.dialog.placeholder.apiKey": "Enter API Key",
"model.dialog.placeholder.maxTokens": "Enter maximum tokens",
Expand Down
1 change: 1 addition & 0 deletions frontend/public/locales/zh/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,7 @@
"model.dialog.placeholder.name": "请输入请求体中的模型名称",
"model.dialog.placeholder.displayName": "请输入模型的展示名称",
"model.dialog.placeholder.url": "请输入模型URL, 例如: https://api.openai.com/v1",
"model.dialog.placeholder.modelEngineUrl": "请输入 ModelEngine 主机地址,例如:https://120.253.225.102:50001",
"model.dialog.placeholder.url.embedding": "请输入模型URL, 例如: https://api.openai.com/v1/embeddings",
"model.dialog.placeholder.apiKey": "请输入API Key",
"model.dialog.placeholder.maxTokens": "请输入最大Token数",
Expand Down
4 changes: 4 additions & 0 deletions frontend/services/modelService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export const modelService = {
provider: string;
type: ModelType;
apiKey: string;
baseUrl?: string;
}): Promise<any[]> => {
try {
const response = await fetch(
Expand All @@ -147,6 +148,7 @@ export const modelService = {
provider: model.provider,
model_type: model.type,
api_key: model.apiKey,
...(model.baseUrl ? { base_url: model.baseUrl } : {}),
}),
}
);
Expand Down Expand Up @@ -202,6 +204,7 @@ export const modelService = {
provider: string;
type: ModelType;
api_key: string;
baseUrl?: string;
}): Promise<any[]> => {
try {
const response = await fetch(
Expand All @@ -213,6 +216,7 @@ export const modelService = {
provider: model.provider,
model_type: model.type,
api_key: model.api_key,
...(model.baseUrl ? { base_url: model.baseUrl } : {}),
}),
}
);
Expand Down
4 changes: 0 additions & 4 deletions test/backend/services/test_model_health_service.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from consts.exceptions import TimeoutException
import asyncio
import os
import sys
from unittest import mock
Expand Down Expand Up @@ -792,5 +790,3 @@ async def test_embedding_dimension_check_wrapper_value_error():
mock_logger.error.assert_called_once_with(
"Error checking embedding dimension: Unsupported model type"
)


35 changes: 31 additions & 4 deletions test/backend/services/test_model_management_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ async def test_create_model_for_tenant_success_llm():
"base_url": "http://localhost:8000",
"model_type": "llm",
}
model_data['ssl_verify'] = False

await svc.create_model_for_tenant(user_id, tenant_id, model_data)

Expand All @@ -316,6 +317,32 @@ async def test_create_model_for_tenant_success_llm():
assert mock_create.call_count == 1


@pytest.mark.asyncio
async def test_create_model_for_tenant_open_router_disables_ssl():
"""When base_url contains 'open/router' ssl_verify should be set to False."""
svc = import_svc()

with mock.patch.object(svc, "get_model_by_display_name", return_value=None), \
mock.patch.object(svc, "create_model_record") as mock_create, \
mock.patch.object(svc, "split_repo_name", return_value=("modelengine", "m")):

user_id = "u1"
tenant_id = "t1"
model_data = {
"model_name": "modelengine/m",
"display_name": None,
"base_url": "https://api.example.com/open/router/v1",
"model_type": "llm",
}

await svc.create_model_for_tenant(user_id, tenant_id, model_data)

# Ensure a single record created and ssl_verify was disabled
assert mock_create.call_count == 1
create_args = mock_create.call_args[0][0]
assert create_args["ssl_verify"] is False


@pytest.mark.asyncio
async def test_create_model_for_tenant_conflict_raises():
svc = import_svc()
Expand Down Expand Up @@ -459,7 +486,7 @@ async def test_create_model_for_tenant_multi_embedding_sets_default_chunk_batch(
mock_dim.assert_awaited_once()
# Should create two records: multi_embedding and its embedding variant
assert mock_create.call_count == 2

# Verify chunk_batch was set to 10 for both records
create_calls = mock_create.call_args_list
# First call is for multi_embedding
Expand Down Expand Up @@ -519,7 +546,7 @@ async def test_batch_create_models_for_tenant_other_provider():
if not hasattr(svc.ProviderEnum, 'MODELENGINE'):
modelengine_item = _EnumItem("modelengine")
svc.ProviderEnum.MODELENGINE = modelengine_item

with mock.patch.object(svc, "get_models_by_tenant_factory_type", return_value=[]), \
mock.patch.object(svc, "delete_model_record"), \
mock.patch.object(svc, "split_repo_name", return_value=("openai", "gpt-4")), \
Expand All @@ -529,7 +556,7 @@ async def test_batch_create_models_for_tenant_other_provider():
mock.patch.object(svc, "create_model_record", return_value=True):

await svc.batch_create_models_for_tenant("u1", "t1", batch_payload)

# Verify prepare_model_dict was called with empty model_url for non-Silicon/ModelEngine provider
call_args = svc.prepare_model_dict.call_args
assert call_args[1]["model_url"] == "" # Should be empty for other providers
Expand Down Expand Up @@ -618,7 +645,7 @@ def get_by_display(display_name, tenant_id):
update_calls = [call for call in mock_update.call_args_list if call[0][0] == "id1"]
if update_calls:
assert update_calls[0][0][1] == {"max_tokens": 8192}

# Should NOT update model2 (max_tokens same) or model3 (new max_tokens is None)
# Verify model2 and model3 were not updated
model2_calls = [call for call in mock_update.call_args_list if call[0][0] == "id2"]
Expand Down
Loading