feat(model-outlets): migrate legacy records and adapt backend services · ModelEngine-Group/nexent@64868ae · GitHub
Skip to content

Commit 64868ae

Browse files
committed
feat(model-outlets): migrate legacy records and adapt backend services
1 parent e95f515 commit 64868ae

54 files changed

Lines changed: 4859 additions & 78 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎backend/apps/agent_app.py‎

Lines changed: 4 additions & 0 deletions

‎backend/apps/conversation_management_app.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
2+
13
import logging
24
from http import HTTPStatus
35
from typing import Annotated, Any, Dict, Optional
@@ -7,6 +9,7 @@
79

810
from consts.model import (
911
BatchDeleteConversationRequest,
12+
ConversationModelPreferencePatch,
1013
ConversationKnowledgeScopeUpdateRequest,
1114
WorkbenchConfigUpdateRequest,
1215
ConversationRequest,
@@ -18,6 +21,8 @@
1821
)
1922
from consts.exceptions import (
2023
AppException,
24+
ModelOutletConflictError,
25+
ModelOutletValidationError,
2126
ConversationNotFoundError,
2227
ValidationError,
2328
TokenExpiredError,
@@ -296,6 +301,54 @@ async def update_conversation_workbench_config_endpoint(
296301
raise HTTPException(status_code=HTTPStatus.UNPROCESSABLE_ENTITY, detail={"code": "WORKBENCH_CONFIG_INVALID"}) from exc
297302

298303

304+
@router.get("/{conversation_id}/model_preferences", response_model=ConversationResponse)
305+
async def get_conversation_model_preferences(conversation_id: int, authorization: Optional[str] = Header(None)):
306+
from nexent.core.concurrency import run_blocking
307+
from services.conversation_model_preferences_service import read_model_preferences
308+
309+
user_id, _ = get_current_user_id(authorization)
310+
try:
311+
result = await run_blocking("conversation-model-preferences-read", read_model_preferences, conversation_id, user_id)
312+
return ConversationResponse(code=0, message="success", data=result)
313+
except ConversationNotFoundError:
314+
raise HTTPException(status_code=404, detail="CONVERSATION_NOT_FOUND") from None
315+
316+
317+
@router.get("/{conversation_id}/model_preferences/{model_id}", response_model=ConversationResponse)
318+
async def get_conversation_model_preference_view(conversation_id: int, model_id: int, authorization: Optional[str] = Header(None)):
319+
from nexent.core.concurrency import run_blocking
320+
from services.conversation_model_preferences_service import read_model_preference_view
321+
322+
user_id, tenant_id = get_current_user_id(authorization)
323+
try:
324+
result = await run_blocking("conversation-model-preference-view", read_model_preference_view,
325+
conversation_id, model_id, user_id, tenant_id)
326+
return ConversationResponse(code=0, message="success", data=result)
327+
except ConversationNotFoundError:
328+
raise HTTPException(status_code=404, detail="CONVERSATION_NOT_FOUND") from None
329+
except ModelOutletValidationError as error:
330+
raise HTTPException(status_code=422, detail={"code": error.category, "field": error.path}) from None
331+
332+
333+
@router.patch("/{conversation_id}/model_preferences/{model_id}", response_model=ConversationResponse)
334+
async def patch_conversation_model_preferences(conversation_id: int, model_id: int,
335+
request: ConversationModelPreferencePatch, authorization: Optional[str] = Header(None)):
336+
from nexent.core.concurrency import run_blocking
337+
from services.conversation_model_preferences_service import patch_model_preferences
338+
339+
user_id, tenant_id = get_current_user_id(authorization)
340+
try:
341+
result = await run_blocking("conversation-model-preferences-update", patch_model_preferences,
342+
conversation_id, model_id, request, user_id, tenant_id)
343+
return ConversationResponse(code=0, message="success", data=result)
344+
except ConversationNotFoundError:
345+
raise HTTPException(status_code=404, detail="CONVERSATION_NOT_FOUND") from None
346+
except ModelOutletConflictError:
347+
raise HTTPException(status_code=409, detail={"code": "MODEL_PREFERENCE_VERSION_CONFLICT"}) from None
348+
except ModelOutletValidationError as error:
349+
raise HTTPException(status_code=422, detail={"code": error.category, "field": error.path}) from None
350+
351+
299352
@router.post("/sources", response_model=Dict[str, Any])
300353
async def get_sources_endpoint(request: Dict[str, Any], authorization: Optional[str] = Header(None)):
301354
"""
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
2+
3+
"""Private conversation configuration; never expose preferences as runtime metadata."""
4+
5+
from copy import deepcopy
6+
7+
from consts.exceptions import (
8+
ConversationNotFoundError,
9+
ModelOutletConflictError,
10+
ModelOutletValidationError,
11+
)
12+
from sqlalchemy import func, select
13+
14+
from .client import get_db_session
15+
from .db_models import ConversationRecord
16+
17+
18+
def model_preferences(conversation_id, user_id, *, expected_version=None, outlet_id=None, resolve_values=None):
19+
"""Read or atomically patch user-owned preferences in the existing conversation row."""
20+
with get_db_session() as session:
21+
statement = select(ConversationRecord).where(ConversationRecord.conversation_id == conversation_id,
22+
ConversationRecord.created_by == user_id, ConversationRecord.delete_flag == "N")
23+
if resolve_values is not None:
24+
statement = statement.with_for_update()
25+
record = session.scalars(statement).first()
26+
if record is None:
27+
raise ConversationNotFoundError("Conversation not found")
28+
version = int(record.model_parameter_preferences_version or 0)
29+
preferences = deepcopy({} if record.model_parameter_preferences is None
30+
else record.model_parameter_preferences)
31+
if not isinstance(preferences, dict):
32+
raise ModelOutletValidationError("CONVERSATION_PREFERENCES_INVALID")
33+
if resolve_values is not None:
34+
if type(expected_version) is not int or expected_version != version:
35+
raise ModelOutletConflictError()
36+
key = str(outlet_id)
37+
values = resolve_values(deepcopy(preferences.get(key, {})))
38+
if values:
39+
preferences[key] = values
40+
else:
41+
preferences.pop(key, None)
42+
version += 1
43+
record.model_parameter_preferences = preferences
44+
record.model_parameter_preferences_version = version
45+
record.updated_by = user_id
46+
record.update_time = func.current_timestamp()
47+
session.flush()
48+
return {"model_parameter_preferences": preferences, "version": version}

‎backend/database/model_management_db.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
2+
13
import logging
24
from typing import Any, Dict, List, Optional
35

@@ -12,6 +14,24 @@
1214
logger = logging.getLogger("database.model_management_db")
1315

1416

17+
def create_resolved_model_record(*args, **kwargs):
18+
"""Create a resolved outlet through the transactional storage implementation."""
19+
from .model_outlet_db import create_resolved_model_record as create_resolved
20+
return create_resolved(*args, **kwargs)
21+
22+
23+
def get_resolved_model_record(*args, **kwargs):
24+
"""Load a tenant-owned resolved outlet without applying legacy parameter filters."""
25+
from .model_outlet_db import get_resolved_model_record as get_resolved
26+
return get_resolved(*args, **kwargs)
27+
28+
29+
def update_resolved_model_record(*args, **kwargs):
30+
"""Update a resolved outlet with an expected configuration revision."""
31+
from .model_outlet_db import update_resolved_model_record as update_resolved
32+
return update_resolved(*args, **kwargs)
33+
34+
1535
def create_model_record(model_data: Dict[str, Any], user_id: str, tenant_id: str) -> bool:
1636
"""
1737
Create a model record

‎backend/management/services/model/adapter.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
2+
13
"""Backend bridge: turn DB model configs into gateway adapters.
24
Service factory functions keep their signatures but delegate adapter
35
construction to the gateway via :func:`get_adapter_from_config`.
@@ -21,6 +23,9 @@
2123
get_gateway,
2224
)
2325
from nexent.core.gateway.registry import get_registry
26+
from nexent.core.model_service.compatibility.invocation_options import LegacyInvocationOptions
27+
from nexent.core.model_service.profiles.outlet_storage import ProfileStorageError, decode_resolved_model_profile
28+
from nexent.core.model_service.registry import model_service_references
2429
from utils.config_utils import tenant_config_manager
2530

2631
logger = logging.getLogger("model_gateway_service")
@@ -118,6 +123,22 @@ def _llm_construct_kwargs(
118123
}
119124

120125

126+
def restore_model_snapshot(cfg: dict, tenant_id: str):
127+
"""Restore one row with executable registrations; never reload TOML or credentials."""
128+
if cfg.get("profile_schema_version") is None:
129+
if cfg.get("resolved_profile") is not None or cfg.get("protocol_id") is not None:
130+
raise ProfileStorageError()
131+
if cfg.get("model_id") is not None and cfg.get("model_type") in {"llm", "chat"}:
132+
from consts.exceptions import ModelOutletValidationError
133+
134+
raise ModelOutletValidationError("PROFILE_MIGRATION_REQUIRED")
135+
return None
136+
profile = decode_resolved_model_profile(cfg, registered_references=model_service_references())
137+
if profile.identity.tenant_id != tenant_id:
138+
raise ProfileStorageError()
139+
return profile
140+
141+
121142
def _config_to_context(
122143
cfg: Optional[dict],
123144
modality: str,
@@ -133,6 +154,24 @@ def _config_to_context(
133154
keys are mapped to subclass fields directly.
134155
"""
135156
cfg = cfg or {}
157+
if modality in ("llm", "llm_long_context"):
158+
snapshot = restore_model_snapshot(cfg, tenant_id)
159+
if snapshot is not None:
160+
# Explicit legacy business overrides remain validated, rather than overriding the wire body.
161+
overrides = {key: construct_extras[key] for key in ("temperature", "top_p", "max_output_tokens")
162+
if construct_extras.get(key) is not None}
163+
snapshot = LegacyInvocationOptions(overrides).resolve(snapshot)
164+
requested_name = construct_extras.get("model_name", snapshot.identity.request_model_id)
165+
if requested_name != snapshot.identity.request_model_id:
166+
raise ProfileStorageError()
167+
context_type = {"llm": LLMContext, "llm_long_context": LongContextLLMContext}[modality]
168+
return context_type(
169+
model_name=snapshot.identity.request_model_id, base_url=snapshot.connection.base_url,
170+
api_key=cfg.get("api_key", ""), modality=modality, factory="openai",
171+
tenant_id=tenant_id, slot=slot, observer=construct_extras.get("observer") or MessageObserver(),
172+
resolved_profile=snapshot, cancellation_scope=construct_extras.get("cancellation_scope"),
173+
**({"truncation_strategy": cfg.get("truncation_strategy")} if modality == "llm_long_context" else {}),
174+
)
136175
factory = _normalize_factory(cfg.get("model_factory"), modality)
137176
needs_observer = modality in ("vlm", "llm", "llm_long_context")
138177
observer = construct_extras.pop("observer", None)

‎backend/management/services/model/health.py‎

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
2+
13
import logging
24
from typing import Optional
35

@@ -327,14 +329,45 @@ async def _perform_connectivity_check(
327329
return connectivity
328330

329331

330-
async def check_model_connectivity(
331-
display_name: str, tenant_id: str, model_type: str = None
332-
) -> dict:
332+
async def check_model_connectivity(display_name: str, tenant_id: str, model_type: str = None) -> dict:
333+
"""Use one streamed LLM check; retain all other model checks unchanged."""
334+
model = get_model_by_display_name(display_name, tenant_id=tenant_id, model_type=model_type)
335+
if model and model.get("model_type") in {"llm", "chat"}:
336+
from consts.model import ModelOutletValidationRequest
337+
from services.model_profile_service import validate_model_outlet
338+
339+
result = await validate_model_outlet(ModelOutletValidationRequest(outlet_id=model["model_id"],
340+
expected_revision=model.get("config_revision") or 1), tenant_id)
341+
return {"connectivity": result["ok"], "model_name": model["model_name"],
342+
**({"error": result["error"]["category"]} if result["error"] else {})}
343+
if not model:
344+
raise LookupError("MODEL_CONFIGURATION_NOT_FOUND")
345+
return await _check_non_llm_connectivity(display_name, tenant_id, model_type, _model=model)
346+
347+
348+
async def verify_model_config_connectivity(model_config: dict):
349+
"""Translate old LLM drafts without saving them or weakening TLS settings."""
350+
if model_config.get("model_type") in {"llm", "chat"}:
351+
from datetime import datetime, timezone
352+
353+
from configs.model_catalog_loader import resolve_reasoning_capability
354+
from services.model_outlet_migration_service import build_legacy_outlet_snapshot
355+
from services.model_profile_service import probe_model_profile
356+
357+
name = model_config.get("model_name", "")
358+
profile = build_legacy_outlet_snapshot({**model_config, "model_id": 1, "tenant_id": "connection-preview"},
359+
legacy_capability=resolve_reasoning_capability(name, model_config.get("base_url"), model_config.get("model_factory")),
360+
migrated_at=datetime.now(timezone.utc).isoformat())
361+
result = await probe_model_profile(profile, model_config.get("api_key"))
362+
return {"connectivity": result["ok"], "model_name": name,
363+
**({"error": result["error"]["category"]} if result["error"] else {})}
364+
return await _verify_non_llm_config_connectivity(model_config)
365+
366+
367+
async def _check_non_llm_connectivity(display_name: str, tenant_id: str, model_type: str = None, *, _model=None) -> dict:
333368
try:
334369
# Query the database using display_name and tenant context from app layer
335-
model = get_model_by_display_name(
336-
display_name, tenant_id=tenant_id, model_type=model_type
337-
)
370+
model = _model if _model is not None else get_model_by_display_name(display_name, tenant_id=tenant_id, model_type=model_type)
338371
if not model:
339372
raise LookupError(f"Model configuration not found for {display_name}")
340373

@@ -434,7 +467,9 @@ async def check_model_connectivity(
434467
raise e
435468

436469

437-
async def verify_model_config_connectivity(model_config: dict):
470+
471+
472+
async def _verify_non_llm_config_connectivity(model_config: dict):
438473
"""
439474
Verify the connectivity of the model configuration, do not save to the database.
440475
"""

‎backend/management/services/model/invocation.py‎

Lines changed: 40 additions & 5 deletions

0 commit comments

Comments
 (0)