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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions backend/apps/config_app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.

import asyncio
import logging
from contextlib import asynccontextmanager
Expand Down Expand Up @@ -68,6 +69,7 @@
ManagedTaskSpec,
ManagerState,
clear_default_thread_manager,
run_blocking,
set_default_thread_manager,
)
from consts.task_recovery import CONFIG_SERVICE_NAME
Expand Down Expand Up @@ -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
Expand Down
177 changes: 175 additions & 2 deletions backend/apps/model_managment_app.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -25,6 +26,11 @@
BackfillDefaultsRequest,
CapacitySuggestionFields,
ModelRequest,
ModelProfilePreviewRequest,
ModelOutletCreateRequest,
ModelOutletPatchRequest,
ModelOutletValidationRequest,
ModelOutletMigrationReviewRequest,
ModelProbeRequest,
ModelCapacitySuggestionRequest,
ModelCapacitySuggestionResponse,
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
54 changes: 5 additions & 49 deletions backend/configs/model_catalog_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand All @@ -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()
Expand Down Expand Up @@ -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}
53 changes: 53 additions & 0 deletions backend/database/conversation_model_preferences_db.py
Original file line number Diff line number Diff line change
@@ -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)}
Loading