diff --git a/backend/apps/config_app.py b/backend/apps/config_app.py index b726dab02a..8a6ad08f3e 100644 --- a/backend/apps/config_app.py +++ b/backend/apps/config_app.py @@ -1,4 +1,5 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + import asyncio import logging from contextlib import asynccontextmanager @@ -68,6 +69,7 @@ ManagedTaskSpec, ManagerState, clear_default_thread_manager, + run_blocking, set_default_thread_manager, ) from consts.task_recovery import CONFIG_SERVICE_NAME @@ -124,10 +126,14 @@ async def config_lifespan(_app): if config_thread_manager.state is ManagerState.CREATED: config_thread_manager.start() set_default_thread_manager(config_thread_manager) - await recover_config_tasks_on_startup() - await validate_external_service_configuration() - await start_dreaming_scheduler() try: + from services.model_outlet_migration_service import migrate_legacy_outlets + + migration = await run_blocking("model-outlet-upgrade", migrate_legacy_outlets) + logger.info("LLM outlet upgrade: migrated=%s failed=%s", migration["migrated"], migration["failed"]) + await recover_config_tasks_on_startup() + await validate_external_service_configuration() + await start_dreaming_scheduler() yield finally: # Preserve the scheduler's existing graceful shutdown behavior. Task diff --git a/backend/apps/model_managment_app.py b/backend/apps/model_managment_app.py index 186702b36c..c0e6d7e171 100644 --- a/backend/apps/model_managment_app.py +++ b/backend/apps/model_managment_app.py @@ -1,4 +1,5 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """FastAPI App layer for model management endpoints. This module exposes HTTP endpoints under the prefix "/model". It follows the App @@ -25,6 +26,11 @@ BackfillDefaultsRequest, CapacitySuggestionFields, ModelRequest, + ModelProfilePreviewRequest, + ModelOutletCreateRequest, + ModelOutletPatchRequest, + ModelOutletValidationRequest, + ModelOutletMigrationReviewRequest, ModelProbeRequest, ModelCapacitySuggestionRequest, ModelCapacitySuggestionResponse, @@ -42,7 +48,7 @@ ) from consts.const import CAPACITY_SUGGESTION_ENABLED -from fastapi import APIRouter, Depends, Header, Query, HTTPException, Request +from fastapi import APIRouter, Depends, Header, Query, HTTPException, Request, Path from fastapi.responses import JSONResponse from fastapi.encoders import jsonable_encoder from http import HTTPStatus @@ -78,7 +84,7 @@ from permissions.depends import authenticate, require from permissions.models import CurrentUser from management.services.permission.identity.authentication_helpers import get_current_user_id -from consts.exceptions import TokenExpiredError, ModelProviderError +from consts.exceptions import TokenExpiredError, ModelProviderError, ModelOutletValidationError, ModelOutletConflictError from nexent.core.concurrency import run_blocking from database.model_management_db import get_model_by_model_id @@ -1425,3 +1431,170 @@ async def get_model_catalog_profile( model_name=model_name, data=None, ) + + +@router.get("/model_profiles") +async def get_model_profiles( + query: str = Query("", max_length=200), + family: Optional[str] = Query(None, max_length=100), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION)), +): + """List local model references without claiming provider/model qualification.""" + from services.model_profile_service import list_atomic_profiles + + data = await run_blocking("model-profile-list", list_atomic_profiles, "model", + query=query, family=family, page=page, page_size=page_size) + return JSONResponse(status_code=HTTPStatus.OK, content={"data": data}) + + +@router.get("/provider_profiles") +async def get_provider_profiles( + query: str = Query("", max_length=200), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION)), +): + """List provider references; endpoint hints are suggestions only.""" + from services.model_profile_service import list_atomic_profiles + + data = await run_blocking("provider-profile-list", list_atomic_profiles, "provider", + query=query, page=page, page_size=page_size) + return JSONResponse(status_code=HTTPStatus.OK, content={"data": data}) + + +@router.get("/profile_protocols") +async def get_profile_protocols(current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION))): + """List the SDK's registered protocol and authentication choices.""" + from services.model_profile_service import list_profile_protocols + + return JSONResponse(status_code=HTTPStatus.OK, content={"data": list_profile_protocols()}) + + +@router.post("/profile_previews") +async def create_profile_preview( + request: ModelProfilePreviewRequest, + current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION)), +): + """Compose a draft from trusted catalog selections without saving an outlet.""" + from services.model_profile_service import preview_model_profile + + try: + data = await run_blocking("model-profile-preview", preview_model_profile, request, current_user.tenant_id) + return JSONResponse(status_code=HTTPStatus.OK, content={"data": data}) + except ModelOutletConflictError: + raise HTTPException(HTTPStatus.CONFLICT, detail="MODEL_OUTLET_VERSION_CONFLICT") from None + except ModelOutletValidationError as error: + raise HTTPException(HTTPStatus.BAD_REQUEST, detail={"code": "MODEL_OUTLET_INVALID", + "details": {"category": error.category, "field": error.path}}) from None + + +@router.post("/outlets") +async def create_model_outlet_endpoint( + request: ModelOutletCreateRequest, + http_request: Request, + current_user: CurrentUser = Depends(require(MODEL_CREATE_PERMISSION)), +): + """Save a fully composed LLM outlet under the authenticated tenant.""" + from services.model_profile_service import create_model_outlet + + try: + data = await run_blocking("model-outlet-create", create_model_outlet, request, + current_user.user_id, current_user.tenant_id) + record_security_event("model_create", request=http_request, + user_id=current_user.user_id, tenant_id=current_user.tenant_id, + details={"model_id": data["model_id"], "model_name": request.request_model_id, + "display_name": request.display_name or request.request_model_id}) + return JSONResponse(status_code=HTTPStatus.OK, content={"data": data}) + except ModelOutletConflictError: + raise HTTPException(HTTPStatus.CONFLICT, detail="MODEL_OUTLET_VERSION_CONFLICT") from None + except ModelOutletValidationError as error: + raise HTTPException(HTTPStatus.BAD_REQUEST, detail={"code": "MODEL_OUTLET_INVALID", + "details": {"category": error.category, "field": error.path}}) from None + + +@router.get("/outlets/{model_id}") +async def get_model_outlet_endpoint( + model_id: int = Path(..., gt=0, lt=2**31), + current_user: CurrentUser = Depends(require(MODEL_READ_PERMISSION)), +): + """Return the tenant-owned saved outlet, not a newly composed catalog view.""" + from services.model_profile_service import get_model_outlet + + data = await run_blocking("model-outlet-get", get_model_outlet, model_id, current_user.tenant_id) + if data is None: + raise HTTPException(HTTPStatus.NOT_FOUND, detail="MODEL_OUTLET_NOT_FOUND") + return JSONResponse(status_code=HTTPStatus.OK, content={"data": data}) + + +@router.patch("/outlets/{model_id}") +async def patch_model_outlet_endpoint( + request: ModelOutletPatchRequest, + http_request: Request, + model_id: int = Path(..., gt=0, lt=2**31), + current_user: CurrentUser = Depends(require(MODEL_UPDATE_PERMISSION)), +): + """Apply revision-checked changes using the saved outlet's own rules.""" + from services.model_profile_service import update_model_outlet + + try: + data = await run_blocking("model-outlet-update", update_model_outlet, model_id, request, + current_user.user_id, current_user.tenant_id) + record_security_event("model_update", request=http_request, + user_id=current_user.user_id, tenant_id=current_user.tenant_id, + details={"model_id": model_id, "config_revision": data["config_revision"]}) + return JSONResponse(status_code=HTTPStatus.OK, content={"data": data}) + except LookupError: + raise HTTPException(HTTPStatus.NOT_FOUND, detail="MODEL_OUTLET_NOT_FOUND") from None + except ModelOutletConflictError: + raise HTTPException(HTTPStatus.CONFLICT, detail="MODEL_OUTLET_VERSION_CONFLICT") from None + except ModelOutletValidationError as error: + raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY, detail={"code": "MODEL_OUTLET_INVALID", + "details": {"category": error.category, "field": error.path}}) from None + + +@router.post("/outlet_validations") +async def validate_model_outlet_endpoint( + request: ModelOutletValidationRequest, + current_user: CurrentUser = Depends(require(MODEL_UPDATE_PERMISSION)), +): + """Check only endpoint/model availability through the common model service.""" + from services.model_profile_service import validate_model_outlet + + try: + data = await validate_model_outlet(request, current_user.tenant_id) + return JSONResponse(status_code=HTTPStatus.OK, content={"data": data}) + except LookupError: + raise HTTPException(HTTPStatus.NOT_FOUND, detail="MODEL_OUTLET_NOT_FOUND") from None + except ModelOutletConflictError: + raise HTTPException(HTTPStatus.CONFLICT, detail="MODEL_OUTLET_VERSION_CONFLICT") from None + except ModelOutletValidationError as error: + raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY, detail={"code": "MODEL_OUTLET_INVALID", + "details": {"category": error.category, "field": error.path}}) from None + + +@router.post("/outlets/{model_id}/migration_reviews") +async def confirm_model_outlet_migration_endpoint( + request: ModelOutletMigrationReviewRequest, + http_request: Request, + model_id: int = Path(..., gt=0, lt=2**31), + current_user: CurrentUser = Depends(require(MODEL_UPDATE_PERMISSION)), +): + """Allow tenant administrators to confirm the displayed outlet revision.""" + from services.model_profile_service import confirm_outlet_migration + + _require_manage_scope(current_user, current_user.tenant_id) + try: + result = await run_blocking("model-migration-review", confirm_outlet_migration, + model_id, request, current_user.user_id, current_user.tenant_id) + if result is None: + raise HTTPException(HTTPStatus.NOT_FOUND, detail="MODEL_OUTLET_NOT_FOUND") + record_security_event("model_update", request=http_request, user_id=current_user.user_id, + tenant_id=current_user.tenant_id, details={"model_id": model_id, "migration_review_revision": request.expected_revision}) + return JSONResponse(status_code=HTTPStatus.OK, content={"data": result}) + except ModelOutletConflictError: + raise HTTPException(HTTPStatus.CONFLICT, detail="MODEL_OUTLET_VERSION_CONFLICT") from None + except ModelOutletValidationError as error: + raise HTTPException(HTTPStatus.UNPROCESSABLE_ENTITY, detail={"code": "MODEL_OUTLET_INVALID", + "details": {"category": error.category, "field": error.path}}) from None diff --git a/backend/configs/model_catalog_loader.py b/backend/configs/model_catalog_loader.py index ada4d1eeaa..26383f78f2 100644 --- a/backend/configs/model_catalog_loader.py +++ b/backend/configs/model_catalog_loader.py @@ -20,11 +20,9 @@ from typing import Any, Dict, Iterable, List, Optional from urllib.parse import urlsplit, urlunsplit -from consts import const as _runtime_const from consts.const import MODEL_CATALOG_JSON_PATH, MODELS_DEV_CATALOG_JSON_PATH from consts.model import ModelCatalogProfile, ModelCatalogProviderInfo -MODEL_REASONING_WIRE_PROFILES_PATH = getattr(_runtime_const, "MODEL_REASONING_WIRE_PROFILES_PATH", "") logger = logging.getLogger("model_catalog") @@ -610,6 +608,7 @@ def _resolve_models_dev_reasoning_capability( model_name: str, base_url: Optional[str], provider_hint: Optional[str], + *, allow_gateway_fallback: bool = True, ) -> Optional[Dict[str, Any]]: """Resolve one model by API first and model ID second.""" requested_api = _canonical_api_url(base_url) @@ -644,7 +643,7 @@ def _resolve_models_dev_reasoning_capability( # A matched provider remains authoritative, including missing/unsupported # models. Only an unlisted gateway URL may use native-model declarations. if not provider_candidates: - if requested_api: + if requested_api and allow_gateway_fallback: return _resolve_gateway_model_reasoning(catalog, model_name, base_url, provider_hint) return None @@ -869,10 +868,11 @@ def infer_provider_from_base_url(base_url: str) -> Optional[str]: return None -def _resolve_base_reasoning_capability( +def resolve_reasoning_capability( model_name: str, base_url: Optional[str] = None, provider_hint: Optional[str] = None, + *, allow_gateway_fallback: bool = True, ) -> Optional[Dict[str, Any]]: """Resolve reasoning capability from an exact profile or safe ID family. @@ -891,6 +891,7 @@ def _resolve_base_reasoning_capability( model_name, base_url, provider_hint, + allow_gateway_fallback=allow_gateway_fallback, ) provider_id = str(provider_hint or "").strip() @@ -1183,48 +1184,3 @@ def apply_catalog_defaults( # Tag capacity_source = "profile" only if the caller didn't already set one. model_data["capacity_source"] = "profile" return applied - - -def resolve_reasoning_capability( - model_name: str, base_url: Optional[str] = None, provider_hint: Optional[str] = None, -) -> Optional[Dict[str, Any]]: - """Apply only an unambiguous, explicitly verified endpoint/model wire profile.""" - capability = _resolve_base_reasoning_capability(model_name, base_url, provider_hint) - if not capability or capability.get("status") != "supported" or not base_url: - return capability - - def endpoint(value: Any) -> Optional[str]: - if not isinstance(value, str): - return None - try: - parsed = urlsplit(value) - if (parsed.scheme not in {"http", "https"} or not parsed.hostname - or parsed.username or parsed.password or parsed.query or parsed.fragment): - return None - return _canonical_api_url(value) - except ValueError: - return None - - target = endpoint(base_url) - if target is None or not MODEL_REASONING_WIRE_PROFILES_PATH: - return capability - try: - if os.path.getsize(MODEL_REASONING_WIRE_PROFILES_PATH) > 65536: - return capability - with open(MODEL_REASONING_WIRE_PROFILES_PATH, encoding="utf-8") as source: - profiles = json.load(source).get("profiles", []) - if not isinstance(profiles, list): - return capability - except (OSError, ValueError, AttributeError): - return capability - matches = [ - profile for profile in profiles if isinstance(profile, dict) - and endpoint(profile.get("base_url")) == target - and profile.get("model_id") == model_name - ] - if len(matches) != 1: - return capability - wire = matches[0].get("toggle_wire_format") - if not isinstance(wire, str) or wire not in {"thinking_object", "enable_thinking", "chat_template"}: - return capability - return {**capability, "toggle_wire_format": wire} diff --git a/backend/database/conversation_model_preferences_db.py b/backend/database/conversation_model_preferences_db.py new file mode 100644 index 0000000000..80dff9d2c4 --- /dev/null +++ b/backend/database/conversation_model_preferences_db.py @@ -0,0 +1,53 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Private conversation configuration; never expose preferences as runtime metadata.""" + +from copy import deepcopy + +from consts.exceptions import ( + ConversationNotFoundError, + ModelOutletConflictError, + ModelOutletValidationError, +) +from sqlalchemy import func, select + +from .client import get_db_session +from .db_models import ConversationRecord + + +def model_preferences(conversation_id, user_id, *, expected_version=None, outlet_id=None, resolve_values=None, selected_model_id=None): + """Read or atomically patch user-owned preferences in the existing conversation row.""" + with get_db_session() as session: + statement = select(ConversationRecord).where(ConversationRecord.conversation_id == conversation_id, + ConversationRecord.created_by == user_id, ConversationRecord.delete_flag == "N") + updating = resolve_values is not None or selected_model_id is not None + if updating: + statement = statement.with_for_update() + record = session.scalars(statement).first() + if record is None: + raise ConversationNotFoundError("Conversation not found") + version = int(record.model_parameter_preferences_version or 0) + preferences = deepcopy({} if record.model_parameter_preferences is None + else record.model_parameter_preferences) + if not isinstance(preferences, dict): + raise ModelOutletValidationError("CONVERSATION_PREFERENCES_INVALID") + if updating: + if type(expected_version) is not int or expected_version != version: + raise ModelOutletConflictError() + if selected_model_id is not None: + record.selected_model_id = selected_model_id + else: + key = str(outlet_id) + values = resolve_values(deepcopy(preferences.get(key, {}))) + if values: + preferences[key] = values + else: + preferences.pop(key, None) + version += 1 + record.model_parameter_preferences = preferences + record.model_parameter_preferences_version = version + record.updated_by = user_id + record.update_time = func.current_timestamp() + session.flush() + return {"model_parameter_preferences": preferences, "version": version, + "selected_model_id": getattr(record, "selected_model_id", None)} diff --git a/backend/database/model_management_db.py b/backend/database/model_management_db.py index 11487575ec..d5e30bfcc8 100644 --- a/backend/database/model_management_db.py +++ b/backend/database/model_management_db.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + import logging from typing import Any, Dict, List, Optional @@ -12,6 +14,24 @@ logger = logging.getLogger("database.model_management_db") +def create_resolved_model_record(*args, **kwargs): + """Create a resolved outlet through the transactional storage implementation.""" + from .model_outlet_db import create_resolved_model_record as create_resolved + return create_resolved(*args, **kwargs) + + +def get_resolved_model_record(*args, **kwargs): + """Load a tenant-owned resolved outlet without applying legacy parameter filters.""" + from .model_outlet_db import get_resolved_model_record as get_resolved + return get_resolved(*args, **kwargs) + + +def update_resolved_model_record(*args, **kwargs): + """Update a resolved outlet with an expected configuration revision.""" + from .model_outlet_db import update_resolved_model_record as update_resolved + return update_resolved(*args, **kwargs) + + def create_model_record(model_data: Dict[str, Any], user_id: str, tenant_id: str) -> bool: """ Create a model record diff --git a/backend/management/services/agent/read.py b/backend/management/services/agent/read.py index a9d6f3e291..97379d572f 100644 --- a/backend/management/services/agent/read.py +++ b/backend/management/services/agent/read.py @@ -45,12 +45,19 @@ def project_agent_models( if available_records and available_records[0][1] is not None else None ) + fields = {"model_ids": available_model_ids, "model_names": names, "model_name": legacy_name} + overrides = agent.get("model_params_override") + if isinstance(overrides, dict): + from nexent.core.model_service.compatibility.agent_parameters import read_agent_parameter_entry + + current = dict(overrides) + for model_id, record in records: + if (record and record.get("model_type") in {"llm", "chat"} + and record.get("profile_schema_version") is not None and str(model_id) in current): + current[str(model_id)] = read_agent_parameter_entry(current[str(model_id)]) + fields["model_params_override"] = current return AgentModelProjection( - fields={ - "model_ids": available_model_ids, - "model_names": names, - "model_name": legacy_name, - }, + fields=fields, deleted_model_ids=frozenset(configured_model_ids) - frozenset(model_ids), availability_model_ids=model_ids, ) diff --git a/backend/management/services/model/adapter.py b/backend/management/services/model/adapter.py index 4ff4ea11fb..14fe3b5664 100644 --- a/backend/management/services/model/adapter.py +++ b/backend/management/services/model/adapter.py @@ -1,4 +1,5 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Backend bridge: turn DB model configs into gateway adapters. Service factory functions keep their signatures but delegate adapter construction to the gateway via :func:`get_adapter_from_config`. @@ -22,6 +23,9 @@ get_gateway, ) from nexent.core.gateway.registry import get_registry +from nexent.core.model_service.compatibility.invocation_options import LegacyInvocationOptions +from nexent.core.model_service.profiles.outlet_storage import ProfileStorageError, decode_resolved_model_profile +from nexent.core.model_service.registry import model_service_references from management.services.configuration.tenant.settings import tenant_config_manager logger = logging.getLogger("model_gateway_service") @@ -119,6 +123,22 @@ def _llm_construct_kwargs( } +def restore_model_snapshot(cfg: dict, tenant_id: str): + """Restore one row with executable registrations; never reload TOML or credentials.""" + if cfg.get("profile_schema_version") is None: + if cfg.get("resolved_profile") is not None or cfg.get("protocol_id") is not None: + raise ProfileStorageError() + if cfg.get("model_id") is not None and cfg.get("model_type") in {"llm", "chat"}: + from consts.exceptions import ModelOutletValidationError + + raise ModelOutletValidationError("PROFILE_MIGRATION_REQUIRED") + return None + profile = decode_resolved_model_profile(cfg, registered_references=model_service_references()) + if profile.identity.tenant_id != tenant_id: + raise ProfileStorageError() + return profile + + def _config_to_context( cfg: Optional[dict], modality: str, @@ -134,6 +154,24 @@ def _config_to_context( keys are mapped to subclass fields directly. """ cfg = cfg or {} + if modality in ("llm", "llm_long_context"): + snapshot = restore_model_snapshot(cfg, tenant_id) + if snapshot is not None: + # Explicit legacy business overrides remain validated, rather than overriding the wire body. + overrides = {key: construct_extras[key] for key in ("temperature", "top_p", "max_output_tokens") + if construct_extras.get(key) is not None} + snapshot = LegacyInvocationOptions(overrides).resolve(snapshot) + requested_name = construct_extras.get("model_name", snapshot.identity.request_model_id) + if requested_name != snapshot.identity.request_model_id: + raise ProfileStorageError() + context_type = {"llm": LLMContext, "llm_long_context": LongContextLLMContext}[modality] + return context_type( + model_name=snapshot.identity.request_model_id, base_url=snapshot.connection.base_url, + api_key=cfg.get("api_key", ""), modality=modality, factory="openai", + tenant_id=tenant_id, slot=slot, observer=construct_extras.get("observer") or MessageObserver(), + resolved_profile=snapshot, cancellation_scope=construct_extras.get("cancellation_scope"), + **({"truncation_strategy": cfg.get("truncation_strategy")} if modality == "llm_long_context" else {}), + ) factory = _normalize_factory(cfg.get("model_factory"), modality) needs_observer = modality in ("vlm", "llm", "llm_long_context") observer = construct_extras.pop("observer", None) diff --git a/backend/management/services/model/health.py b/backend/management/services/model/health.py index 1950186a0f..c8b47b77c2 100644 --- a/backend/management/services/model/health.py +++ b/backend/management/services/model/health.py @@ -1,4 +1,5 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + import logging from typing import Optional @@ -328,14 +329,45 @@ async def _perform_connectivity_check( return connectivity -async def check_model_connectivity( - display_name: str, tenant_id: str, model_type: str = None -) -> dict: +async def check_model_connectivity(display_name: str, tenant_id: str, model_type: str = None) -> dict: + """Use one streamed LLM check; retain all other model checks unchanged.""" + model = get_model_by_display_name(display_name, tenant_id=tenant_id, model_type=model_type) + if model and model.get("model_type") in {"llm", "chat"}: + from consts.model import ModelOutletValidationRequest + from services.model_profile_service import validate_model_outlet + + result = await validate_model_outlet(ModelOutletValidationRequest(outlet_id=model["model_id"], + expected_revision=model.get("config_revision") or 1), tenant_id) + return {"connectivity": result["ok"], "model_name": model["model_name"], + **({"error": result["error"]["category"]} if result["error"] else {})} + if not model: + raise LookupError("MODEL_CONFIGURATION_NOT_FOUND") + return await _check_non_llm_connectivity(display_name, tenant_id, model_type, _model=model) + + +async def verify_model_config_connectivity(model_config: dict): + """Translate old LLM drafts without saving them or weakening TLS settings.""" + if model_config.get("model_type") in {"llm", "chat"}: + from datetime import datetime, timezone + + from configs.model_catalog_loader import resolve_reasoning_capability + from services.model_outlet_migration_service import build_legacy_outlet_snapshot + from services.model_profile_service import probe_model_profile + + name = model_config.get("model_name", "") + profile = build_legacy_outlet_snapshot({**model_config, "model_id": 1, "tenant_id": "connection-preview"}, + legacy_capability=resolve_reasoning_capability(name, model_config.get("base_url"), model_config.get("model_factory")), + migrated_at=datetime.now(timezone.utc).isoformat()) + result = await probe_model_profile(profile, model_config.get("api_key")) + return {"connectivity": result["ok"], "model_name": name, + **({"error": result["error"]["category"]} if result["error"] else {})} + return await _verify_non_llm_config_connectivity(model_config) + + +async def _check_non_llm_connectivity(display_name: str, tenant_id: str, model_type: str = None, *, _model=None) -> dict: try: # Query the database using display_name and tenant context from app layer - model = get_model_by_display_name( - display_name, tenant_id=tenant_id, model_type=model_type - ) + model = _model if _model is not None else get_model_by_display_name(display_name, tenant_id=tenant_id, model_type=model_type) if not model: raise LookupError(f"Model configuration not found for {display_name}") @@ -435,7 +467,9 @@ async def check_model_connectivity( raise e -async def verify_model_config_connectivity(model_config: dict): + + +async def _verify_non_llm_config_connectivity(model_config: dict): """ Verify the connectivity of the model configuration, do not save to the database. """ diff --git a/backend/management/services/model/invocation.py b/backend/management/services/model/invocation.py index 856590948d..ae3079bed2 100644 --- a/backend/management/services/model/invocation.py +++ b/backend/management/services/model/invocation.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + import logging import random import time @@ -7,9 +9,10 @@ from consts.error_code import ErrorCode from consts.exceptions import AppException from database.model_management_db import get_model_by_model_id -from management.services.model.adapter import get_llm_adapter_from_config +from nexent.core.model_errors import ModelErrorCode, ModelInvocationTerminalError from nexent.core.models.retry import get_retry_after_seconds from nexent.monitor import set_monitoring_context, set_monitoring_operation +from management.services.model.adapter import get_llm_adapter_from_config logger = logging.getLogger("llm_utils") @@ -151,21 +154,22 @@ def call_llm_for_system_prompt( llm_model_config.get("timeout_seconds") if llm_model_config else None ) + saved_outlet = isinstance(llm_model_config, dict) and llm_model_config.get("profile_schema_version") is not None llm = get_llm_adapter_from_config( llm_model_config, tenant_id, - temperature=0.3, - top_p=0.95, + **({} if saved_outlet else {"temperature": 0.3, "top_p": 0.95}), display_name=display_name or None, timeout_seconds=timeout_seconds, ) - # The gateway adapter lazily wraps the legacy OpenAIModel; the streaming - # completion below reaches the wrapped model's client, so build it first. + # Build the gateway's compatibility model before consuming its stream. llm._build_model() messages = [ {"role": MESSAGE_ROLE["SYSTEM"], "content": system_prompt}, {"role": MESSAGE_ROLE["USER"], "content": user_prompt}, ] + if saved_outlet: + return _consume_outlet_content(llm, messages, callback) for attempt in range(1, _LLM_RETRY_MAX_ATTEMPTS + 1): current_request = None try: @@ -297,4 +301,35 @@ def call_llm_for_system_prompt( ) +def _consume_outlet_content(llm, messages, callback): + """Consume normalized text with transport/retry owned by the saved outlet service.""" + stream = None + try: + stream = llm.generate_stream(messages) + text = [] + is_thinking = False + for delta in stream: + if delta.content is not None: + is_thinking = _process_thinking_tokens(delta.content, is_thinking, text, callback) + return "".join(text) + except ModelInvocationTerminalError as error: + codes = { + ModelErrorCode.AUTHENTICATION_ERROR: ErrorCode.MODEL_API_KEY_INVALID, + ModelErrorCode.NOT_FOUND: ErrorCode.MODEL_NOT_FOUND, + ModelErrorCode.RATE_LIMIT_EXHAUSTED: ErrorCode.MODEL_RATE_LIMIT_EXCEEDED, + ModelErrorCode.SERVICE_UNAVAILABLE: ErrorCode.MODEL_SERVICE_UNAVAILABLE, + ModelErrorCode.TIMEOUT: ErrorCode.MODEL_CONNECTION_ERROR, + ModelErrorCode.CONNECTION_ERROR: ErrorCode.MODEL_CONNECTION_ERROR, + } + raise AppException(codes.get(error.error_code, ErrorCode.MODEL_PROMPT_GENERATION_FAILED)) from error + except Exception as error: + raise AppException(ErrorCode.MODEL_PROMPT_GENERATION_FAILED) from error + finally: + if stream is not None: + try: + stream.close() + except Exception: + logger.warning("Failed to close prompt-generation normalized stream") + + __all__ = ["call_llm_for_system_prompt", "_process_thinking_tokens"] diff --git a/backend/management/services/model/management.py b/backend/management/services/model/management.py index b7bc280e89..078cff88ff 100644 --- a/backend/management/services/model/management.py +++ b/backend/management/services/model/management.py @@ -121,6 +121,28 @@ def _coerce_legacy_max_tokens_alias(model_data: Dict[str, Any]) -> None: model_data["max_tokens"] = max_output +def _create_model_configuration(model_data, user_id, tenant_id): + """All LLM configuration writers persist the same resolved format.""" + if model_data.get("model_type") in {"llm", "chat"}: + from services.model_outlet_legacy_service import create_legacy_llm_outlet + + return create_legacy_llm_outlet(model_data, user_id, tenant_id) + return create_model_record(model_data, user_id, tenant_id) + + +def _update_model_configuration(record, changes, user_id, tenant_id, *, scope_update=False): + """Do not allow legacy configuration writes to replace resolved JSON.""" + if record is None: + raise LookupError("MODEL_OUTLET_NOT_FOUND") + if record.get("model_type") in {"llm", "chat"}: + from services.model_outlet_legacy_service import update_legacy_llm_outlet + + return update_legacy_llm_outlet(record, changes, user_id, tenant_id) + if scope_update: + return update_model_record(record["model_id"], changes, user_id, tenant_id) + return update_model_record(record["model_id"], changes, user_id) + + async def create_model_for_tenant( user_id: str, tenant_id: str, @@ -237,7 +259,7 @@ async def create_model_for_tenant( if is_multimodal: # Create multi_embedding record - create_model_record(model_data, user_id, tenant_id) + _create_model_configuration(model_data, user_id, tenant_id) logging.debug( f"Multimodal embedding model {model_data['display_name']} created successfully" ) @@ -245,14 +267,15 @@ async def create_model_for_tenant( # Create embedding record variant embedding_data = model_data.copy() embedding_data["model_type"] = "embedding" - create_model_record(embedding_data, user_id, tenant_id) + _create_model_configuration(embedding_data, user_id, tenant_id) logging.debug( f"Embedding model {embedding_data['display_name']} created successfully" ) else: # Non-multimodal - create_model_record(model_data, user_id, tenant_id) - logging.debug(f"Model {model_data['display_name']} created successfully") + _create_model_configuration(model_data, user_id, tenant_id) + logging.debug( + f"Model {model_data['display_name']} created successfully") # Auto-configure default-model slots that the tenant never set. # Only the models created by THIS call are eligible for empty slots. @@ -639,9 +662,7 @@ async def batch_create_models_for_tenant( if existing_model.get("capacity_source") != "operator": update_data["capacity_source"] = "operator" if update_data: - update_model_record( - existing_model["model_id"], update_data, user_id - ) + _update_model_configuration(existing_model, update_data, user_id, tenant_id) continue model_dict = await prepare_model_dict( @@ -657,7 +678,7 @@ async def batch_create_models_for_tenant( # ============================================================ apply_catalog_defaults(model_dict, provider) _apply_model_reasoning_default(model_dict, provider) - create_model_record(model_dict, user_id, tenant_id) + _create_model_configuration(model_dict, user_id, tenant_id) if model_dict.get("display_name"): created_display_names.append(model_dict["display_name"]) logging.debug(f"Model {model['id']} created successfully") @@ -807,12 +828,8 @@ async def update_single_model_for_tenant( # Update both embedding and multi_embedding records for model in existing_models: # Prepare update data, excluding model_type to preserve original type - update_data = { - k: v - for k, v in model_data.items() - if k not in ["model_id", "model_type"] - } - update_model_record(model["model_id"], update_data, user_id) + update_data = {k: v for k, v in model_data.items() if k not in ["model_id", "model_type"]} + _update_model_configuration(model, update_data, user_id, tenant_id) logging.debug( f"Model {current_display_name} (embedding + multi_embedding) updated successfully" ) @@ -821,7 +838,7 @@ async def update_single_model_for_tenant( current_model = existing_models[0] current_model_id = current_model["model_id"] update_data = {k: v for k, v in model_data.items() if k != "model_id"} - update_model_record(current_model_id, update_data, user_id) + _update_model_configuration(current_model, update_data, user_id, tenant_id) logging.debug(f"Model {current_display_name} updated successfully") except LookupError: raise @@ -847,10 +864,8 @@ async def batch_update_models_for_tenant( model_id_or_name = model.get("model_id") or model.get("model_name") # Check if model_id is a numeric string (primary key) - if model_id_or_name and model_id_or_name.isdigit(): - update_model_record( - int(model_id_or_name), update_data, user_id, tenant_id - ) + if model_id_or_name is not None and str(model_id_or_name).isdigit(): + _update_model_configuration(get_model_by_model_id(int(model_id_or_name), tenant_id), update_data, user_id, tenant_id, scope_update=True) else: # Parse "model_repo/model_name" format from frontend's model_id field if "/" in model_id_or_name: @@ -873,9 +888,7 @@ async def batch_update_models_for_tenant( ) continue - update_model_record( - model_record["model_id"], update_data, user_id, tenant_id - ) + _update_model_configuration(model_record, update_data, user_id, tenant_id, scope_update=True) logging.info("[DEBUG] Batch update models successfully") except Exception as e: @@ -972,29 +985,27 @@ async def list_llm_models_for_tenant(tenant_id: str): result: List[Dict[str, Any]] = [] for record in records: _enrich_model_reasoning_capability(record) - result.append( - { - "model_id": record["model_id"], - "model_name": add_repo_to_name( - model_repo=record["model_repo"], - model_name=record["model_name"], - ), - "model_type": record.get("model_type", "llm"), - "connect_status": ModelConnectStatusEnum.get_value( - record.get("connect_status") - ), - "display_name": record["display_name"], - "api_key": record.get("api_key", ""), - "base_url": record.get("base_url", ""), - "max_tokens": record.get("max_tokens", 4096), - "extra_params": record.get("extra_params"), - **( - {"reasoning_capability": record["reasoning_capability"]} - if record.get("reasoning_capability") is not None - else {} - ), - } - ) + result.append({ + "model_id": record["model_id"], + "model_name": add_repo_to_name( + model_repo=record["model_repo"], + model_name=record["model_name"], + ), + "model_type": record.get("model_type", "llm"), + "connect_status": ModelConnectStatusEnum.get_value(record.get("connect_status")), + "display_name": record["display_name"], + "api_key": record.get("api_key", ""), + "base_url": record.get("base_url", ""), + "max_tokens": record.get("max_tokens", 4096), + "extra_params": record.get("extra_params"), + **{key: record[key] for key in ("profile_schema_version", "config_revision", "migration_status", + "migration_review_required", "migration_details", "migration_diagnostic") if key in record}, + **( + {"reasoning_capability": record["reasoning_capability"]} + if record.get("reasoning_capability") is not None + else {} + ), + }) logging.debug("Successfully retrieved model list") return result diff --git a/backend/management/services/model/providers/openai_provider.py b/backend/management/services/model/providers/openai_provider.py index 530d541864..604e54df3d 100644 --- a/backend/management/services/model/providers/openai_provider.py +++ b/backend/management/services/model/providers/openai_provider.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Generic OpenAI-compatible provider. Used as the fallback for any provider that is not covered by a dedicated @@ -38,7 +40,7 @@ async def get_models(self, provider_config: Dict) -> List[Dict]: if not url.endswith("/models"): url = f"{url}/models" - headers = {"Authorization": f"Bearer {model_api_key}"} + headers = {"Authorization": f"Bearer {model_api_key.strip()}"} if model_api_key.strip() else {} async with httpx.AsyncClient(verify=ssl_verify, timeout=30) as client: response = await client.get(url, headers=headers) diff --git a/backend/scripts/migrate_model_outlets.py b/backend/scripts/migrate_model_outlets.py new file mode 100644 index 0000000000..59db94c4c5 --- /dev/null +++ b/backend/scripts/migrate_model_outlets.py @@ -0,0 +1,26 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Preview or apply the one-time LLM outlet migration with sanitized summaries.""" + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--apply", action="store_true", help="Persist snapshots; default is a read-only preview") + parser.add_argument("--batch-size", type=int, default=100) + arguments = parser.parse_args() + from services.model_outlet_migration_service import migrate_legacy_outlets + + report = migrate_legacy_outlets(dry_run=not arguments.apply, batch_size=arguments.batch_size) + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 1 if report["failed"] else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/services/agent_model_parameters.py b/backend/services/agent_model_parameters.py new file mode 100644 index 0000000000..e24e6033c7 --- /dev/null +++ b/backend/services/agent_model_parameters.py @@ -0,0 +1,83 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Apply explicit per-Agent values to an immutable saved model outlet.""" + +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError +from nexent.core.model_service.profiles.protocol_parameters import ensure_outlet_sampling_rules + + +def merge_agent_model_parameters(profile, entry): + """Read a current/historical entry at the boundary, then apply canonical values.""" + from nexent.core.model_service.compatibility.agent_parameters import read_agent_parameter_entry + return _apply_agent_parameters(profile, read_agent_parameter_entry(entry)) + + +def _apply_agent_parameters(profile, entry): + from nexent.core.model_service.profiles.parameter_rules import overlay_profile_parameters + from nexent.core.model_service.profiles.request_parameters import overlay_extra_api_parameters + + window = entry.get("context_window_tokens") + if window is not None and (type(window) is not int or window <= 0): + raise ParameterValidationError("PARAMETER_VALUE_INVALID", "/context_window_tokens") + resolved = overlay_profile_parameters(ensure_outlet_sampling_rules(profile), entry["parameter_values"], scope="outlet") + if entry.get("custom_overrides") is not None: + data = resolved.model_dump(mode="json") + data["extra_api_parameters"] = overlay_extra_api_parameters(resolved, entry["custom_overrides"]) + resolved = resolved.__class__.model_validate(data) + reserve = resolved.parameter_values.get("max_output_tokens", profile.context_configuration.output_reserve_tokens) + if window is not None and type(reserve) is int and window <= reserve: + raise ParameterValidationError("PARAMETER_CONTEXT_TOO_SMALL", "/context_window_tokens") + return resolved + + +def merge_runtime_model_parameters(profile, entry, preferences): + """Validate each explicit layer, then merge without any legacy-format round trip.""" + from nexent.core.model_service.compatibility.agent_parameters import read_agent_parameter_entry + from services.conversation_model_preferences_service import merge_model_preferences + + if not isinstance(preferences, dict): + from consts.exceptions import ModelOutletValidationError + raise ModelOutletValidationError("CONVERSATION_PREFERENCES_INVALID") + canonical = read_agent_parameter_entry(entry) + _apply_agent_parameters(profile, canonical) + combined = {**canonical, "parameter_values": {**canonical["parameter_values"], **preferences}} + try: + resolved = _apply_agent_parameters(profile, combined) + except ParameterValidationError as error: + from consts.exceptions import ModelOutletValidationError + raise ModelOutletValidationError(error.category, error.path) from None + return merge_model_preferences(resolved, preferences) + + +def bind_agent_model_profiles(model_list, root_config, root_info, load_agent_info): + """Give each Agent's selected outlet an independent parameter view.""" + from copy import deepcopy + from uuid import uuid4 + + from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile + + originals = {model.cite_name: model for model in model_list} + + def bind(config, info=None): + model = originals.get(config.model_name) + profile = getattr(model, "resolved_profile", None) + if isinstance(profile, ResolvedModelProfile): + if info is None: + info = load_agent_info(config.agent_id, config.version_no) + entry = ((info or {}).get("model_params_override") or {}).get(str(profile.outlet_id)) + if entry: + resolved = merge_agent_model_parameters(profile, entry) + private = deepcopy(model) + private.cite_name = "agent_model_" + uuid4().hex + private.resolved_profile = resolved + for key in ("temperature", "top_p", "enable_thinking", "reasoning_effort", "reasoning_budget_tokens"): + value = resolved.parameter_values.get(key) + setattr(private, key, value is True if key == "enable_thinking" else value) + if entry.get("context_window_tokens") is not None: + private.context_window_tokens = entry["context_window_tokens"] + model_list.append(private) + config.model_name = private.cite_name + for child in getattr(config, "worker_agents", []): + bind(child) + + bind(root_config, root_info) diff --git a/backend/services/conversation_model_preferences_service.py b/backend/services/conversation_model_preferences_service.py new file mode 100644 index 0000000000..b841bda437 --- /dev/null +++ b/backend/services/conversation_model_preferences_service.py @@ -0,0 +1,125 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Conversation-only parameter patches validated against frozen outlet rules.""" + +from consts.exceptions import ModelOutletValidationError +from nexent.core.model_service.profiles.parameter_rules import ( + overlay_profile_parameters, + patch_parameter_values, +) +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError +from nexent.core.model_service.profiles.protocol_parameters import ensure_outlet_sampling_rules + + +def merge_model_preferences(profile, preferences): + """Do not discard invalid stored choices or apply creation defaults during a run.""" + if not isinstance(preferences, dict): + raise ModelOutletValidationError("CONVERSATION_PREFERENCES_INVALID") + profile = ensure_outlet_sampling_rules(profile) + try: + resolved = overlay_profile_parameters(profile, preferences, scope="conversation") + values = resolved.parameter_values + output = values.get("max_output_tokens", values.get("max_tokens")) + context = profile.capabilities.context + limits = [value for value in (context.output_limit, context.combined_window) if value is not None] + if type(output) is int and limits and output > min(limits): + raise ModelOutletValidationError("PARAMETER_LIMIT_EXCEEDED", "/max_output_tokens") + return resolved + except ParameterValidationError as error: + raise ModelOutletValidationError(error.category, error.path) from None + + +def read_model_preferences(conversation_id, user_id): + from database.conversation_model_preferences_db import model_preferences + + return model_preferences(conversation_id, user_id) + + +def patch_model_preferences(conversation_id, outlet_id, request, user_id, tenant_id): + from database.conversation_model_preferences_db import model_preferences + from database.model_outlet_db import get_resolved_model_record + from nexent.core.model_service.registry import model_service_references + + # Check conversation access before reading an outlet or validating its fields. + read_model_preferences(conversation_id, user_id) + profile = get_resolved_model_record(outlet_id, tenant_id, registered_references=model_service_references()) + if profile is None or profile.identity.model_type != "llm": + raise ModelOutletValidationError("MODEL_OUTLET_NOT_AVAILABLE") + deleted = request.delete_parameters + try: + patch_parameter_values({}, request.parameter_values, deleted) + except ParameterValidationError as error: + raise ModelOutletValidationError(error.category, error.path) from None + + def resolve(saved): + if not isinstance(saved, dict): + raise ModelOutletValidationError("CONVERSATION_PREFERENCES_INVALID") + updated = patch_parameter_values(saved, request.parameter_values, deleted) + merge_model_preferences(profile, updated) + return updated + + return model_preferences(conversation_id, user_id, expected_version=request.expected_version, + outlet_id=outlet_id, resolve_values=resolve) + + +def read_model_preference_view(conversation_id, outlet_id, user_id, tenant_id): + from database.model_outlet_db import get_resolved_model_record + from nexent.core.model_service.registry import model_service_references + + saved = read_model_preferences(conversation_id, user_id) + profile = get_resolved_model_record(outlet_id, tenant_id, registered_references=model_service_references()) + if profile is None or profile.identity.model_type != "llm": + raise ModelOutletValidationError("MODEL_OUTLET_NOT_AVAILABLE") + profile = ensure_outlet_sampling_rules(profile) + values = saved["model_parameter_preferences"].get(str(outlet_id), {}) + issues = [] + try: + merge_model_preferences(profile, values) + except ModelOutletValidationError as error: + issues.append({"category": error.category, "field": error.path}) + return {"version": saved["version"], "parameter_values": values, "defaults": profile.parameter_values, + "output_limit": profile.capabilities.context.output_limit, + "field_specs": profile.parameter_rules, "config_revision": profile.config_revision, "issues": issues} + + +def validate_model_preferences(preferences, tenant_id, *, allowed_model_ids=None): + """Validate first-turn drafts with the same frozen rules as preference PATCH.""" + from consts.exceptions import ForbiddenError + from database.model_outlet_db import get_resolved_model_record + from management.services.model.run_controls import validate_selected_model + from nexent.core.model_service.registry import model_service_references + + if not isinstance(preferences, dict): + raise ModelOutletValidationError("CONVERSATION_PREFERENCES_INVALID") + for key, values in preferences.items(): + if not isinstance(key, str) or not key.isdecimal() or str(int(key)) != key or int(key) <= 0: + raise ModelOutletValidationError("CONVERSATION_PREFERENCES_INVALID") + outlet_id = int(key) + if allowed_model_ids and outlet_id not in allowed_model_ids: + raise ForbiddenError("The selected model is not configured for this Agent") + validate_selected_model(outlet_id, tenant_id) + profile = get_resolved_model_record(outlet_id, tenant_id, registered_references=model_service_references()) + if profile is None or profile.identity.model_type != "llm": + raise ModelOutletValidationError("MODEL_OUTLET_NOT_AVAILABLE") + merge_model_preferences(profile, values) + + +def patch_model_selection(conversation_id, request, user_id, tenant_id): + """Both Agent chat modes save selection through the same ownership and CAS path.""" + from types import SimpleNamespace + from consts.exceptions import ConversationNotFoundError + from database.agent_db import search_agent_info_by_agent_id + from database.conversation_model_preferences_db import model_preferences + from management.services.conversation.service import get_conversation_service + from management.services.model.run_controls import validate_agent_model_selection + + conversation = get_conversation_service(conversation_id, user_id, tenant_id) + if conversation is None: + raise ConversationNotFoundError("Conversation not found") + workbench = isinstance(conversation.get("workbench_config"), dict) + selection = SimpleNamespace(model_id=request.selected_model_id, workbench=None, + entrypoint="workbench" if workbench else None, is_debug=False, + agent_id=conversation.get("agent_id"), version_no=None) + validate_agent_model_selection(selection, tenant_id, search_agent_info_by_agent_id) + return model_preferences(conversation_id, user_id, expected_version=request.expected_version, + selected_model_id=request.selected_model_id) diff --git a/backend/services/model_outlet_legacy_service.py b/backend/services/model_outlet_legacy_service.py new file mode 100644 index 0000000000..8ad87743bd --- /dev/null +++ b/backend/services/model_outlet_legacy_service.py @@ -0,0 +1,120 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Translate old LLM configuration payloads at the service boundary only.""" + +from copy import deepcopy +from datetime import UTC, datetime + +from consts.exceptions import ModelOutletValidationError + + +def create_legacy_llm_outlet(model_data, user_id, tenant_id): + """Save an old create request as a complete outlet; never insert a legacy row.""" + from configs.model_catalog_loader import resolve_reasoning_capability + from database.model_outlet_db import create_resolved_model_record + from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile + from nexent.core.model_service.registry import model_service_references + from services.model_outlet_migration_service import build_legacy_outlet_snapshot + from management.services.model.names import add_repo_to_name + + row = {**deepcopy(model_data), "model_id": 1, "tenant_id": tenant_id} + name = add_repo_to_name(row.get("model_repo") or "", row["model_name"]) + capability = resolve_reasoning_capability(name, row.get("base_url"), row.get("model_factory")) + profile = build_legacy_outlet_snapshot(row, legacy_capability=capability, + migrated_at=datetime.now(UTC).isoformat()) + data = profile.model_dump(mode="json") + data["outlet_id"] = None + data["connection"]["credential_ref"]["model_id"] = None + # This is a newly created outlet, not a migrated historical record. + data["provenance"]["provider_metadata"].pop("migration", None) + return create_resolved_model_record(ResolvedModelProfile.model_validate(data), user_id, tenant_id, + registered_references=model_service_references(), api_key=model_data.get("api_key"), + connect_status=model_data.get("connect_status") or "not_detected") + + +def update_legacy_llm_outlet(record, changes, user_id, tenant_id): + """Translate partial old updates using saved rules, without consulting current TOML.""" + from consts.model import ModelOutletPatchRequest + from nexent.core.agents.agent_model import ModelConfig + from nexent.core.model_service.profiles.outlet_storage import ( + decode_resolved_model_profile, + ) + from nexent.core.model_service.registry import model_service_references + from services.model_outlet_migration_service import _leaves + from services.model_profile_service import update_model_outlet + from management.services.model.names import add_repo_to_name + + if not record.get("profile_schema_version"): + return _repair_legacy_outlet(record, changes, user_id, tenant_id) + profile = decode_resolved_model_profile(record, registered_references=model_service_references()) + if "model_name" in changes: + name = add_repo_to_name(changes.get("model_repo") or "", changes["model_name"]) + if name != profile.identity.request_model_id: + raise ModelOutletValidationError("OUTLET_IDENTITY_IMMUTABLE", "/model_name") + if changes.get("model_factory", profile.identity.provider_id) != profile.identity.provider_id: + raise ModelOutletValidationError("OUTLET_IDENTITY_IMMUTABLE", "/model_factory") + values = {key: changes[key] for key in ("temperature", "top_p") if key in changes} + extra = changes.get("extra_params") + if "extra_params" in changes and extra is not None and not isinstance(extra, dict): + raise ModelOutletValidationError() + if extra is not None: + values.update({key: value for key, value in extra.items() if key != "__custom__"}) + if "max_output_tokens" in changes or "max_tokens" in changes: + config = ModelConfig(cite_name=profile.identity.display_name, model_name=profile.identity.request_model_id, + url=profile.connection.base_url, max_output_tokens=changes.get("max_output_tokens"), + max_tokens=changes.get("max_tokens")) + values["max_output_tokens"] = config.max_output_tokens + patch = {"expected_revision": changes.get("config_revision", profile.config_revision), "parameter_values": values} + for field in ("display_name", "api_key"): + if field in changes: + patch[field] = changes[field] + connection = {key: changes[key] for key in ("base_url", "ssl_verify", "timeout_seconds", "concurrency_limit") + if key in changes} + if connection: + patch["connection"] = connection + if "extra_params" in changes: + extra = extra or {} + raw_custom = extra.get("__custom__") + if raw_custom is None: + raw_custom = {} + if not isinstance(raw_custom, dict): + raise ModelOutletValidationError() + patch["extra_api_parameters"] = [{"path": path, "value": value} for path, value in _leaves(raw_custom)] + # The old extra_params field is a replacement bag, unlike the new PATCH contract. + previous_extra_keys = set(profile.parameter_values) - {"temperature", "top_p", "max_output_tokens", "max_tokens"} + patch["delete_parameters"] = sorted(previous_extra_keys - values.keys()) + context = {field: changes[column] for field, column in (("combined_window", "context_window_tokens"), + ("input_limit", "max_input_tokens"), ("output_limit", "max_output_tokens")) if column in changes} + if context: + patch["context_limits"] = context + configuration = {field: changes[column] for field, column in (("output_reserve_tokens", "default_output_reserve_tokens"), + ("tokenizer_family", "tokenizer_family"), ("capacity_source", "capacity_source"), + ("capability_profile_version", "capability_profile_version")) if column in changes} + if configuration: + patch["context_configuration"] = configuration + return update_model_outlet(record["model_id"], ModelOutletPatchRequest(**patch), user_id, tenant_id) + + +def _repair_legacy_outlet(record, changes, user_id, tenant_id): + from configs.model_catalog_loader import resolve_reasoning_capability + from database.model_outlet_db import repair_legacy_model_record + from nexent.core.model_service.registry import model_service_references + from services.model_outlet_migration_service import build_legacy_outlet_snapshot + from management.services.model.names import add_repo_to_name + + allowed = {"display_name", "model_name", "model_repo", "model_factory", "base_url", "api_key", "ssl_verify", + "timeout_seconds", "concurrency_limit", "temperature", "top_p", "extra_params", "max_tokens", + "max_output_tokens", "context_window_tokens", "max_input_tokens", "default_output_reserve_tokens", + "tokenizer_family", "capacity_source", "capability_profile_version"} + + def construct(current): + current.update({key: value for key, value in changes.items() if key in allowed}) + name = add_repo_to_name(current.get("model_repo") or "", current["model_name"]) + capability = resolve_reasoning_capability(name, current.get("base_url"), current.get("model_factory")) + profile = build_legacy_outlet_snapshot(current, legacy_capability=capability, + migrated_at=datetime.now(UTC).isoformat()) + return profile, current.get("api_key") + + return repair_legacy_model_record(record["model_id"], tenant_id, user_id, + changes.get("config_revision", record.get("config_revision") or 1), construct, + registered_references=model_service_references()) diff --git a/backend/services/model_outlet_migration_service.py b/backend/services/model_outlet_migration_service.py new file mode 100644 index 0000000000..f897befc38 --- /dev/null +++ b/backend/services/model_outlet_migration_service.py @@ -0,0 +1,220 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""One-time legacy LLM snapshot construction; database ownership stays in the DAL.""" + +from copy import deepcopy + +from consts.exceptions import ModelOutletValidationError +from consts.model import ModelProfilePreviewRequest +from nexent.core.agents.agent_model import ModelConfig +from nexent.core.model_service.compatibility.legacy_config import legacy_agent_request_parameters, legacy_thinking_enabled +from nexent.core.model_service.profiles.outlet_storage import decode_resolved_model_profile, encode_resolved_model_profile +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile +from nexent.core.model_service.registry import model_service_references +from pydantic import ValidationError +from services.model_profile_service import build_outlet_draft +from management.services.model.names import add_repo_to_name + +MIGRATION_VERSION = "legacy_llm_outlet_v2" + + +def _pointer(key): + return key.replace("~", "~0").replace("/", "~1") + + +def _leaves(value, prefix=""): + for key, child in value.items(): + path = prefix + "/" + _pointer(key) + if isinstance(child, dict) and child: + yield from _leaves(child, path) + else: + yield path, child + + +def _rule(key, value, path=None, transform="identity_v1"): + schema = {"type": {bool: "boolean", int: "integer", float: "number", str: "string", + list: "array", dict: "object", type(None): "null"}[type(value)]} + # Sampling NULL is an explicit provider default, not a missing stored key. + if key in {"temperature", "top_p"}: + schema = {"type": ["number", "null"]} + result = {"key": key, "schema": schema, "visible": path is not None, + "editable": path is not None, "required": False, "allowed_scopes": ["outlet", "conversation"], + "ui_group": "reasoning" if key.startswith("reasoning") or key == "enable_thinking" else "advanced", + "owner": "combination", "source": {"ref": MIGRATION_VERSION, "version": 1}} + if path is not None: + result["api_binding"] = {"path": path, "transform_ref": transform, "transform_version": 1} + return result + + +def _parameters(profile, body, legacy_extra): + mapped = {"/temperature": "temperature", "/top_p": "top_p", "/max_tokens": "max_output_tokens", + "/reasoning_effort": "reasoning_effort", "/thinking_budget": "reasoning_budget_tokens", + "/thinking/budget_tokens": "reasoning_budget_tokens", "/enable_thinking": "enable_thinking", + "/chat_template_kwargs/enable_thinking": "enable_thinking", "/thinking/type": "enable_thinking"} + rules, values, extras = {}, {}, [] + for path, value in _leaves(body): + key = mapped.get(path) + if key is None: + extras.append({"path": path, "value": value}) + continue + if key in values: + raise ModelOutletValidationError(category="LEGACY_PARAMETER_BINDING_CONFLICT", path=key) + transform = "identity_v1" + if path == "/thinking/type": + if value not in ("enabled", "disabled"): + raise ModelOutletValidationError(category="LEGACY_THINKING_FORMAT_INVALID", path=key) + value = value == "enabled" + transform = "thinking_toggle_v1" + values[key], rules[key] = value, _rule(key, value, path, transform) + unbound = [] + for key in ("enable_thinking", "reasoning_effort", "reasoning_budget_tokens"): + if key not in values and key in legacy_extra: + values[key], rules[key] = legacy_extra[key], _rule(key, legacy_extra[key]) + unbound.append(key) + # Keep unconfigured runtime-option declarations without introducing new defaults. + for declaration in profile.parameter_rules: + key = declaration["key"] + if key not in rules: + declaration = deepcopy(declaration) + for field in ("default_value", "fixed_value", "condition"): + declaration.pop(field, None) + declaration["required"] = False + rules[key] = declaration + if "max_output_tokens" not in rules and "max_tokens" not in rules: + declaration = _rule("max_output_tokens", 1, "/max_tokens") + declaration["schema"]["minimum"] = 1 + rules["max_output_tokens"] = declaration + if "stop" not in rules: + declaration = _rule("stop", [], "/stop") + declaration.update(visible=False, allowed_scopes=["outlet"], ui_group="runtime") + declaration["schema"] = {"type": ["string", "array"], "items": {"type": "string"}} + rules["stop"] = declaration + if "response_format" not in rules: + declaration = _rule("response_format", {}, "/response_format") + declaration.update(visible=False, allowed_scopes=["outlet"], ui_group="runtime") + rules["response_format"] = declaration + return list(rules.values()), values, extras, unbound + + +def build_legacy_outlet_snapshot(row, *, legacy_capability, migrated_at, from_version="pre-profile"): + """Preserve effective legacy values and references; never read current TOML for saved rows.""" + refs = model_service_references() + if row.get("profile_schema_version") is not None: + return decode_resolved_model_profile(row, registered_references=refs) + if row.get("model_type") not in {"llm", "chat"}: + raise ModelOutletValidationError(category="LEGACY_MODEL_TYPE_UNSUPPORTED") + try: + name = add_repo_to_name(row.get("model_repo") or "", row["model_name"]) + request = ModelProfilePreviewRequest(request_model_id=name, display_name=row.get("display_name") or name, + authentication_scheme="bearer_token_v1" if row.get("api_key") else "no_auth_v1", + provider_info={"base_url": row["base_url"], "provider_id": row.get("model_factory") or "custom", + "ssl_verify": row.get("ssl_verify") if row.get("ssl_verify") is not None else True, + "timeout_seconds": row.get("timeout_seconds") or 60, + "concurrency_limit": row.get("concurrency_limit")}) + profile, _ = build_outlet_draft(request, row["tenant_id"]) + body = legacy_agent_request_parameters(row, model_id=name, capability=legacy_capability) + old = ModelConfig(cite_name=request.display_name, model_name=name, url=row["base_url"], + max_tokens=row.get("max_tokens"), max_output_tokens=row.get("max_output_tokens"), + context_window_tokens=row.get("context_window_tokens")) + if row.get("default_output_reserve_tokens") is not None: + # The legacy W2 budget supplied this value to the actual model request. + body["max_tokens"] = row["default_output_reserve_tokens"] + elif old.max_output_tokens is not None: + body.setdefault("max_tokens", old.max_output_tokens) + data = profile.model_dump(mode="json") + rules, values, extras, unbound = _parameters(profile, body, row.get("extra_params") or {}) + data.update(outlet_id=row["model_id"], config_revision=row.get("config_revision") or 1, + parameter_rules=rules, parameter_values=values, extra_api_parameters=extras) + data["connection"]["credential_ref"]["model_id"] = row["model_id"] + defaults = [] + context = data["capabilities"]["context"] + for field, column in (("combined_window", "context_window_tokens"), ("input_limit", "max_input_tokens"), + ("output_limit", "max_output_tokens")): + if row.get(column) is not None: + context[field] = row[column] + if context["combined_window"] is None: + context["combined_window"] = 200000 + defaults.append("capabilities.context.combined_window") + for field in ("input_limit", "output_limit"): + if row.get({"input_limit": "max_input_tokens", "output_limit": "max_output_tokens"}[field]) is None: + if context[field] is not None and context[field] > context["combined_window"]: + context[field] = None + for field, column in (("output_reserve_tokens", "default_output_reserve_tokens"), + ("tokenizer_family", "tokenizer_family"), ("capacity_source", "capacity_source"), + ("capability_profile_version", "capability_profile_version")): + data["context_configuration"][field] = row.get(column) + controls = [] + if "enable_thinking" in values: + controls.append({"mode": "toggle", "parameter_key": "enable_thinking", "values": [False, True]}) + if "reasoning_effort" in values: + controls.append({"mode": "effort", "parameter_key": "reasoning_effort", "values": [values["reasoning_effort"]]}) + if type(values.get("reasoning_budget_tokens")) is int: + value = values["reasoning_budget_tokens"] + controls.append({"mode": "budget_tokens", "parameter_key": "reasoning_budget_tokens", "min": 0, + "max": None, "special_values": [value] if value < 0 else []}) + reasoning = data["capabilities"]["reasoning"] + declared_reasoning = row.get("reasoning_supported") + enabled_legacy = legacy_thinking_enabled(row.get("extra_params")) + if declared_reasoning is True or (declared_reasoning is None and enabled_legacy): + reasoning.update(status="supported", controls=controls or reasoning["controls"]) + elif declared_reasoning is False: + reasoning.update(status="unsupported", controls=None, response_paths=[]) + elif reasoning["status"] == "unknown": + reasoning.update(status="unsupported", controls=None, response_paths=[]) + defaults.append("capabilities.reasoning.status") + if reasoning["status"] == "supported" and controls: + reasoning["controls"] = controls + if reasoning["controls"] is not None: + reasoning["controls"] = [control for control in reasoning["controls"] + if not (control["parameter_key"] in unbound and values.get(control["parameter_key"]) is None)] + tools = data["capabilities"]["tools"] + if row.get("native_tool_call_supported") is not None: + tools["status"] = "supported" if row["native_tool_call_supported"] else "unsupported" + elif tools["status"] == "unknown": + tools["status"] = "unsupported" + defaults.append("capabilities.tools.status") + data["provenance"]["provider_metadata"]["migration"] = { + "origin": "legacy_model_record", "from_version": from_version, "version": MIGRATION_VERSION, + "migrated_at": migrated_at, "reviewed_revision": None, "defaulted_fields": defaults, + "unmatched_atoms": [kind for kind, key in (("model", profile.model_profile_key), + ("provider", profile.provider_key)) if key is None], + "unbound_legacy_parameters": unbound, + } + resolved = ResolvedModelProfile.model_validate(data) + encode_resolved_model_profile(resolved, registered_references=refs, + protected_paths=("/api_key", "/authorization", "/headers", "/credentials", "/base_url")) + return resolved + except ModelOutletValidationError: + raise + except (KeyError, TypeError, ValueError, ValidationError): + raise ModelOutletValidationError(category="LEGACY_OUTLET_INVALID") from None + + +def migrate_legacy_outlets(*, dry_run=False, batch_size=100): + """Process each legacy LLM once per invocation, retaining sanitized failure diagnostics.""" + from datetime import datetime, timezone + + from configs.model_catalog_loader import resolve_reasoning_capability + from database.model_outlet_db import migrate_legacy_model_records + + migrated_at = datetime.now(timezone.utc).isoformat() + + def construct(record): + name = add_repo_to_name(record.get("model_repo") or "", record["model_name"]) + capability = resolve_reasoning_capability(name, record.get("base_url"), record.get("model_factory"), + allow_gateway_fallback=False) + return build_legacy_outlet_snapshot(record, legacy_capability=capability, migrated_at=migrated_at) + + results = [] + cursor = 0 + while True: + batch = migrate_legacy_model_records(construct, registered_references=model_service_references(), + batch_size=batch_size, after_model_id=cursor, dry_run=dry_run) + if not batch: + break + results.extend(batch) + cursor = max(item["model_id"] for item in batch) + return {"migration_version": MIGRATION_VERSION, "dry_run": dry_run, + "migrated": sum(item["status"] == "migrated" for item in results), + "ready": sum(item["status"] == "ready" for item in results), + "failed": sum(item["status"] == "failed" for item in results), "items": results} diff --git a/frontend/components/chat/ModelThinkingToggle.tsx b/frontend/components/chat/ModelThinkingToggle.tsx new file mode 100644 index 0000000000..42a915c54e --- /dev/null +++ b/frontend/components/chat/ModelThinkingToggle.tsx @@ -0,0 +1,42 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; + +export function ModelThinkingToggle({ + checked, + disabled, + onChange, +}: { + checked: boolean; + disabled?: boolean; + onChange: (value: boolean) => void; +}) { + const { t } = useTranslation(); + return ( +
+ {t("chat.modelSelector.deepThinking")} + +
+ ); +} diff --git a/frontend/components/model/ModelCapacityFields.tsx b/frontend/components/model/ModelCapacityFields.tsx new file mode 100644 index 0000000000..dd551b29fa --- /dev/null +++ b/frontend/components/model/ModelCapacityFields.tsx @@ -0,0 +1,53 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { Form, InputNumber } from "antd"; +import { useTranslation } from "react-i18next"; + +/** Shared capacity inputs for add, batch configuration and saved outlet editing. */ +export function ModelCapacityFields({ + contextWindow, + outputLimit, + onChange, +}: { + contextWindow?: number | null; + outputLimit?: number | null; + onChange: ( + key: "combined_window" | "output_limit", + value: number | null + ) => void; +}) { + const { t } = useTranslation(); + return ( +
+ {(["combined_window", "output_limit"] as const).map((key) => { + const label = t( + key === "combined_window" + ? "modelProfile.combined_window" + : "modelProfile.maximumOutput" + ); + return ( + + onChange(key, value)} + /> + + ); + })} +
+ ); +} diff --git a/frontend/components/model/ModelOutletForm.tsx b/frontend/components/model/ModelOutletForm.tsx new file mode 100644 index 0000000000..8f3902bed6 --- /dev/null +++ b/frontend/components/model/ModelOutletForm.tsx @@ -0,0 +1,17 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import type { ReactNode } from "react"; +import { ConfigProvider, Form, type FormProps } from "antd"; + +/** Match the project's 28px input contract without changing other model editors. */ +export function ModelOutletForm( + props: Omit & { children?: ReactNode } +) { + return ( + +
+ + ); +} diff --git a/frontend/components/model/ModelParameterField.tsx b/frontend/components/model/ModelParameterField.tsx new file mode 100644 index 0000000000..27178375a0 --- /dev/null +++ b/frontend/components/model/ModelParameterField.tsx @@ -0,0 +1,177 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { RotateCcw } from "lucide-react"; +import { Form, Input, InputNumber, Select, Switch } from "antd"; +import { useTranslation } from "react-i18next"; +import { StandardInput } from "@/components/common/StandardInput"; +import { StandardButton } from "@/components/common/StandardButton"; +import type { ModelParameterRule } from "@/types/modelProfile"; + +interface ModelParameterFieldProps { + rule: ModelParameterRule; + values: Record; + fallbackValues?: Record; + pending?: boolean; + resetLabel?: string; + label?: string; + hideReset?: boolean; + scope?: "outlet" | "conversation"; + inModelSelector?: boolean; + onChange: (key: string, value: unknown) => void; + onReset: (key: string) => void; +} + +export function ModelParameterField({ + rule, + values, + fallbackValues = {}, + pending = false, + resetLabel, + label, + hideReset = false, + scope = "outlet", + inModelSelector = false, + onChange, + onReset, +}: ModelParameterFieldProps) { + const { t } = useTranslation(); + const value = Object.hasOwn(values, rule.key) + ? values[rule.key] + : fallbackValues[rule.key]; + const disabled = + !rule.editable || !rule.allowed_scopes.includes(scope) || pending; + const options = rule.schema.enum; + const types = [rule.schema.type].flat(); + let control; + if (Array.isArray(options)) + control = ( +