Skip to content

Commit f08348e

Browse files
committed
✨config ModelEngine Service
2 parents 6a30c20 + 282fe62 commit f08348e

File tree

6 files changed

+192
-5
lines changed

6 files changed

+192
-5
lines changed

backend/consts/const.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@ class VectorDatabaseType(str, Enum):
1818
MODEL_ENGINE_HOST = ""
1919
# ModelEngine Configuration
2020
MODEL_ENGINE_ENABLED = os.getenv("MODEL_ENGINE_ENABLED", "false").lower() == "true"
21-
if MODEL_ENGINE_ENABLED:
22-
MODEL_ENGINE_HOST = os.getenv('MODEL_ENGINE_HOST')
21+
MODEL_ENGINE_HOST = os.getenv('MODEL_ENGINE_HOST') or ""
2322
MODEL_ENGINE_API_KEY = os.getenv("MODEL_ENGINE_API_KEY") or ""
2423

2524

doc/docs/zh/opensource-memorial-wall.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,19 @@ Nexent开发者加油
632632
开放原子大赛接触到了Nexent平台,祝越来越好!
633633
:::
634634

635-
::: hongmuxiangxun - 2025-12-19
635+
::: info hongmuxiangxun - 2025-12-19
636636
开放原子大赛接触到了Nexent平台,祝越来越好,越来越容易上手。
637637
:::
638+
639+
::: info jinmo - 2025-12-25
640+
无意中接触了Nexent平台,喜欢它的风格,祝它能被更多人知晓。
641+
:::
642+
643+
::: tip 开源新手 - 2025-12-25
644+
感谢 Nexent 让我踏上了开源之旅!这个项目的文档真的很棒,帮助我快速上手,希望 Nexent 越来越好!
645+
:::
646+
647+
::: info 1-xiaozheng-1 - 2025-12-25
648+
从modelengine中看到Nexent,发现这个平台创建智能体简直一绝,希望越来越好。
649+
:::
650+

docker/.env.example

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,9 @@ REDIS_URL=redis://redis:6379/0
7070
REDIS_BACKEND_URL=redis://redis:6379/1
7171

7272
# Model Engine Config
73-
MODEL_ENGINE_HOST=https://localhost:30555
74-
MODEL_ENGINE_APIKEY=
73+
MODEL_ENGINE_ENABLED=true
74+
MODEL_ENGINE_HOST=""
75+
MODEL_ENGINE_API_KEY=""
7576

7677
# Supabase Config
7778
DASHBOARD_USERNAME=supabase
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { modelService } from "./modelService";
2+
import { ModelType } from "@/types/modelConfig";
3+
4+
const TYPES_TO_SYNC: ModelType[] = [
5+
("llm" as unknown) as ModelType,
6+
("embedding" as unknown) as ModelType,
7+
("multi_embedding" as unknown) as ModelType,
8+
("vlm" as unknown) as ModelType,
9+
("tts" as unknown) as ModelType,
10+
("stt" as unknown) as ModelType,
11+
];
12+
13+
/**
14+
* Sync models from ModelEngine and verify connectivity.
15+
* Returns an object with overall success and per-model verification results.
16+
*/
17+
export async function syncModelEngine(apiKey: string) {
18+
let syncFailed = false;
19+
try {
20+
for (const type of TYPES_TO_SYNC) {
21+
try {
22+
const providerModels = await modelService.addProviderModel({
23+
provider: "modelengine",
24+
type: type as any,
25+
apiKey,
26+
});
27+
if (providerModels && providerModels.length > 0) {
28+
await modelService.addBatchCustomModel({
29+
api_key: apiKey,
30+
provider: "modelengine",
31+
type,
32+
models: providerModels,
33+
});
34+
}
35+
} catch (err) {
36+
// mark that at least one provider fetch failed
37+
syncFailed = true;
38+
}
39+
}
40+
41+
// reload all models from backend
42+
const allModelsAfter = await modelService.getAllModels();
43+
const modelEngineModels = allModelsAfter.filter((m) => m.source === "modelengine");
44+
45+
// update persisted api keys for modelengine models if needed
46+
if (modelEngineModels.length > 0 && apiKey) {
47+
const updates = modelEngineModels.map((m) => ({
48+
model_id: String(m.id || m.name || m.displayName),
49+
apiKey: apiKey,
50+
}));
51+
try {
52+
await modelService.updateBatchModel(updates);
53+
} catch (err) {
54+
// non-fatal; continue to verification but flag sync failure
55+
syncFailed = true;
56+
}
57+
}
58+
59+
// verify each persistent model and collect results
60+
const verificationResults: Array<{ displayName: string; type: string; connected: boolean }> = [];
61+
for (const m of modelEngineModels) {
62+
if (!m.displayName) continue;
63+
try {
64+
const connected = await modelService.verifyCustomModel(m.displayName);
65+
verificationResults.push({ displayName: m.displayName, type: m.type, connected });
66+
} catch (err) {
67+
verificationResults.push({ displayName: m.displayName, type: m.type, connected: false });
68+
}
69+
}
70+
71+
const anyUnavailable = verificationResults.some((r) => !r.connected);
72+
const success = !syncFailed && !anyUnavailable;
73+
return { success, verificationResults, error: syncFailed ? "provider_fetch_failed" : undefined };
74+
} catch (err: any) {
75+
return { success: false, verificationResults: [], error: err?.message || String(err) };
76+
}
77+
}

test/backend/services/test_config_sync_service.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -925,3 +925,85 @@ def side_effect(config_key, tenant_id=None):
925925
service_mocks['logger'].warning.assert_called_with(
926926
"Failed to get config for EMBEDDING_ID: Database timeout"
927927
)
928+
929+
930+
@pytest.mark.asyncio
931+
async def test_save_config_impl_persist_model_engine_key_failure(service_mocks):
932+
"""When persisting MODEL_ENGINE_API_KEY fails, a warning should be logged but function should continue."""
933+
config = MagicMock()
934+
# Provide a simple config dict that includes modelengine apiKey
935+
config.model_dump.return_value = {"app": {}, "models": {}, "modelengine": {"apiKey": "me-key"}}
936+
937+
tenant_id = "test_tenant_id"
938+
user_id = "test_user_id"
939+
940+
# tenant_config_manager.load_config should return something reasonable
941+
service_mocks['tenant_config_manager'].load_config.return_value = {}
942+
943+
# Make set_single_config raise when persisting MODEL_ENGINE_API_KEY
944+
service_mocks['tenant_config_manager'].set_single_config.side_effect = Exception("persist failed")
945+
946+
# Execute
947+
result = await save_config_impl(config, tenant_id, user_id)
948+
949+
# Function should not raise; it should log a warning
950+
assert result is None
951+
service_mocks['logger'].warning.assert_called()
952+
953+
954+
@pytest.mark.asyncio
955+
async def test_save_config_impl_modelengine_deletion_paths(service_mocks):
956+
"""When MODEL_ENGINE_API_KEY is empty, deletion logic should attempt to remove records and handle failures."""
957+
config = MagicMock()
958+
# modelengine apiKey empty -> triggers deletion flow
959+
config.model_dump.return_value = {"app": {}, "models": {}, "modelengine": {"apiKey": ""}}
960+
961+
tenant_id = "test_tenant_id"
962+
user_id = "test_user_id"
963+
964+
# tenant config load
965+
service_mocks['tenant_config_manager'].load_config.return_value = {}
966+
967+
# Case A: get_model_records returns models where one has no id (should be skipped) and one has id which delete_model_record will raise on
968+
from backend.services import config_sync_service
969+
970+
async def _run_case_a():
971+
with patch('backend.services.config_sync_service.get_model_records', return_value=[{"model_id": None}, {"model_id": "7"}]), \
972+
patch('backend.services.config_sync_service.delete_model_record', side_effect=Exception("delete failed")):
973+
res = await save_config_impl(config, tenant_id, user_id)
974+
assert res is None
975+
# A warning should be logged for the failed delete
976+
service_mocks['logger'].warning.assert_called()
977+
978+
await _run_case_a()
979+
980+
# Case B: get_model_records itself raises -> should be caught and logged
981+
async def _run_case_b():
982+
with patch('backend.services.config_sync_service.get_model_records', side_effect=Exception("query failed")):
983+
res = await save_config_impl(config, tenant_id, user_id)
984+
assert res is None
985+
service_mocks['logger'].warning.assert_called()
986+
987+
await _run_case_b()
988+
989+
990+
def test_build_model_config_embedding_direct(service_mocks):
991+
"""Direct test of build_model_config to ensure embedding dimension is included when model_type contains 'embedding'."""
992+
from backend.services.config_sync_service import build_model_config
993+
994+
model_config = {
995+
"display_name": "Emb",
996+
"model_type": "embedding",
997+
"max_tokens": 2048,
998+
"base_url": "http://test",
999+
"api_key": "k"
1000+
}
1001+
1002+
# get_model_name_from_config is patched by service_mocks fixture; ensure it returns a value
1003+
service_mocks['get_model_name'].return_value = "emb-name"
1004+
1005+
result = build_model_config(model_config)
1006+
assert result["displayName"] == "Emb"
1007+
assert result["name"] == "emb-name"
1008+
# dimension should be set when 'embedding' in model_type
1009+
assert result.get("dimension") == 2048

test/backend/services/test_model_provider_service.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,21 @@ async def test_modelengine_get_models_no_env_config():
770770
assert result == []
771771

772772

773+
@pytest.mark.asyncio
774+
async def test_modelengine_get_models_disabled_flag_logs_and_returns_empty():
775+
"""If MODEL_ENGINE_ENABLED is False, provider should log an info message and return empty list."""
776+
from backend.services.model_provider_service import ModelEngineProvider
777+
778+
provider_config = {"model_type": "llm", "api_key": "test-key"}
779+
780+
with mock.patch("backend.services.model_provider_service.MODEL_ENGINE_ENABLED", False), \
781+
mock.patch("backend.services.model_provider_service.logger") as mock_logger:
782+
783+
result = await ModelEngineProvider().get_models(provider_config)
784+
785+
assert result == []
786+
mock_logger.info.assert_called_once_with("ModelEngine integration is disabled via environment configuration")
787+
773788
@pytest.mark.asyncio
774789
async def test_modelengine_get_models_llm_success():
775790
"""ModelEngine provider should return LLM models with correct type mapping."""

0 commit comments

Comments
 (0)