diff --git a/backend/database/conversation_db.py b/backend/database/conversation_db.py
index af37222ce8..16e9d376b0 100644
--- a/backend/database/conversation_db.py
+++ b/backend/database/conversation_db.py
@@ -205,7 +205,9 @@ def create_conversation(conversation_title: str, user_id: Optional[str] = None,
chat_mode: Optional[str] = None,
knowledge_scope: Optional[Dict[str, Any]] = None,
runtime_metadata: Optional[Dict[str, Any]] = None,
- workbench_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
+ workbench_config: Optional[Dict[str, Any]] = None,
+ model_parameter_preferences: Optional[Dict[str, Any]] = None,
+ selected_model_id: Optional[int] = None) -> Dict[str, Any]:
"""
Create a new conversation record
@@ -236,6 +238,11 @@ def create_conversation(conversation_title: str, user_id: Optional[str] = None,
if workbench_config is not None:
data["workbench_config"] = deepcopy(workbench_config)
data["workbench_config_version"] = 1
+ if selected_model_id is not None:
+ data["selected_model_id"] = selected_model_id
+ if model_parameter_preferences:
+ data["model_parameter_preferences"] = deepcopy(model_parameter_preferences)
+ data["model_parameter_preferences_version"] = 1
if user_id:
data = add_creation_tracking(data, user_id)
@@ -843,7 +850,7 @@ def resolve_conversation_runtime_metadata(
def _assert_workbench_topology(record, config: Dict[str, Any]) -> None:
"""Keep entrypoint and creation workflows stable while allowing chat mounts to change."""
current = record.workbench_config
- if not isinstance(current, dict) or current.get("schema_version") != 3:
+ if not isinstance(current, dict) or current.get("schema_version") != 4:
raise WorkbenchError("WORKBENCH_CONVERSATION_REQUIRED", status_code=409)
creation_modes = {"skill_create", "agent_create"}
if current.get("mode") in creation_modes or config.get("mode") in creation_modes:
diff --git a/backend/database/model_outlet_db.py b/backend/database/model_outlet_db.py
new file mode 100644
index 0000000000..8d92440384
--- /dev/null
+++ b/backend/database/model_outlet_db.py
@@ -0,0 +1,425 @@
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
+"""Resolved model-record operations using caller-owned transactions and revisions."""
+
+from collections.abc import Mapping, Sequence
+
+from consts.exceptions import (
+ ModelOutletConflictError,
+ ModelOutletDatabaseError,
+ ModelOutletValidationError,
+)
+from nexent.core.model_service.profiles.contracts import VersionedReference
+from nexent.core.model_service.profiles.outlet_storage import (
+ decode_resolved_model_profile,
+ encode_resolved_model_profile,
+)
+from nexent.core.model_service.profiles.parameter_rules import (
+ compile_parameter_rules,
+ validate_saved_parameter_values,
+)
+from nexent.core.model_service.profiles.parameter_schema import json_values_equal
+from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile
+from sqlalchemy import func, insert, select, update
+from sqlalchemy.exc import SQLAlchemyError
+
+from .client import get_db_session
+from .db_models import ModelRecord
+
+_UNSET = object()
+
+
+def _string(value, maximum=100, *, optional=False):
+ if optional and value is None:
+ return
+ if (not isinstance(value, str) or len(value) > maximum or "\x00" in value
+ or (not optional and not value.strip())):
+ raise ModelOutletValidationError()
+ try:
+ value.encode("utf-8")
+ except UnicodeError:
+ raise ModelOutletValidationError() from None
+
+
+def _credential(value):
+ _string(value, 500, optional=True)
+
+
+def _check_json_strings(value):
+ if isinstance(value, str):
+ if "\x00" in value:
+ raise ModelOutletValidationError()
+ elif isinstance(value, dict):
+ for key, child in value.items():
+ _check_json_strings(key)
+ _check_json_strings(child)
+ elif isinstance(value, list):
+ for child in value:
+ _check_json_strings(child)
+
+
+def _execute(session, statement):
+ try:
+ return session.execute(statement)
+ except SQLAlchemyError:
+ # Session owners handle rollback; never expose statement parameters to their logger.
+ raise ModelOutletDatabaseError() from None
+
+
+def _active_statement(model_id, tenant_id):
+ if type(model_id) is not int or not 0 < model_id < 2**31:
+ raise ModelOutletValidationError()
+ _string(tenant_id)
+ return select(ModelRecord.__table__).where(
+ ModelRecord.model_id == model_id,
+ ModelRecord.tenant_id == tenant_id,
+ ModelRecord.delete_flag == "N",
+ )
+
+
+def create_resolved_model_record(
+ profile: ResolvedModelProfile,
+ user_id: str,
+ tenant_id: str,
+ *,
+ registered_references: Mapping[str, Sequence[VersionedReference]],
+ api_key: str | None = None,
+ connect_status: str = "not_detected",
+ db_session=None,
+ protected_paths: Sequence[str] = (),
+) -> int:
+ """Insert one complete snapshot; return its assigned identifier without committing shared sessions."""
+ _string(user_id)
+ _string(tenant_id)
+ _credential(api_key)
+ if connect_status not in {"not_detected", "detecting", "available", "unavailable"}:
+ raise ModelOutletValidationError()
+ if (
+ not isinstance(profile, ResolvedModelProfile)
+ or profile.outlet_id is not None
+ or profile.config_revision != 1
+ or profile.identity.tenant_id != tenant_id
+ ):
+ raise ModelOutletValidationError()
+ values = encode_resolved_model_profile(
+ profile,
+ registered_references=registered_references,
+ protected_paths=protected_paths,
+ )
+ _check_json_strings(values)
+ values.update(
+ api_key=api_key,
+ connect_status=connect_status,
+ created_by=user_id,
+ updated_by=user_id,
+ delete_flag="N",
+ create_time=func.current_timestamp(),
+ update_time=func.current_timestamp(),
+ )
+ with get_db_session(db_session) as session:
+ return _execute(
+ session, insert(ModelRecord).values(values).returning(ModelRecord.model_id)
+ ).scalar_one()
+
+
+def get_resolved_model_record(
+ model_id: int,
+ tenant_id: str,
+ *,
+ registered_references: Mapping[str, Sequence[VersionedReference]],
+ db_session=None,
+ protected_paths: Sequence[str] = (),
+) -> ResolvedModelProfile | None:
+ """Read active rows only; missing and other-tenant rows both return no result."""
+ statement = _active_statement(model_id, tenant_id)
+ with get_db_session(db_session) as session:
+ row = _execute(session, statement).mappings().first()
+ if row is None:
+ return None
+ return decode_resolved_model_profile(
+ row,
+ registered_references=registered_references,
+ protected_paths=protected_paths,
+ )
+
+
+def _validate_update(current, proposed, *, api_key=_UNSET):
+ from nexent.core.model_service.profiles.thinking_configuration import (
+ configure_outlet_policy,
+ )
+ from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError
+
+ old_metadata = current.provenance.provider_metadata
+ new_metadata = proposed.provenance.provider_metadata
+
+ def selected(key, field, reset):
+ value = new_metadata.get(key)
+ if value is None:
+ return reset if key in old_metadata else None
+ if not isinstance(value, dict) or field not in value:
+ raise ModelOutletValidationError()
+ return value[field]
+
+ # Only the finite SDK transformations may change saved rules/capabilities.
+ # Recompute from the trusted current row; never trust submitted originals.
+ try:
+ expected = configure_outlet_policy(current,
+ thinking_control=selected("operator_thinking", "preset", "profile_default"),
+ thinking_mode=selected("operator_thinking_degree", "mode", "default"),
+ has_credential=bool(api_key and api_key.strip()) if api_key is not _UNSET else None)
+ except (ParameterValidationError, ValueError, TypeError):
+ raise ModelOutletValidationError() from None
+ for key in (
+ "provider_id",
+ "request_model_id",
+ "model_type",
+ "tenant_id",
+ "provider_key",
+ "model_profile_key",
+ ):
+ if getattr(current.identity, key) != getattr(proposed.identity, key):
+ raise ModelOutletValidationError()
+ for key in (
+ "schema_version",
+ "bindings",
+ "instruction_mapping",
+ "connection_rules",
+ "provenance",
+ "parameter_rules",
+ ):
+ left, right = getattr(expected, key), getattr(proposed, key)
+ if hasattr(left, "model_dump"):
+ left, right = left.model_dump(mode="json"), right.model_dump(mode="json")
+ if not json_values_equal(left, right):
+ raise ModelOutletValidationError()
+ old_caps, new_caps = (
+ expected.capabilities.model_dump(),
+ proposed.capabilities.model_dump(),
+ )
+ old_caps.pop("context")
+ new_caps.pop("context")
+ if not json_values_equal(old_caps, new_caps):
+ raise ModelOutletValidationError()
+ compiled = compile_parameter_rules(expected.parameter_rules)
+ validate_saved_parameter_values(compiled, proposed.parameter_values)
+ rules = {rule["key"]: rule for rule in compiled.declarations}
+ old_rules = {rule["key"]: rule for rule in current.parameter_rules}
+ for key in current.parameter_values.keys() | proposed.parameter_values.keys():
+ unchanged = (
+ key in current.parameter_values
+ and key in proposed.parameter_values
+ and json_values_equal(
+ current.parameter_values[key], proposed.parameter_values[key]
+ )
+ )
+ rule = rules.get(key) or old_rules.get(key)
+ if not unchanged and (
+ not rule or not rule["editable"] or "outlet" not in rule["allowed_scopes"]
+ ):
+ raise ModelOutletValidationError()
+
+
+def update_resolved_model_record(
+ profile: ResolvedModelProfile,
+ expected_revision: int,
+ user_id: str,
+ tenant_id: str,
+ *,
+ registered_references: Mapping[str, Sequence[VersionedReference]],
+ api_key=_UNSET,
+ db_session=None,
+ protected_paths: Sequence[str] = (),
+) -> int:
+ """Atomically update a permitted snapshot; return the new revision or raise a safe conflict."""
+ _string(user_id)
+ _string(tenant_id)
+ if (
+ not isinstance(profile, ResolvedModelProfile)
+ or profile.outlet_id is None
+ or profile.identity.tenant_id != tenant_id
+ or type(expected_revision) is not int
+ or not 0 < expected_revision < 2**31 - 1
+ or profile.config_revision != expected_revision
+ ):
+ raise ModelOutletValidationError()
+ if api_key is not _UNSET:
+ _credential(api_key)
+ values = encode_resolved_model_profile(
+ profile,
+ registered_references=registered_references,
+ protected_paths=protected_paths,
+ )
+ _check_json_strings(values)
+ with get_db_session(db_session) as session:
+ current = get_resolved_model_record(
+ profile.outlet_id,
+ tenant_id,
+ registered_references=registered_references,
+ db_session=session,
+ protected_paths=protected_paths,
+ )
+ if current is None or current.config_revision != expected_revision:
+ raise ModelOutletConflictError()
+ _validate_update(current, profile, api_key=api_key)
+ for key in (
+ "model_id",
+ "tenant_id",
+ "model_factory",
+ "model_name",
+ "model_type",
+ "protocol_id",
+ "profile_schema_version",
+ ):
+ values.pop(key)
+ values.update(
+ config_revision=expected_revision + 1,
+ updated_by=user_id,
+ update_time=func.current_timestamp(),
+ )
+ if api_key is not _UNSET:
+ values["api_key"] = api_key
+ if (api_key is not _UNSET or current.connection.base_url != profile.connection.base_url
+ or current.connection.ssl_verify != profile.connection.ssl_verify):
+ values["connect_status"] = "not_detected"
+ statement = (
+ update(ModelRecord)
+ .where(
+ ModelRecord.model_id == profile.outlet_id,
+ ModelRecord.tenant_id == tenant_id,
+ ModelRecord.delete_flag == "N",
+ ModelRecord.config_revision == expected_revision,
+ )
+ .values(values)
+ .returning(ModelRecord.config_revision)
+ )
+ revision = _execute(session, statement).scalar_one_or_none()
+ if revision is None:
+ raise ModelOutletConflictError()
+ return revision
+
+
+def has_model_outlet_credential(model_id, tenant_id, *, db_session=None):
+ """Read a presence flag in SQL without returning credential material."""
+ statement = _active_statement(model_id, tenant_id).with_only_columns(func.length(func.trim(ModelRecord.api_key)) > 0)
+ with get_db_session(db_session) as session:
+ return bool(_execute(session, statement).scalar_one_or_none())
+
+
+def update_model_outlet_connectivity(model_id, tenant_id, expected_revision, status, *, db_session=None):
+ """Reject stale checks without changing configuration revisions or credential values."""
+ _active_statement(model_id, tenant_id)
+ if (type(expected_revision) is not int or not 0 < expected_revision < 2**31
+ or status not in ("available", "unavailable")):
+ raise ModelOutletValidationError()
+ statement = update(ModelRecord).where(
+ ModelRecord.model_id == model_id, ModelRecord.tenant_id == tenant_id,
+ ModelRecord.delete_flag == "N", ModelRecord.model_type == "llm",
+ ModelRecord.config_revision == expected_revision,
+ ).values(connect_status=status).returning(ModelRecord.model_id)
+ with get_db_session(db_session) as session:
+ if _execute(session, statement).scalar_one_or_none() is None:
+ raise ModelOutletConflictError()
+
+
+def migrate_legacy_model_records(builder, *, registered_references, batch_size=100,
+ after_model_id=0, dry_run=False, db_session=None):
+ """Migrate a locked batch in the caller's transaction; never commit shared sessions."""
+ if (type(batch_size) is not int or not 1 <= batch_size <= 1000
+ or type(after_model_id) is not int or not 0 <= after_model_id < 2**31):
+ raise ModelOutletValidationError()
+ statement = select(ModelRecord.__table__).where(
+ ModelRecord.model_type.in_(("llm", "chat")), ModelRecord.profile_schema_version.is_(None),
+ ModelRecord.model_id > after_model_id,
+ ).order_by(ModelRecord.model_id).limit(batch_size)
+ if not dry_run:
+ statement = statement.with_for_update()
+ report = []
+ with get_db_session(db_session) as session:
+ rows = _execute(session, statement).mappings().all()
+ for row in rows:
+ result = {"model_id": row["model_id"], "tenant_id": row["tenant_id"]}
+ try:
+ profile = builder(row)
+ if (profile.outlet_id != row["model_id"] or profile.identity.tenant_id != row["tenant_id"]
+ or profile.identity.model_type != "llm"):
+ raise ModelOutletValidationError(category="LEGACY_MIGRATION_IDENTITY_INVALID")
+ values = encode_resolved_model_profile(profile, registered_references=registered_references,
+ protected_paths=("/api_key", "/authorization", "/headers", "/credentials", "/base_url"))
+ _check_json_strings(values)
+ values.pop("model_id")
+ values.pop("tenant_id")
+ values.update(model_repo=None, max_tokens=None)
+ result.update(status="ready" if dry_run else "migrated", category=None)
+ except ModelOutletValidationError as error:
+ result.update(status="failed", category=error.category)
+ values = {"resolved_profile": {"migration_error": {"category": error.category,
+ "field": error.path}}}
+ if not dry_run:
+ _execute(session, update(ModelRecord).where(
+ ModelRecord.model_id == row["model_id"], ModelRecord.tenant_id == row["tenant_id"],
+ ModelRecord.profile_schema_version.is_(None),
+ ).values(values))
+ report.append(result)
+ return report
+
+
+def confirm_model_outlet_migration(model_id, tenant_id, user_id, expected_revision, *,
+ registered_references, db_session=None):
+ """Confirm exactly one configuration revision without changing model parameters."""
+ from datetime import datetime, timezone
+
+ _string(user_id)
+ if type(expected_revision) is not int or not 0 < expected_revision < 2**31:
+ raise ModelOutletValidationError()
+ with get_db_session(db_session) as session:
+ row = _execute(session, _active_statement(model_id, tenant_id).with_for_update()).mappings().first()
+ if row is None:
+ return None
+ profile = decode_resolved_model_profile(row, registered_references=registered_references)
+ if profile.config_revision != expected_revision:
+ raise ModelOutletConflictError()
+ data = profile.model_dump(mode="json")
+ migration = data["provenance"]["provider_metadata"].get("migration")
+ if profile.identity.model_type != "llm" or not isinstance(migration, dict):
+ raise ModelOutletValidationError(category="MIGRATION_NOT_APPLICABLE")
+ migration.update(reviewed_revision=expected_revision, reviewed_by=user_id,
+ reviewed_at=datetime.now(timezone.utc).isoformat())
+ reviewed = ResolvedModelProfile.model_validate(data)
+ remainder = encode_resolved_model_profile(reviewed, registered_references=registered_references)["resolved_profile"]
+ statement = update(ModelRecord).where(ModelRecord.model_id == model_id, ModelRecord.tenant_id == tenant_id,
+ ModelRecord.delete_flag == "N", ModelRecord.config_revision == expected_revision).values(
+ resolved_profile=remainder, updated_by=user_id, update_time=func.current_timestamp()
+ ).returning(ModelRecord.model_id)
+ if _execute(session, statement).scalar_one_or_none() is None:
+ raise ModelOutletConflictError()
+ return {"model_id": model_id, "config_revision": expected_revision, "migration_review_required": False}
+
+
+def repair_legacy_model_record(model_id, tenant_id, user_id, expected_revision, builder, *,
+ registered_references, db_session=None):
+ """Repair a failed legacy migration under the same row lock without changing its ID."""
+ _string(user_id)
+ with get_db_session(db_session) as session:
+ row = _execute(session, _active_statement(model_id, tenant_id).with_for_update()).mappings().first()
+ if row is None:
+ raise LookupError("MODEL_OUTLET_NOT_FOUND")
+ if (row["profile_schema_version"] is not None or type(expected_revision) is not int
+ or row["config_revision"] != expected_revision):
+ raise ModelOutletConflictError()
+ if row["model_type"] not in {"llm", "chat"}:
+ raise ModelOutletValidationError()
+ profile, api_key = builder(dict(row))
+ if profile.outlet_id != model_id or profile.identity.tenant_id != tenant_id:
+ raise ModelOutletValidationError()
+ _credential(api_key)
+ values = encode_resolved_model_profile(profile, registered_references=registered_references,
+ protected_paths=("/api_key", "/authorization", "/headers", "/credentials", "/base_url"))
+ _check_json_strings(values)
+ for field in ("model_id", "tenant_id"):
+ values.pop(field)
+ values.update(model_repo=None, max_tokens=None, api_key=api_key, config_revision=expected_revision + 1,
+ updated_by=user_id, update_time=func.current_timestamp())
+ _execute(session, update(ModelRecord).where(ModelRecord.model_id == model_id,
+ ModelRecord.tenant_id == tenant_id, ModelRecord.profile_schema_version.is_(None),
+ ModelRecord.config_revision == expected_revision).values(values))
+ return {"model_id": model_id, "config_revision": expected_revision + 1}
diff --git a/backend/services/model_profile_service.py b/backend/services/model_profile_service.py
new file mode 100644
index 0000000000..494d8253e2
--- /dev/null
+++ b/backend/services/model_profile_service.py
@@ -0,0 +1,465 @@
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
+"""Read-only profile discovery for LLM onboarding from the pinned local catalog."""
+
+from functools import lru_cache
+
+from consts.exceptions import ModelOutletConflictError, ModelOutletValidationError
+
+from nexent.core.model_service.profiles.parameter_schema import _json_copy
+from nexent.core.model_service.profiles.resolver import load_profile_catalog
+from nexent.core.model_service.registry import model_service_references
+
+
+def _validate_credential_input(value):
+ """Reject UI masking sentinels before persistence or model transport."""
+ if value and (set(value.strip()) == {"*"} or "********" in value):
+ raise ModelOutletValidationError()
+
+
+@lru_cache(maxsize=1)
+def get_model_profile_catalog():
+ """Load one immutable catalog per process; deployment updates invalidate it by restart."""
+ return load_profile_catalog()
+
+
+def list_atomic_profiles(kind, *, query="", family=None, page=1, page_size=20):
+ """Return reference summaries, never imply that copied entries are qualified outlets."""
+ if (kind not in ("model", "provider") or type(page) is not int or page < 1
+ or type(page_size) is not int or not 1 <= page_size <= 100
+ or not isinstance(query, str) or len(query) > 200
+ or (family is not None and (not isinstance(family, str) or len(family) > 100))):
+ raise ModelOutletValidationError()
+ catalog = get_model_profile_catalog()
+ entries = catalog.models if kind == "model" else catalog.providers
+ needle = query.strip().casefold()
+ selected = []
+ for key, atom in sorted(entries.items()):
+ identity = atom.source.identity
+ name = identity.get("name", key)
+ model_family = identity.get("family")
+ if family and (model_family or "").casefold() != family.casefold():
+ continue
+ if needle and needle not in (key + " " + name).casefold():
+ continue
+ item = {"key": key, "name": name, "revision": atom.revision, "catalog_kind": kind,
+ "qualification": "reference_only"}
+ if kind == "model":
+ item["family"] = model_family
+ parameters = _json_copy(atom.parameters)
+ item["configuration"] = {
+ "capabilities": _json_copy(atom.capabilities),
+ "field_specs": parameters,
+ "parameter_values": {
+ rule["key"]: rule["fixed_value"] if "fixed_value" in rule else rule["default_value"]
+ for rule in parameters if "fixed_value" in rule or "default_value" in rule
+ },
+ }
+ else:
+ item["endpoint_hints"] = list(atom.endpoint_hints)
+ suggested = atom.source.connection_suggestions.get("base_url")
+ if suggested:
+ item["suggested_base_url"] = suggested
+ selected.append(item)
+ offset = (page - 1) * page_size
+ return {"catalog_revision": catalog.revision, "items": selected[offset:offset + page_size],
+ "total": len(selected), "page": page, "page_size": page_size}
+
+
+def list_profile_protocols():
+ """Expose only executable protocol references; unknown API families are not choices."""
+ references = model_service_references()
+ return {"items": [{"ref": reference.ref, "version": reference.version}
+ for reference in references["protocol"]],
+ "authentication_schemes": [{"ref": reference.ref, "version": reference.version}
+ for reference in references["auth"]]}
+
+
+def _select_profile(catalog, request, kind):
+ """Use explicit versioned selections or an unambiguous exact automatic suggestion."""
+ from urllib.parse import urlsplit
+
+ entries = catalog.models if kind == "model" else catalog.providers
+ explicit = request.model_profile if kind == "model" else request.provider_profile
+ if explicit is not None:
+ atom = entries.get(explicit.key)
+ if atom is None:
+ raise ModelOutletValidationError()
+ if atom.revision != explicit.revision:
+ raise ModelOutletConflictError()
+ return explicit.key, [explicit.key]
+ if request.selection_mode == "manual":
+ return None, []
+ if kind == "model":
+ candidates = [key for key, atom in entries.items()
+ if request.request_model_id == key or request.request_model_id == key.split("/", 1)[-1]
+ or request.request_model_id in atom.aliases]
+ else:
+ endpoint = urlsplit(request.provider_info.base_url)
+ candidates = []
+ for key, atom in entries.items():
+ if request.provider_info.provider_id == key:
+ candidates.append(key)
+ continue
+ hints = list(atom.endpoint_hints)
+ suggested = atom.source.connection_suggestions.get("base_url")
+ if suggested:
+ hints.append(suggested)
+ if any((endpoint.scheme, endpoint.netloc, endpoint.path.rstrip("/")) ==
+ (urlsplit(hint).scheme, urlsplit(hint).netloc, urlsplit(hint).path.rstrip("/")) for hint in hints):
+ candidates.append(key)
+ return (candidates[0] if len(candidates) == 1 else None), sorted(candidates)
+
+
+def _validate_request_output_limit(profile):
+ from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError
+
+ output = profile.parameter_values.get("max_output_tokens")
+ limits = [value for value in (profile.capabilities.context.combined_window,
+ profile.capabilities.context.output_limit) if value is not None]
+ if type(output) is int and limits and output > min(limits):
+ raise ParameterValidationError("PARAMETER_LIMIT_EXCEEDED", "/max_output_tokens")
+ return profile
+
+
+def _patch_context_limits(data, patch):
+ """Bound inherited hidden limits when the user reduces the effective window."""
+ context = data["capabilities"]["context"]
+ context.update(patch)
+ window = patch.get("combined_window")
+ if type(window) is int:
+ for key in ("input_limit", "output_limit"):
+ if key not in patch and type(context.get(key)) is int:
+ context[key] = min(context[key], window)
+
+
+def build_outlet_draft(request, tenant_id, *, require_revision=False):
+ """Recompose trusted policy at preview/save; never accept a client effective profile."""
+ from urllib.parse import urlsplit
+
+ from nexent.core.model_service.profiles.composition import (
+ ProfileCompositionError,
+ compose_provider_model_policy,
+ )
+ from nexent.core.model_service.profiles.contracts import VersionedReference
+ from nexent.core.model_service.profiles.outlet_resolution import (
+ OutletResolutionError,
+ construct_resolved_model_profile,
+ )
+ from nexent.core.model_service.profiles.protocol_parameters import openai_chat_parameter_rules
+ from nexent.core.model_service.profiles.parameter_schema import (
+ ParameterValidationError,
+ )
+ from nexent.core.model_service.profiles.request_parameters import (
+ apply_extra_api_parameters,
+ )
+ from nexent.core.model_service.profiles.resolved_profile import (
+ ConnectionRules,
+ ContextConfiguration,
+ OutletConnection,
+ )
+ from pydantic import ValidationError
+
+ if hasattr(request, "api_key"):
+ _validate_credential_input(request.api_key)
+ catalog = get_model_profile_catalog()
+ if require_revision and request.catalog_revision is None:
+ raise ModelOutletValidationError()
+ if request.catalog_revision is not None and request.catalog_revision != catalog.revision:
+ raise ModelOutletConflictError()
+ try:
+ endpoint = urlsplit(request.provider_info.base_url)
+ if (endpoint.scheme not in ("http", "https") or not endpoint.hostname
+ or endpoint.username is not None or endpoint.password is not None or endpoint.fragment):
+ raise ModelOutletValidationError()
+ endpoint.port
+ provider_key, providers = _select_profile(catalog, request, "provider")
+ model_key, models = _select_profile(catalog, request, "model")
+ refs = model_service_references()
+ bindings = {key: values[0].model_dump() for key, values in refs.items() if key != "parameter_transform"}
+ scheme = request.authentication_scheme
+ if hasattr(request, "api_key"):
+ scheme = "bearer_token_v1" if request.api_key and request.api_key.strip() else "no_auth_v1"
+ bindings["auth"] = next(ref.model_dump() for ref in refs["auth"] if ref.ref == scheme)
+ if bindings["protocol"]["ref"] != request.protocol_id:
+ raise ModelOutletValidationError()
+ credential_kind = "none" if scheme == "no_auth_v1" else "model_record"
+ policy = compose_provider_model_policy(
+ catalog, provider_key=provider_key, model_profile_key=model_key,
+ request_model_id=request.request_model_id, base_bindings=bindings,
+ base_instruction=VersionedReference(ref="system_only_v1", version=1),
+ registered_references=refs, submitted_values={key: value for key, value in request.parameter_values.items()
+ if not ((request.thinking_control not in (None, "profile_default") and key == "enable_thinking")
+ or (request.thinking_mode not in (None, "default") and key in {"reasoning_effort", "reasoning_budget_tokens"}))},
+ protocol_parameter_rules=openai_chat_parameter_rules(),
+ )
+ apply_extra_api_parameters({}, request.extra_api_parameters,
+ parameter_paths=[item["api_binding"]["path"] for item in policy.parameter_rules.declarations
+ if item.get("api_binding")],
+ protected_paths=("/api_key", "/authorization", "/headers", "/credentials", "/base_url"))
+ profile = construct_resolved_model_profile(
+ policy, catalog, tenant_id=tenant_id, display_name=request.display_name or request.request_model_id,
+ model_type="llm", provider_id=request.provider_info.provider_id or provider_key or "custom",
+ connection=OutletConnection(
+ **request.provider_info.model_dump(exclude={"provider_id"}),
+ credential_ref={"kind": credential_kind, "model_id": None},
+ ),
+ connection_rules=ConnectionRules(credential_kind=credential_kind, relative_path="chat/completions",
+ method="POST", headers={}),
+ context_configuration=ContextConfiguration(output_reserve_tokens=None, tokenizer_family=None,
+ capacity_source="profile", capability_profile_version=None),
+ registered_references=refs, extra_api_parameters=request.extra_api_parameters,
+ )
+ if request.context_limits is not None or request.context_configuration is not None:
+ from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile
+
+ data = profile.model_dump(mode="json")
+ if request.context_limits is not None:
+ _patch_context_limits(data, request.context_limits.model_dump(exclude_unset=True))
+ if request.context_configuration is not None:
+ data["context_configuration"].update(request.context_configuration.model_dump(exclude_unset=True))
+ profile = ResolvedModelProfile.model_validate(data)
+ from nexent.core.model_service.profiles.thinking_configuration import configure_thinking, configure_thinking_degree
+
+ profile = configure_thinking_degree(configure_thinking(profile, request.thinking_control), request.thinking_mode)
+ configured = {key: value for key, value in request.parameter_values.items()
+ if (key == "enable_thinking" and request.thinking_control not in (None, "profile_default"))
+ or (key in {"reasoning_effort", "reasoning_budget_tokens"}
+ and request.thinking_mode not in (None, "default"))}
+ if configured:
+ from nexent.core.model_service.compatibility.invocation_options import LegacyInvocationOptions
+
+ profile = LegacyInvocationOptions(configured).resolve(profile)
+ _validate_request_output_limit(profile)
+ return profile, {"models": models, "providers": providers}
+ except ModelOutletConflictError:
+ raise
+ except (ProfileCompositionError, ParameterValidationError) as error:
+ raise ModelOutletValidationError(error.category, error.path) from None
+ except OutletResolutionError as error:
+ raise ModelOutletValidationError(error.category) from None
+ except (ValidationError, ValueError, StopIteration):
+ raise ModelOutletValidationError() from None
+
+
+def preview_model_profile(request, tenant_id):
+ profile, candidates = build_outlet_draft(request, tenant_id)
+ return {"catalog_revision": get_model_profile_catalog().revision, "candidates": candidates,
+ "selected": {"model": profile.model_profile_key, "provider": profile.provider_key},
+ "field_specs": profile.parameter_rules, "parameter_values": profile.parameter_values,
+ "capabilities": profile.capabilities.model_dump(mode="json"),
+ "instruction_mapping": profile.instruction_mapping.model_dump(mode="json"),
+ "conflicts": []}
+
+
+def _backfill_new_outlet(model_id, user_id, tenant_id):
+ from management.services.model.management import _backfill_default_model_slots
+
+ return _backfill_default_model_slots(user_id, tenant_id, new_model_ids=[model_id])
+
+
+def create_model_outlet(request, user_id, tenant_id):
+ """Persist the server-composed snapshot and keep credentials out of the response."""
+ from database.model_management_db import get_models_by_display_name
+ from database.model_outlet_db import create_resolved_model_record
+ from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile
+
+ profile, _ = build_outlet_draft(request, tenant_id, require_revision=True)
+ if get_models_by_display_name(profile.identity.display_name, tenant_id):
+ raise ModelOutletConflictError()
+ model_id = create_resolved_model_record(profile, user_id, tenant_id,
+ registered_references=model_service_references(), api_key=request.api_key)
+ data = profile.model_dump(mode="json")
+ data["outlet_id"] = model_id
+ data["connection"]["credential_ref"]["model_id"] = model_id
+ saved = ResolvedModelProfile.model_validate(data)
+ defaults = _backfill_new_outlet(model_id, user_id, tenant_id)
+ return {"auto_configured_defaults": defaults, "model_id": model_id, "config_revision": saved.config_revision, "profile": saved.model_dump(mode="json"),
+ "has_credential": bool(request.api_key and request.api_key.strip())}
+
+
+def get_model_outlet(model_id, tenant_id):
+ """Read the saved snapshot without consulting the current TOML catalog."""
+ from database.model_outlet_db import get_resolved_model_record, has_model_outlet_credential
+
+ profile = get_resolved_model_record(model_id, tenant_id, registered_references=model_service_references())
+ if profile is None:
+ return None
+ if profile.identity.model_type != "llm":
+ raise ModelOutletValidationError()
+ from nexent.core.model_service.profiles.protocol_parameters import ensure_outlet_sampling_rules
+
+ profile = ensure_outlet_sampling_rules(profile)
+ return {"model_id": model_id, "config_revision": profile.config_revision,
+ "profile": profile.model_dump(mode="json"), "field_specs": profile.parameter_rules,
+ "has_credential": has_model_outlet_credential(model_id, tenant_id)}
+
+
+def apply_outlet_patch(profile, request):
+ """Validate partial updates against the saved rules without applying new defaults."""
+ from nexent.core.model_service.profiles.parameter_rules import compile_parameter_rules, validate_saved_parameter_values
+ from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError, json_values_equal
+ from nexent.core.model_service.profiles.request_parameters import apply_extra_api_parameters
+ from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile
+ from pydantic import ValidationError
+ from urllib.parse import urlsplit
+
+ _validate_credential_input(request.api_key)
+ try:
+ from nexent.core.model_service.profiles.thinking_configuration import configure_outlet_policy
+
+ from nexent.core.model_service.profiles.protocol_parameters import ensure_outlet_sampling_rules
+
+ profile = ensure_outlet_sampling_rules(profile)
+ previous_rules = {rule["key"]: rule for rule in profile.parameter_rules}
+ profile = configure_outlet_policy(profile, thinking_control=request.thinking_control,
+ thinking_mode=request.thinking_mode,
+ has_credential=bool(request.api_key and request.api_key.strip()) if "api_key" in request.model_fields_set else None)
+ removed = request.delete_parameters
+ if len(set(removed)) != len(removed) or set(removed) & request.parameter_values.keys():
+ raise ParameterValidationError("PARAMETER_UPDATE_CONFLICT")
+ compiled = compile_parameter_rules(profile.parameter_rules)
+ rules = {rule["key"]: rule for rule in compiled.declarations}
+ values = dict(profile.parameter_values)
+ for key in set(removed) | request.parameter_values.keys():
+ if key not in rules:
+ old = previous_rules.get(key)
+ if key in removed and old and old["editable"] and "outlet" in old["allowed_scopes"]:
+ continue
+ raise ParameterValidationError("PARAMETER_UNKNOWN", "/" + key)
+ same = key not in removed and key in values and json_values_equal(values[key], request.parameter_values[key])
+ if not same and (not rules[key]["editable"] or "outlet" not in rules[key]["allowed_scopes"]):
+ raise ParameterValidationError("FIXED_PARAMETER", "/" + key)
+ for key in removed:
+ values.pop(key, None)
+ values.update(request.parameter_values)
+ data = profile.model_dump(mode="json")
+ data["parameter_values"] = validate_saved_parameter_values(compiled, values)
+ if request.context_limits is not None:
+ _patch_context_limits(data, request.context_limits.model_dump(exclude_unset=True))
+ if request.context_configuration is not None:
+ data["context_configuration"].update(request.context_configuration.model_dump(exclude_unset=True))
+ if "display_name" in request.model_fields_set:
+ data["identity"]["display_name"] = request.display_name
+ if "connection" in request.model_fields_set and request.connection is None:
+ raise ModelOutletValidationError()
+ if request.connection is not None:
+ data["connection"].update(request.connection.model_dump(exclude_unset=True))
+ endpoint = urlsplit(data["connection"]["base_url"])
+ if endpoint.scheme not in ("http", "https") or not endpoint.hostname or endpoint.username or endpoint.password or endpoint.fragment:
+ raise ModelOutletValidationError()
+ endpoint.port
+ if "extra_api_parameters" in request.model_fields_set:
+ data["extra_api_parameters"] = request.extra_api_parameters
+ apply_extra_api_parameters({}, data["extra_api_parameters"],
+ parameter_paths=[rule["api_binding"]["path"] for rule in profile.parameter_rules if rule.get("api_binding")],
+ protected_paths=("/api_key", "/authorization", "/headers", "/credentials", "/base_url"))
+ return _validate_request_output_limit(ResolvedModelProfile.model_validate(data))
+ except ParameterValidationError as error:
+ raise ModelOutletValidationError(error.category, error.path) from None
+ except (ValidationError, ValueError):
+ raise ModelOutletValidationError() from None
+
+
+def update_model_outlet(model_id, request, user_id, tenant_id):
+ """Update a frozen outlet with CAS; preserve omitted credentials and fields."""
+ from database.model_management_db import get_models_by_display_name
+ from database.model_outlet_db import get_resolved_model_record, update_resolved_model_record, has_model_outlet_credential
+
+ refs = model_service_references()
+ current = get_resolved_model_record(model_id, tenant_id, registered_references=refs)
+ if current is None:
+ raise LookupError("MODEL_OUTLET_NOT_FOUND")
+ if current.identity.model_type != "llm":
+ raise ModelOutletValidationError()
+ if current.config_revision != request.expected_revision:
+ raise ModelOutletConflictError()
+ proposed = apply_outlet_patch(current, request)
+ if proposed.identity.display_name != current.identity.display_name:
+ if any(row["model_id"] != model_id for row in get_models_by_display_name(proposed.identity.display_name, tenant_id)):
+ raise ModelOutletConflictError()
+ credentials = {"api_key": request.api_key} if "api_key" in request.model_fields_set else {}
+ revision = update_resolved_model_record(proposed, request.expected_revision, user_id, tenant_id,
+ registered_references=refs, **credentials)
+ data = proposed.model_dump(mode="json")
+ data["config_revision"] = revision
+ return {"model_id": model_id, "config_revision": revision, "profile": data,
+ "has_credential": has_model_outlet_credential(model_id, tenant_id)}
+
+
+def _load_validation_outlet(model_id, tenant_id):
+ from database.model_management_db import get_model_by_model_id
+ from nexent.core.model_service.profiles.outlet_storage import decode_resolved_model_profile, ProfileStorageError
+
+ row = get_model_by_model_id(model_id, tenant_id=tenant_id)
+ if row is None:
+ raise LookupError("MODEL_OUTLET_NOT_FOUND")
+ try:
+ profile = decode_resolved_model_profile(row, registered_references=model_service_references())
+ if profile.identity.tenant_id != tenant_id or profile.identity.model_type != "llm" or profile.outlet_id != model_id:
+ raise ModelOutletValidationError()
+ return profile, row.get("api_key")
+ except ProfileStorageError:
+ raise ModelOutletValidationError("PROFILE_STORAGE_INVALID") from None
+
+
+async def validate_model_outlet(request, tenant_id):
+ """Probe one captured endpoint/model with the common sender; no tool qualification."""
+ from nexent.core.concurrency import run_blocking
+
+ if request.outlet_id is not None:
+ profile, key = await run_blocking("outlet-validation-load", _load_validation_outlet, request.outlet_id, tenant_id)
+ if request.expected_revision is not None and profile.config_revision != request.expected_revision:
+ raise ModelOutletConflictError()
+ else:
+ profile, _ = await run_blocking("outlet-validation-draft", build_outlet_draft, request.draft, tenant_id)
+ key = request.draft.api_key
+ result = await probe_model_profile(profile, key)
+ ok, error = result["ok"], result["error"]
+ if request.outlet_id is not None:
+ from database.model_outlet_db import update_model_outlet_connectivity
+
+ await run_blocking("outlet-validation-status", update_model_outlet_connectivity,
+ request.outlet_id, tenant_id, profile.config_revision, "available" if ok else "unavailable")
+ return {"ok": ok, "error": error, "check_scope": "connection_only",
+ "model_id": profile.outlet_id, "config_revision": profile.config_revision}
+
+
+async def probe_model_profile(profile, key):
+ """Probe through the production streamed sender without mutating configuration."""
+ from uuid import uuid4
+
+ from nexent.core.model_service.contracts import ModelMessage, ModelRequest
+ from nexent.core.model_service.registry import AuthenticationRegistry
+ from nexent.core.model_service.service import ModelService, ModelServiceError
+ from nexent.core.models.retry import DEFAULT_MODEL_RETRY
+
+ auth = AuthenticationRegistry()
+ service = ModelService(lambda captured: auth.sender_for(captured, lambda _tenant, _reference: key),
+ retry_policy=DEFAULT_MODEL_RETRY)
+ call = ModelRequest(request_id=uuid4().hex, outlet_id=profile.outlet_id,
+ config_revision=profile.config_revision, stream=True, purpose="connection_validation",
+ messages=[ModelMessage(semantic_role="user", content_parts=[{"type": "text", "text": "Hello"}])])
+ error = None
+ ok = False
+ try:
+ async with service.astream(call, profile) as events:
+ async for event in events:
+ if event.type == "error":
+ error = {"category": event.error.category, "message": event.error.safe_message}
+ elif event.type == "message_completed":
+ ok = bool(event.message.content_parts or event.message.tool_calls)
+ if not ok and error is None:
+ error = {"category": "MODEL_EMPTY_RESPONSE"}
+ except ModelServiceError as failure:
+ ok = False
+ error = {"category": failure.error.category, "message": failure.error.safe_message}
+ return {"ok": ok, "error": error}
+
+
+def confirm_outlet_migration(model_id, request, user_id, tenant_id):
+ """Confirm saved migration metadata under the authenticated tenant."""
+ from database.model_outlet_db import confirm_model_outlet_migration
+
+ return confirm_model_outlet_migration(model_id, tenant_id, user_id, request.expected_revision,
+ registered_references=model_service_references())
diff --git a/sdk/nexent/core/agents/nexent_agent.py b/sdk/nexent/core/agents/nexent_agent.py
index 98fa03e1c7..916e6e5217 100644
--- a/sdk/nexent/core/agents/nexent_agent.py
+++ b/sdk/nexent/core/agents/nexent_agent.py
@@ -324,6 +324,8 @@ def create_model(self, model_cite_name: str):
)
if model_config is None:
raise ValueError(f"Model {model_cite_name} not found")
+ if model_config.resolved_profile is not None:
+ return self._create_resolved_model(model_config)
model_kwargs = dict(
observer=self.observer,
model_id=model_config.model_name,
@@ -364,6 +366,23 @@ def create_model(self, model_cite_name: str):
model.stop_event = self.stop_event
return model
+ def _create_resolved_model(self, model_config):
+ """Use the supplied outlet snapshot and the run's existing cancellation owner."""
+ from ..concurrency.cancellation import RunCancellationScope
+ from ..model_service.compatibility.legacy_chat_model import LegacyChatModel
+
+ profile = model_config.resolved_profile
+ if (
+ profile.identity.tenant_id != self.tenant_id
+ or profile.identity.request_model_id != model_config.model_name
+ or profile.connection.base_url != model_config.url
+ ):
+ raise ValueError("Model configuration does not match its outlet snapshot")
+ scope = self.cancellation_scope if self.cancellation_scope is not None else RunCancellationScope(self.stop_event)
+ return LegacyChatModel.from_profile(
+ profile, model_config.api_key, observer=self.observer, cancellation_scope=scope,
+ )
+
def create_local_tool(self, tool_config: ToolConfig):
class_name = tool_config.class_name
diff --git a/sdk/nexent/core/gateway/modality/llm/openai.py b/sdk/nexent/core/gateway/modality/llm/openai.py
index 0fedc24abf..1559e8b278 100644
--- a/sdk/nexent/core/gateway/modality/llm/openai.py
+++ b/sdk/nexent/core/gateway/modality/llm/openai.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
"""OpenAI LLM adapters: standard + long-context."""
from __future__ import annotations
@@ -5,9 +7,10 @@
import asyncio
from typing import Any, AsyncIterator
+from nexent.core.concurrency import run_blocking
+from nexent.core.models.openai_long_context_model import LongTextPreparation
from nexent.core.models import OpenAILongContextModel, OpenAIModel
from nexent.core.models.retry import DEFAULT_MODEL_RETRY
-from nexent.core.concurrency import run_blocking
from ...model_context import LLMContext
from ...multimodal_adapter import ModelInfo
@@ -40,6 +43,20 @@ def __init__(self, context: LLMContext) -> None:
def _build_model(self) -> None:
"""Construct the wrapped :class:`OpenAIModel` on first use."""
ctx = self._context
+ if ctx.resolved_profile is not None:
+ from nexent.core.model_service.compatibility.legacy_chat_model import LegacyChatModel
+
+ profile = ctx.resolved_profile
+ if (
+ profile.identity.tenant_id != ctx.tenant_id
+ or profile.identity.request_model_id != ctx.model_name
+ or profile.connection.base_url != ctx.base_url
+ ):
+ raise ValueError("Model context does not match its outlet snapshot")
+ self._model = LegacyChatModel.from_profile(
+ profile, ctx.api_key, observer=ctx.observer, cancellation_scope=ctx.cancellation_scope,
+ )
+ return
kwargs = dict(
observer=ctx.observer,
model_id=ctx.model_name,
@@ -74,6 +91,8 @@ async def stream(self, request: LLMRequest) -> AsyncIterator[Any]:
"""Create a streaming completion using the wrapped OpenAI client."""
if self._model is None:
self._build_model()
+ if self._context.resolved_profile is not None:
+ return self._model.agenerate_stream(request.messages, **request.kwargs)
completion_kwargs = {
"model": self._model.model_id,
"messages": request.messages,
@@ -97,6 +116,18 @@ async def health_check(self) -> bool:
def get_model_info(self) -> ModelInfo:
"""Return ``ModelInfo`` advertising text + tool_calling capabilities."""
+ if self._context.resolved_profile is not None:
+ profile = self._context.resolved_profile
+ return ModelInfo(
+ model_id=profile.identity.request_model_id,
+ display_name=profile.identity.display_name,
+ provider=profile.identity.provider_id,
+ capabilities={
+ "text": "text" in profile.capabilities.modalities.output,
+ "tool_calling": profile.capabilities.tools.status == "supported",
+ "long_context": False,
+ },
+ )
return ModelInfo(
model_id=self._context.model_name,
display_name=self._context.display_name or "",
@@ -110,7 +141,7 @@ def get_model_info(self) -> ModelInfo:
@register_adapter("openai", "llm_long_context")
-class OpenAILongContextLLMAdapter(OpenAILLMAdapter):
+class OpenAILongContextLLMAdapter(LongTextPreparation, OpenAILLMAdapter):
"""Adapter for OpenAILongContextModel with long-context support.
Reuses the standard OpenAI LLM adapter behavior and overrides only
@@ -126,6 +157,10 @@ class OpenAILongContextLLMAdapter(OpenAILLMAdapter):
def _build_model(self) -> None:
"""Construct the wrapped :class:`OpenAILongContextModel` on first use."""
ctx = self._context
+ if ctx.resolved_profile is not None:
+ limits = ctx.resolved_profile.capabilities.context
+ self.initialize_long_text(limits.input_limit or limits.combined_window, ctx.truncation_strategy or "start")
+ return super()._build_model()
self._model = OpenAILongContextModel(
observer=ctx.observer,
model_id=ctx.model_name,
@@ -159,6 +194,8 @@ def analyze_long_text(
"""
if self._model is None:
self._build_model()
+ if self._context.resolved_profile is not None:
+ return LongTextPreparation.analyze_long_text(self, text_content, system_prompt, user_prompt)
return self._model.analyze_long_text(
text_content=text_content,
system_prompt=system_prompt,
@@ -167,6 +204,10 @@ def analyze_long_text(
def get_model_info(self) -> ModelInfo:
"""Return ``ModelInfo`` advertising ``long_context=True``."""
+ if self._context.resolved_profile is not None:
+ info = super().get_model_info()
+ info.capabilities["long_context"] = True
+ return info
return ModelInfo(
model_id=self._context.model_name,
display_name=self._context.display_name or "",
diff --git a/sdk/nexent/core/model_service/compatibility/legacy_chat_model.py b/sdk/nexent/core/model_service/compatibility/legacy_chat_model.py
new file mode 100644
index 0000000000..754c97516d
--- /dev/null
+++ b/sdk/nexent/core/model_service/compatibility/legacy_chat_model.py
@@ -0,0 +1,411 @@
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
+"""Existing smolagents model interface over the one model service."""
+
+from copy import deepcopy
+from inspect import iscoroutinefunction
+from uuid import uuid4
+
+from smolagents.models import (
+ ChatMessage,
+ ChatMessageStreamDelta,
+ ChatMessageToolCallFunction,
+ ChatMessageToolCallStreamDelta,
+ Model,
+ get_clean_message_list,
+ get_tool_json_schema,
+ tool_role_conversions,
+)
+from smolagents.monitoring import TokenUsage
+
+from nexent.core.concurrency.cancellation import RunCancellationScope
+from nexent.core.model_errors import ModelErrorCode, ModelInvocationTerminalError
+from nexent.core.model_service.compatibility.invocation_options import LegacyInvocationOptions
+from nexent.core.model_service.contracts import ModelMessage, ModelRequest, ToolDefinition
+from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile
+from nexent.core.model_service.registry import AuthenticationRegistry
+from nexent.core.model_service.service import ModelService, ModelServiceError
+from nexent.core.models.capacity_budget import parse_context_budget_snapshot
+from nexent.core.models.message_utils import merge_system_messages
+from nexent.core.models.prompt_cache import PromptCacheUsage
+from nexent.core.models.retry import DEFAULT_MODEL_RETRY
+from nexent.core.utils.observer import MessageObserver, ProcessType
+
+
+_ERROR_CODES = {
+ "MODEL_EMPTY_RESPONSE": ModelErrorCode.EMPTY_RESPONSE_EXHAUSTED,
+ "MODEL_AUTHENTICATION_ERROR": ModelErrorCode.AUTHENTICATION_ERROR,
+ "MODEL_NOT_FOUND": ModelErrorCode.NOT_FOUND,
+ "MODEL_RATE_LIMITED": ModelErrorCode.RATE_LIMIT_EXHAUSTED,
+ "MODEL_SERVICE_UNAVAILABLE": ModelErrorCode.SERVICE_UNAVAILABLE,
+ "MODEL_TIMEOUT": ModelErrorCode.TIMEOUT,
+ "MODEL_NETWORK_ERROR": ModelErrorCode.CONNECTION_ERROR,
+ "MODEL_CONTEXT_EXCEEDED": ModelErrorCode.CONTEXT_OVERFLOW,
+ "MODEL_REQUEST_INVALID": ModelErrorCode.INVALID_REQUEST,
+}
+
+
+class LegacyChatModel(Model):
+ """No provider branches, HTTP clients, tool execution or AgentMode fallback."""
+
+ supports_deferred_attempt_commit = True
+ supports_suppressed_attempt_stream = True
+
+ def __init__(
+ self, profile, service, *, observer=None, cancellation_scope=None, flatten_messages_as_text=False,
+ context_budget_snapshot=None, capacity_snapshot=None,
+ ):
+ self.profile = ResolvedModelProfile.model_validate(profile.model_dump())
+ super().__init__(
+ model_id=self.profile.identity.request_model_id, flatten_messages_as_text=flatten_messages_as_text
+ )
+ self.service = service
+ self.observer = observer if observer is not None else MessageObserver()
+ self.cancellation_scope = cancellation_scope if cancellation_scope is not None else RunCancellationScope()
+ self.stop_event = self.cancellation_scope.stop_event
+ self.temperature = self.profile.parameter_values.get("temperature")
+ self.top_p = self.profile.parameter_values.get("top_p")
+ self.model_factory = self.profile.identity.provider_id
+ self.display_name = self.profile.identity.display_name
+ self.context_budget_snapshot = context_budget_snapshot
+ self.capacity_snapshot = deepcopy(capacity_snapshot)
+ self.last_input_token_count = None
+ self.last_output_token_count = None
+ self.last_finish_reason = None
+ self.last_response_diagnostics = None
+ self.last_attempt_number = None
+ self.last_attempt_id = None
+ self.last_reasoning_preview = None
+ self.last_cached_input_token_count = None
+ self.last_prompt_cache_usage = None
+ self.last_provider_cache_advice = None
+ self.last_retry_count = 0
+ self.last_context_evidence = None
+
+ @classmethod
+ def from_profile(cls, profile, api_key, *, observer=None, cancellation_scope=None):
+ """Construct the compatibility model with the shared transport and retry policy."""
+ auth = AuthenticationRegistry()
+ service = ModelService(
+ lambda snapshot: auth.sender_for(snapshot, lambda _tenant, _reference: api_key),
+ retry_policy=DEFAULT_MODEL_RETRY,
+ )
+ return cls(profile, service, observer=observer, cancellation_scope=cancellation_scope)
+
+ @property
+ def context_budget_snapshot(self):
+ return self._context_budget_snapshot
+
+ @context_budget_snapshot.setter
+ def context_budget_snapshot(self, value):
+ self._context_budget_snapshot = parse_context_budget_snapshot(deepcopy(value)) if value is not None else None
+
+ async def check_connectivity(self):
+ """Probe endpoint/model with saved declarations through the same HTTP owner."""
+ try:
+ request, profile = self._request(
+ [{"role": "user", "content": "Hello"}], LegacyInvocationOptions({}), None,
+ )
+ await self.service.ainvoke(
+ request.model_copy(update={"stream": False}), profile, scope=self.cancellation_scope,
+ )
+ return True
+ except Exception:
+ return False
+
+ def _request(self, messages, options, tools, *, profile=None):
+ profile = options.resolve(self.profile if profile is None else profile)
+ normalized = []
+ for message in merge_system_messages(deepcopy(messages)):
+ if isinstance(message, ChatMessage):
+ message = {"role": message.role, "content": message.content}
+ if not isinstance(message, dict) or not {"role", "content"} <= message.keys():
+ raise ValueError("Legacy messages require role and content")
+ message = deepcopy(message)
+ if isinstance(message["content"], str):
+ message["content"] = [{"type": "text", "text": message["content"]}]
+ elif message["content"] is None:
+ message["content"] = []
+ normalized.append(message)
+ cleaned = get_clean_message_list(
+ normalized,
+ role_conversions=tool_role_conversions,
+ convert_images_to_image_urls=True,
+ flatten_messages_as_text=self.flatten_messages_as_text,
+ )
+ request = ModelRequest(
+ request_id=uuid4().hex,
+ outlet_id=profile.outlet_id,
+ config_revision=profile.config_revision,
+ stream=True,
+ messages=[
+ ModelMessage(
+ semantic_role=getattr(message["role"], "value", message["role"]),
+ content_parts=(
+ [{"type": "text", "text": message["content"]}]
+ if isinstance(message["content"], str)
+ else message["content"]
+ ),
+ )
+ for message in cleaned
+ ],
+ tools=[
+ ToolDefinition(
+ name=definition["function"]["name"],
+ description=definition["function"]["description"],
+ input_schema=definition["function"]["parameters"],
+ )
+ for definition in (get_tool_json_schema(tool) for tool in tools or [])
+ ],
+ )
+ return request, profile
+
+ @staticmethod
+ def _terminal(error):
+ return ModelInvocationTerminalError(
+ _ERROR_CODES.get(error.category, ModelErrorCode.UNKNOWN_ERROR),
+ error.attempt,
+ cause=ModelServiceError(error),
+ )
+
+ def _reset_response(self, profile):
+ self.last_input_token_count = self.last_output_token_count = self.last_finish_reason = None
+ self.last_cached_input_token_count = self.last_prompt_cache_usage = None
+ self.last_reasoning_preview = None
+ self._has_response_content = False
+ self.last_response_diagnostics = {
+ "finish_reason": None, "content_chunk_count": 0, "content_char_count": 0,
+ "reasoning_chunk_count": 0, "reasoning_char_count": 0,
+ "input_tokens": None, "output_tokens": None,
+ "requested_output_tokens": profile.parameter_values.get(
+ "max_output_tokens", profile.parameter_values.get("max_tokens")
+ ),
+ "reasoning_only_budget_exhausted": False,
+ }
+
+ def _observe_event(self, event, profile):
+ diagnostics = self.last_response_diagnostics
+ kind = {"text_delta": "content", "reasoning_delta": "reasoning"}.get(event.type)
+ if kind is not None and event.content_kind != "refusal":
+ diagnostics[kind + "_chunk_count"] += 1
+ diagnostics[kind + "_char_count"] += len(event.delta)
+ if kind == "content":
+ self._has_response_content |= bool(event.delta.strip())
+ usage = event.usage if event.type == "usage" else event.message.usage if event.type == "message_completed" else None
+ if usage is not None:
+ self.last_input_token_count, self.last_output_token_count = usage.input_tokens, usage.output_tokens
+ self.last_cached_input_token_count = usage.cached_input_tokens
+ self.last_prompt_cache_usage = None
+ diagnostics.update(input_tokens=usage.input_tokens, output_tokens=usage.output_tokens)
+ if (
+ usage.origin == "provider" and profile.capabilities.usage.input_includes_cached == "supported"
+ and usage.input_tokens is not None and usage.cached_input_tokens is not None
+ and usage.cached_input_tokens <= usage.input_tokens
+ ):
+ cached, total = usage.cached_input_tokens, usage.input_tokens
+ self.last_prompt_cache_usage = PromptCacheUsage(
+ cached_input_tokens=cached, uncached_input_tokens=total - cached,
+ provider_cache_hit=cached > 0, hit_ratio=round(cached / total, 4) if total else 0.0,
+ metrics_source=usage.semantics_id or "provider_reported",
+ )
+ if event.type in {"message_completed", "error"}:
+ self.last_finish_reason = (
+ event.message.finish_reason if event.type == "message_completed" else event.error.finish_reason
+ )
+ diagnostics.update(
+ finish_reason=self.last_finish_reason,
+ reasoning_only_budget_exhausted=(
+ self.last_finish_reason == "length" and diagnostics["reasoning_char_count"] > 0
+ and not self._has_response_content
+ ),
+ )
+
+ def _events(
+ self, messages, options, tools, *, suppress_stream=False, empty_response_policy="accept", context_rebuild=None,
+ ):
+ if context_rebuild is not None and (
+ not callable(context_rebuild) or iscoroutinefunction(context_rebuild)
+ or iscoroutinefunction(getattr(context_rebuild, "__call__", None))
+ ):
+ raise TypeError("Legacy context_rebuild must be a synchronous callback")
+ request, profile = self._request(messages, options, tools)
+
+ def rebuild_request(_error):
+ rebuilt = context_rebuild()
+ rebuilt_messages = getattr(rebuilt, "messages", rebuilt)
+ if not isinstance(rebuilt_messages, list):
+ raise TypeError("context_rebuild must return FinalContext or a message list")
+ self.last_context_evidence = getattr(rebuilt, "evidence", None)
+ replacement, _ = self._request(rebuilt_messages, options, tools, profile=profile)
+ return replacement.model_copy(update={"request_id": request.request_id})
+
+ self.last_attempt_id = request.request_id
+ self.last_attempt_number = None
+ self.last_retry_count = 0
+ self.last_context_evidence = None
+ self._reset_response(profile)
+ attempt_number = None
+ completed = False
+ if not suppress_stream:
+ self.observer.current_mode = ProcessType.MODEL_OUTPUT_THINKING
+ try:
+ with self.service.stream(
+ request, profile, scope=self.cancellation_scope, empty_response_policy=empty_response_policy,
+ request_rebuild=rebuild_request if context_rebuild is not None else None,
+ ) as events:
+ for event in events:
+ if attempt_number != event.attempt:
+ if attempt_number is not None and not suppress_stream:
+ self.observer.rollback_model_attempt(request.request_id, attempt_number)
+ attempt_number = event.attempt
+ self.last_attempt_id = request.request_id
+ self.last_attempt_number = event.attempt
+ self.last_retry_count = event.attempt - 1
+ self._reset_response(profile)
+ if not suppress_stream:
+ self.observer.begin_model_attempt(request.request_id, event.attempt)
+ self._observe_event(event, profile)
+ if event.type == "error":
+ raise self._terminal(event.error)
+ if event.type == "reasoning_delta" and not suppress_stream:
+ self.observer.add_model_reasoning_content(event.delta)
+ elif event.type == "text_delta" and event.content_kind != "refusal" and not suppress_stream:
+ self.observer.add_model_new_token(event.delta)
+ elif event.type == "message_completed":
+ completed = True
+ if not suppress_stream:
+ self.observer.flush_remaining_tokens()
+ message = event.message
+ self.last_finish_reason = message.finish_reason
+ self.last_input_token_count = message.usage.input_tokens
+ self.last_output_token_count = message.usage.output_tokens
+ yield event
+ except ModelServiceError as error:
+ raise self._terminal(error.error) from None
+ finally:
+ if not completed and attempt_number is not None and not suppress_stream:
+ self.observer.rollback_model_attempt(request.request_id, attempt_number)
+
+ def generate(
+ self, messages, stop_sequences=None, response_format=None, tools_to_call_from=None,
+ *, _defer_attempt_commit=False, _suppress_attempt_stream=False, _retry_empty_response=True,
+ context_budget_snapshot=None, context_rebuild=None, **kwargs,
+ ):
+ if any(type(value) is not bool for value in (
+ _defer_attempt_commit, _suppress_attempt_stream, _retry_empty_response
+ )):
+ raise ValueError("Attempt controls must be booleans")
+ options = LegacyInvocationOptions(
+ kwargs, stop_sequences, response_format,
+ context_budget_snapshot if context_budget_snapshot is not None else self.context_budget_snapshot,
+ self.capacity_snapshot,
+ )
+ events = self._events(
+ messages, options, tools_to_call_from, suppress_stream=_suppress_attempt_stream,
+ empty_response_policy="retry" if _retry_empty_response else "fail",
+ context_rebuild=context_rebuild,
+ )
+ try:
+ for event in events:
+ if event.type != "message_completed":
+ continue
+ message = event.message
+ result = ChatMessage.from_dict(
+ {
+ "role": "assistant",
+ "content": "".join(
+ part["text"] for part in message.content_parts if part.get("type") == "text"
+ ),
+ "tool_calls": [
+ {
+ "id": call.provider_call_id,
+ "type": "function",
+ "function": {
+ "name": call.name,
+ "arguments": call.raw_arguments,
+ },
+ }
+ for call in message.tool_calls
+ ],
+ },
+ raw=message,
+ )
+ if message.usage.input_tokens is not None and message.usage.output_tokens is not None:
+ result.token_usage = TokenUsage(message.usage.input_tokens, message.usage.output_tokens)
+ result.model_attempt_id = self.last_attempt_id
+ result.model_attempt_number = event.attempt
+ result.model_attempt_commit_deferred = _defer_attempt_commit and not _suppress_attempt_stream
+ result.refusal = message.refusal
+ if not _defer_attempt_commit and not _suppress_attempt_stream:
+ self.observer.commit_model_attempt(self.last_attempt_id, event.attempt)
+ return result
+ raise RuntimeError("Model stream ended without completion")
+ finally:
+ events.close()
+
+ def generate_stream(
+ self, messages, stop_sequences=None, response_format=None, tools_to_call_from=None,
+ *, _suppress_attempt_stream=False, context_budget_snapshot=None, context_rebuild=None, **kwargs,
+ ):
+ if type(_suppress_attempt_stream) is not bool:
+ raise ValueError("Attempt controls must be booleans")
+ options = LegacyInvocationOptions(
+ kwargs, stop_sequences, response_format,
+ context_budget_snapshot if context_budget_snapshot is not None else self.context_budget_snapshot,
+ self.capacity_snapshot,
+ )
+ events = self._events(
+ messages, options, tools_to_call_from, suppress_stream=_suppress_attempt_stream, context_rebuild=context_rebuild,
+ )
+ try:
+ for event in events:
+ delta = self._stream_delta(event)
+ if delta is not None:
+ yield delta
+ if event.type == "message_completed":
+ if not _suppress_attempt_stream:
+ self.observer.commit_model_attempt(self.last_attempt_id, event.attempt)
+ finally:
+ events.close()
+
+ @staticmethod
+ def _stream_delta(event):
+ if event.type == "text_delta" and event.content_kind != "refusal":
+ return ChatMessageStreamDelta(content=event.delta)
+ elif event.type == "tool_call_delta":
+ delta = event.tool_call_delta
+ return ChatMessageStreamDelta(
+ tool_calls=[
+ ChatMessageToolCallStreamDelta(
+ index=delta.index,
+ id=delta.provider_call_id,
+ type="function",
+ function=ChatMessageToolCallFunction(
+ name=delta.name_delta or "", arguments=delta.arguments_delta or ""
+ ),
+ )
+ ]
+ )
+ elif event.type == "usage":
+ usage = event.usage
+ if usage.input_tokens is not None and usage.output_tokens is not None:
+ return ChatMessageStreamDelta(token_usage=TokenUsage(usage.input_tokens, usage.output_tokens))
+
+ async def agenerate_stream(
+ self, messages, stop_sequences=None, response_format=None, tools_to_call_from=None, **kwargs,
+ ):
+ """Compatibility deltas over the asynchronous transport; the caller owns iteration."""
+ options = LegacyInvocationOptions(
+ kwargs, stop_sequences, response_format, self.context_budget_snapshot, self.capacity_snapshot,
+ )
+ request, profile = self._request(messages, options, tools_to_call_from)
+ try:
+ async with self.service.astream(request, profile, scope=self.cancellation_scope) as events:
+ async for event in events:
+ if event.type == "error":
+ raise self._terminal(event.error)
+ delta = self._stream_delta(event)
+ if delta is not None:
+ yield delta
+ except ModelServiceError as error:
+ raise self._terminal(error.error) from None
diff --git a/sdk/nexent/core/models/model_concurrency.py b/sdk/nexent/core/model_service/concurrency.py
similarity index 66%
rename from sdk/nexent/core/models/model_concurrency.py
rename to sdk/nexent/core/model_service/concurrency.py
index 5b0c5bfb37..0c80de2bbc 100644
--- a/sdk/nexent/core/models/model_concurrency.py
+++ b/sdk/nexent/core/model_service/concurrency.py
@@ -1,5 +1,8 @@
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
from __future__ import annotations
+import asyncio
import threading
import time
from dataclasses import dataclass
@@ -51,6 +54,24 @@ def acquire(
raise ValueError("model concurrency limit must be greater than zero")
if timeout_seconds <= 0:
raise ValueError("model concurrency wait timeout must be greater than zero")
+ entry = self._retain_entry(key, limit)
+
+ deadline = time.monotonic() + timeout_seconds
+ try:
+ while not stop_event.is_set():
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise ModelConcurrencyExceeded(
+ f"model concurrency permit timed out after {timeout_seconds:.3f} seconds"
+ )
+ if entry.semaphore.acquire(timeout=min(0.05, remaining)):
+ return ModelConcurrencyPermit(self, key, entry)
+ raise RuntimeError("model concurrency wait cancelled")
+ except BaseException:
+ self._release_reference(key, entry)
+ raise
+
+ def _retain_entry(self, key, limit):
with self._lock:
entry = self._entries.get(key)
if entry is None:
@@ -59,7 +80,21 @@ def acquire(
elif entry.limit != limit:
raise ValueError("conflicting concurrency limits for the same model key")
entry.references += 1
+ return entry
+ async def acquire_async(
+ self,
+ key: tuple[str, str, str],
+ limit: int,
+ timeout_seconds: float,
+ stop_event: threading.Event,
+ ) -> ModelConcurrencyPermit:
+ """Share sync admission state while never blocking the caller's event loop."""
+ if limit <= 0:
+ raise ValueError("model concurrency limit must be greater than zero")
+ if timeout_seconds <= 0:
+ raise ValueError("model concurrency wait timeout must be greater than zero")
+ entry = self._retain_entry(key, limit)
deadline = time.monotonic() + timeout_seconds
try:
while not stop_event.is_set():
@@ -68,8 +103,9 @@ def acquire(
raise ModelConcurrencyExceeded(
f"model concurrency permit timed out after {timeout_seconds:.3f} seconds"
)
- if entry.semaphore.acquire(timeout=min(0.05, remaining)):
+ if entry.semaphore.acquire(blocking=False):
return ModelConcurrencyPermit(self, key, entry)
+ await asyncio.sleep(min(0.05, remaining))
raise RuntimeError("model concurrency wait cancelled")
except BaseException:
self._release_reference(key, entry)
diff --git a/sdk/nexent/core/model_service/service.py b/sdk/nexent/core/model_service/service.py
new file mode 100644
index 0000000000..78cc927e1a
--- /dev/null
+++ b/sdk/nexent/core/model_service/service.py
@@ -0,0 +1,553 @@
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
+"""Logical model calls over registered adapters and the shared HTTP sender."""
+
+import asyncio
+import inspect
+import json
+import logging
+import math
+import random
+import time
+import uuid
+from contextlib import asynccontextmanager, contextmanager
+from dataclasses import dataclass, replace
+
+from nexent.core.concurrency.cancellation import RunCancellationScope, RunTerminated
+from nexent.core.model_service.concurrency import ModelConcurrencyExceeded, model_concurrency_limiter
+from nexent.core.model_service.contracts import ModelError, ModelEvent, ModelRequest
+from nexent.core.model_service.errors import normalize_model_error
+from nexent.core.model_service.http_contracts import ModelTransportError
+from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile
+from nexent.core.model_service.registry import AdapterRegistry
+from nexent.monitor import get_monitoring_manager
+
+
+logger = logging.getLogger(__name__)
+
+
+class ModelServiceError(Exception):
+ """Only the public normalized error is exposed to callers."""
+
+ def __init__(self, error):
+ self.error = error
+ super().__init__(error.category)
+
+
+@dataclass(frozen=True)
+class ModelRequestRetryPolicy:
+ max_attempts: int = 3
+
+ def __post_init__(self):
+ if type(self.max_attempts) is not int or self.max_attempts < 1:
+ raise ValueError("Retry attempts must be a positive integer")
+
+ def calculate_backoff(self, attempt: int) -> float:
+ return min(2 ** min(attempt - 1, 5), 30) + random.uniform(0, 0.25)
+
+
+@dataclass
+class _Call:
+ request: ModelRequest
+ profile: ResolvedModelProfile
+ adapter: object
+ sender: object
+ prepared: object
+ scope: RunCancellationScope
+ deadline: float | None
+ policy: object
+ attempt: int = 1
+ visible: bool = False
+ sequence: int = 0
+ monitoring: object = None
+ token_tracker: object = None
+ request_rebuild: object = None
+ rebuilds: int = 0
+ rebuild_pending: bool = False
+ empty_response_retries: int = 0
+
+ def check(self):
+ if self.scope.cancelled:
+ raise RunTerminated()
+ if self.deadline is not None and time.monotonic() >= self.deadline:
+ error = normalize_model_error(
+ ModelTransportError("HTTP_RUN_DEADLINE"),
+ request_id=self.request.request_id,
+ attempt=self.attempt,
+ max_attempts=self.policy.max_attempts,
+ )
+ raise ModelServiceError(error)
+
+ def timeout(self):
+ self.check()
+ configured = self.profile.connection.timeout_seconds
+ return configured if self.deadline is None else min(configured, self.deadline - time.monotonic())
+
+ def wire_request(self):
+ self.monitoring.add_span_event("model_attempt_begin", {
+ "request_id": self.request.request_id, "attempt": self.attempt,
+ })
+ return replace(
+ self.prepared,
+ headers=dict(self.prepared.headers),
+ json_body=json.loads(self.prepared.body_bytes),
+ timeout_seconds=self.timeout(),
+ )
+
+ def event(self, event):
+ self.check()
+ if event.type in {"text_delta", "reasoning_delta", "tool_call_delta"}:
+ self.token_tracker.record_first_token()
+ elif event.type == "usage":
+ self.monitoring.add_span_event("model_usage", {
+ "attempt": self.attempt, **event.usage.model_dump(exclude_none=True),
+ })
+ elif event.type == "message_completed":
+ self.observe_completion(event.message)
+ self.sequence += 1
+ self.visible |= event.type in {"text_delta", "reasoning_delta", "tool_call_delta", "message_completed"}
+ return event.model_copy(update={"sequence": self.sequence, "attempt": self.attempt})
+
+ def observe_completion(self, message):
+ usage = message.usage
+ if usage.input_tokens is not None and usage.output_tokens is not None:
+ self.token_tracker.record_completion(usage.input_tokens, usage.output_tokens, total_tokens=usage.total_tokens)
+ self.monitoring.set_span_attributes(**{
+ "llm.usage." + key: value for key, value in usage.model_dump().items() if value is not None
+ }, **{"llm.response.finish_reason": message.finish_reason, "llm.response.attempt": self.attempt})
+ self.monitoring.set_openinference_output("".join(
+ part.get("text", "") for part in message.content_parts if part.get("type") == "text"
+ ))
+
+ def failure(self, error):
+ if self.scope.cancelled:
+ raise RunTerminated()
+ normalized = (
+ error.error
+ if isinstance(error, ModelServiceError)
+ else self.adapter.normalize_error(
+ error,
+ request_id=self.request.request_id,
+ attempt=self.attempt,
+ max_attempts=self.policy.max_attempts,
+ )
+ )
+ self.rebuild_pending = (
+ normalized.category == "MODEL_CONTEXT_EXCEEDED" and self.request_rebuild is not None
+ and not self.visible and self.attempt < self.policy.max_attempts and self.rebuilds < 2
+ )
+ if self.rebuild_pending:
+ normalized = normalized.model_copy(update={"retryable": True})
+ delay = None
+ if normalized.retryable and not self.visible and self.attempt < self.policy.max_attempts:
+ delay = 0 if self.rebuild_pending else self.policy.calculate_backoff(self.attempt)
+ if not isinstance(delay, (int, float)) or not math.isfinite(delay) or delay < 0:
+ raise ValueError("Retry backoff must be finite and nonnegative")
+ response = getattr(error, "response", None)
+ if response is not None:
+ try:
+ retry_after = float(
+ next(value for key, value in response.headers.items() if key.lower() == "retry-after")
+ )
+ if math.isfinite(retry_after) and retry_after >= 0:
+ delay = max(delay, retry_after)
+ except (ValueError, TypeError, StopIteration):
+ pass
+ if self.deadline is not None and time.monotonic() + delay >= self.deadline:
+ delay = None
+ normalized = normalized.model_copy(update={"will_retry": delay is not None})
+ self.monitoring.add_span_event("model_attempt_error", {
+ "request_id": self.request.request_id, "attempt": self.attempt,
+ "category": normalized.category, "will_retry": delay is not None,
+ })
+ if normalized.category in {"MODEL_TIMEOUT", "MODEL_CONCURRENCY_TIMEOUT", "MODEL_RUN_DEADLINE"}:
+ logger.warning(
+ "model_request_timeout",
+ extra={
+ "request_id": self.request.request_id,
+ "outlet_id": self.profile.outlet_id,
+ "attempt": self.attempt,
+ "category": normalized.category,
+ "phase": "after_output" if self.visible else "before_output",
+ "timeout_seconds": self.profile.connection.timeout_seconds,
+ "exception_type": type(error).__name__,
+ },
+ )
+ if delay is not None:
+ logger.info(
+ "model_request_retry",
+ extra={
+ "request_id": self.request.request_id,
+ "attempt": self.attempt,
+ "category": normalized.category,
+ "will_retry": True,
+ "retry_delay_seconds": delay,
+ },
+ )
+ return normalized, delay
+
+ def apply_rebuild(self, request):
+ self.check()
+ if not isinstance(request, ModelRequest):
+ raise TypeError("request_rebuild must return a ModelRequest")
+ request = ModelRequest.model_validate(request.model_dump())
+ if request.model_dump(exclude={"messages"}) != self.request.model_dump(exclude={"messages"}):
+ raise ValueError("Context recovery may only replace messages")
+ prepared = self.adapter.prepare_http_request(request, self.profile)
+ self.request, self.prepared = request, prepared
+ self.rebuilds += 1
+ self.rebuild_pending = False
+ self.monitoring.add_span_event("model_context_rebuild", {
+ "request_id": request.request_id, "attempt": self.attempt,
+ "recovery_dispatch": self.rebuilds, "message_count": len(request.messages),
+ })
+ self.check()
+
+ def wait(self, delay):
+ end = time.monotonic() + delay
+ while time.monotonic() < end:
+ self.check()
+ self.scope.stop_event.wait(min(0.05, end - time.monotonic()))
+ self.check()
+ self.attempt += 1
+
+ async def await_retry(self, delay):
+ end = time.monotonic() + delay
+ while time.monotonic() < end:
+ self.check()
+ await asyncio.sleep(min(0.05, end - time.monotonic()))
+ self.check()
+ self.attempt += 1
+
+
+class ModelService:
+ """No environment reads, resource execution, AgentMode selection or semantic repair."""
+
+ def __init__(self, sender_factory, *, registry=None, limiter=None, retry_policy=None, monitoring=None, outlet_reader=None):
+ if outlet_reader is not None and (
+ not callable(outlet_reader) or inspect.iscoroutinefunction(outlet_reader)
+ or inspect.iscoroutinefunction(getattr(outlet_reader, "__call__", None))
+ ):
+ raise TypeError("Outlet reader must be a synchronous snapshot reader")
+ self._outlet_reader = outlet_reader
+ self._sender_factory = sender_factory
+ self._registry = registry if registry is not None else AdapterRegistry()
+ self._limiter = limiter if limiter is not None else model_concurrency_limiter
+ self._policy = retry_policy if retry_policy is not None else ModelRequestRetryPolicy()
+ self._monitoring = monitoring if monitoring is not None else get_monitoring_manager()
+ if type(self._policy.max_attempts) is not int or self._policy.max_attempts < 1:
+ raise ValueError("Retry attempts must be a positive integer")
+
+ def bind_outlet(self, model_id, run_context):
+ """Select one tenant-owned saved snapshot without storing active run state on the service."""
+ tenant_id = getattr(run_context, "tenant_id", None)
+ if (type(model_id) is not int or not 0 < model_id < 2**31
+ or not isinstance(tenant_id, str) or not tenant_id.strip()):
+ raise ValueError("MODEL_OUTLET_SELECTION_INVALID")
+ if self._outlet_reader is None:
+ raise ValueError("MODEL_OUTLET_READER_UNCONFIGURED")
+ try:
+ profile = self._outlet_reader(model_id, run_context)
+ except Exception:
+ raise ValueError("MODEL_OUTLET_RESOLUTION_FAILED") from None
+ if profile is None:
+ raise ValueError("MODEL_OUTLET_NOT_FOUND")
+ try:
+ if not isinstance(profile, ResolvedModelProfile):
+ raise TypeError("Invalid snapshot")
+ profile = ResolvedModelProfile.model_validate(profile.model_dump())
+ except Exception:
+ raise ValueError("MODEL_OUTLET_SNAPSHOT_INVALID") from None
+ if profile.outlet_id != model_id or profile.identity.tenant_id != tenant_id:
+ raise ValueError("MODEL_OUTLET_SNAPSHOT_MISMATCH")
+ self._registry.resolve(profile.bindings.adapter)
+ return profile
+
+ def _call(self, request, profile, scope, deadline, request_rebuild=None):
+ if deadline is not None and (type(deadline) not in (int, float) or not math.isfinite(deadline)):
+ raise ValueError("Run deadline must be a finite monotonic timestamp")
+ request = ModelRequest.model_validate(request.model_dump())
+ profile = ResolvedModelProfile.model_validate(profile.model_dump())
+ scope = scope if scope is not None else RunCancellationScope()
+ if scope.cancelled:
+ raise RunTerminated()
+ try:
+ adapter = self._registry.resolve(profile.bindings.adapter)
+ prepared = adapter.prepare_http_request(request, profile)
+ sender = self._sender_factory(profile).freeze_credentials(prepared)
+ except Exception as error:
+ raise ModelServiceError(
+ normalize_model_error(
+ error,
+ request_id=request.request_id,
+ max_attempts=self._policy.max_attempts,
+ )
+ ) from None
+ call = _Call(request, profile, adapter, sender, prepared, scope, deadline, self._policy,
+ monitoring=self._monitoring, request_rebuild=request_rebuild)
+ call.check()
+ return call
+
+ @staticmethod
+ def _validate_rebuild(callback, *, asynchronous=False):
+ if callback is None:
+ return
+ is_async = inspect.iscoroutinefunction(callback) or inspect.iscoroutinefunction(getattr(callback, "__call__", None))
+ if not callable(callback) or is_async != asynchronous:
+ raise TypeError("Request rebuild callback must match the synchronous or asynchronous API")
+
+ @staticmethod
+ async def _arebuild(call, error):
+ call.check()
+ budget = asyncio.timeout_at(call.deadline)
+ loop, task = asyncio.get_running_loop(), asyncio.current_task()
+ active = True
+
+ def cancel_if_active():
+ if active:
+ task.cancel()
+
+ token = call.scope.register_closer(lambda: loop.call_soon_threadsafe(cancel_if_active))
+ try:
+ async with budget:
+ request = await call.request_rebuild(error)
+ except asyncio.CancelledError:
+ call.check()
+ raise
+ except TimeoutError:
+ if budget.expired():
+ call.check()
+ raise
+ finally:
+ active = False
+ call.scope.unregister_closer(token)
+ call.apply_rebuild(request)
+
+ @contextmanager
+ def _trace(self, call):
+ with self._monitoring.trace_llm_request(
+ f"{call.profile.identity.display_name or call.profile.identity.request_model_id}.generate",
+ call.profile.identity.request_model_id,
+ **{
+ "input.value": [message.model_dump(mode="json") for message in call.request.messages],
+ "model.request_id": call.request.request_id,
+ **({"model.outlet_id": call.profile.outlet_id} if call.profile.outlet_id is not None else {}),
+ "model.config_revision": call.profile.config_revision,
+ "model.provider": call.profile.identity.provider_id,
+ },
+ ) as span:
+ call.token_tracker = self._monitoring.create_token_tracker(call.profile.identity.request_model_id, span)
+ yield
+
+ def _admission_args(self, call):
+ profile = call.profile
+ return {
+ "key": (profile.identity.tenant_id, profile.identity.provider_id, profile.identity.request_model_id),
+ "limit": profile.connection.concurrency_limit,
+ "timeout_seconds": call.timeout(),
+ "stop_event": call.scope.stop_event,
+ }
+
+ @contextmanager
+ def _permit(self, call):
+ call.check()
+ permit = None
+ try:
+ if call.profile.connection.concurrency_limit is not None:
+ try:
+ permit = self._limiter.acquire(**self._admission_args(call))
+ except ModelConcurrencyExceeded:
+ raise ModelTransportError("HTTP_CONCURRENCY_TIMEOUT") from None
+ call.check()
+ yield
+ finally:
+ if permit is not None:
+ permit.release()
+
+ @asynccontextmanager
+ async def _apermit(self, call):
+ call.check()
+ permit = None
+ try:
+ if call.profile.connection.concurrency_limit is not None:
+ try:
+ permit = await self._limiter.acquire_async(**self._admission_args(call))
+ except ModelConcurrencyExceeded:
+ raise ModelTransportError("HTTP_CONCURRENCY_TIMEOUT") from None
+ call.check()
+ budget = asyncio.timeout_at(call.deadline)
+ try:
+ async with budget:
+ yield
+ except TimeoutError:
+ if budget.expired():
+ raise ModelTransportError("HTTP_RUN_DEADLINE") from None
+ raise
+ finally:
+ if permit is not None:
+ permit.release()
+
+ def invoke(self, request, profile, *, scope=None, deadline=None, request_rebuild=None):
+ self._validate_rebuild(request_rebuild, asynchronous=False)
+ if request.stream:
+ raise ValueError("Invoke requires a non-streaming request")
+ call = self._call(request, profile, scope, deadline, request_rebuild)
+ with self._trace(call):
+ while True:
+ try:
+ with self._permit(call):
+ raw = call.sender.invoke(call.wire_request(), scope=call.scope)
+ result = call.adapter.decode_response(raw, call.request, call.profile)
+ call.check()
+ call.observe_completion(result)
+ return result
+ except Exception as error:
+ normalized, delay = call.failure(error)
+ if delay is None:
+ raise ModelServiceError(normalized) from None
+ if call.rebuild_pending:
+ call.check()
+ call.apply_rebuild(call.request_rebuild(normalized))
+ call.wait(delay)
+
+ async def ainvoke(self, request, profile, *, scope=None, deadline=None, request_rebuild=None):
+ self._validate_rebuild(request_rebuild, asynchronous=True)
+ if request.stream:
+ raise ValueError("Invoke requires a non-streaming request")
+ call = self._call(request, profile, scope, deadline, request_rebuild)
+ with self._trace(call):
+ while True:
+ try:
+ async with self._apermit(call):
+ raw = await call.sender.ainvoke(call.wire_request(), scope=call.scope)
+ result = call.adapter.decode_response(raw, call.request, call.profile)
+ call.check()
+ call.observe_completion(result)
+ return result
+ except Exception as error:
+ normalized, delay = call.failure(error)
+ if delay is None:
+ raise ModelServiceError(normalized) from None
+ if call.rebuild_pending:
+ call.check()
+ await self._arebuild(call, normalized)
+ await call.await_retry(delay)
+
+ @staticmethod
+ def _error_event(call, error, message_id):
+ call.sequence += 1
+ return ModelEvent(
+ request_id=call.request.request_id,
+ message_id=message_id,
+ sequence=call.sequence,
+ attempt=call.attempt,
+ type="error",
+ error=error,
+ )
+
+ @staticmethod
+ def _check_empty_response(call, event, policy):
+ if policy == "accept" or event.type != "message_completed":
+ return
+ message = event.message
+ if message.tool_calls or message.refusal or any(
+ part.get("type") == "text" and part.get("text", "").strip() for part in message.content_parts
+ ):
+ return
+ retryable = policy == "retry" and message.finish_reason == "stop" and call.empty_response_retries < 1
+ if retryable:
+ call.empty_response_retries += 1
+ raise ModelServiceError(ModelError(
+ category="MODEL_EMPTY_RESPONSE", scope="protocol",
+ retryable=retryable,
+ attempt=call.attempt, max_attempts=call.policy.max_attempts,
+ safe_message="The model returned an empty response.", request_id=call.request.request_id,
+ finish_reason=message.finish_reason,
+ ))
+
+ @staticmethod
+ def _validate_empty_policy(policy):
+ if policy not in ("accept", "fail", "retry"):
+ raise ValueError("Unknown empty response policy")
+
+ def _events(self, call, empty_response_policy):
+ message_id = uuid.uuid4()
+ pending_delay = None
+ while True:
+ try:
+ if pending_delay is not None:
+ call.wait(pending_delay)
+ with self._permit(call), call.sender.stream(call.wire_request(), scope=call.scope) as frames:
+ for event in call.adapter.decode_stream(frames, call.request, call.profile):
+ message_id = event.message_id
+ self._check_empty_response(call, event, empty_response_policy)
+ yield call.event(event)
+ if event.type == "message_completed":
+ return
+ return
+ except Exception as error:
+ normalized, delay = call.failure(error)
+ if delay is None:
+ yield self._error_event(call, normalized, message_id)
+ return
+ if call.rebuild_pending:
+ call.check()
+ call.apply_rebuild(call.request_rebuild(normalized))
+ pending_delay = delay
+
+ async def _aevents(self, call, empty_response_policy):
+ message_id = uuid.uuid4()
+ pending_delay = None
+ while True:
+ try:
+ if pending_delay is not None:
+ await call.await_retry(pending_delay)
+ async with self._apermit(call), call.sender.astream(call.wire_request(), scope=call.scope) as frames:
+ async for event in call.adapter.adecode_stream(frames, call.request, call.profile):
+ message_id = event.message_id
+ self._check_empty_response(call, event, empty_response_policy)
+ yield call.event(event)
+ if event.type == "message_completed":
+ return
+ return
+ except Exception as error:
+ normalized, delay = call.failure(error)
+ if delay is None:
+ yield self._error_event(call, normalized, message_id)
+ return
+ if call.rebuild_pending:
+ call.check()
+ await self._arebuild(call, normalized)
+ pending_delay = delay
+
+ @contextmanager
+ def stream(
+ self, request, profile, *, scope=None, deadline=None, empty_response_policy="accept", request_rebuild=None,
+ ):
+ self._validate_rebuild(request_rebuild, asynchronous=False)
+ self._validate_empty_policy(empty_response_policy)
+ if not request.stream:
+ raise ValueError("Stream requires a streaming request")
+ call = self._call(request, profile, scope, deadline, request_rebuild)
+ events = self._events(call, empty_response_policy)
+ with self._trace(call):
+ try:
+ yield events
+ finally:
+ events.close()
+
+ @asynccontextmanager
+ async def astream(
+ self, request, profile, *, scope=None, deadline=None, empty_response_policy="accept", request_rebuild=None,
+ ):
+ self._validate_rebuild(request_rebuild, asynchronous=True)
+ self._validate_empty_policy(empty_response_policy)
+ if not request.stream:
+ raise ValueError("Stream requires a streaming request")
+ call = self._call(request, profile, scope, deadline, request_rebuild)
+ events = self._aevents(call, empty_response_policy)
+ with self._trace(call):
+ try:
+ yield events
+ finally:
+ await events.aclose()
diff --git a/sdk/nexent/core/models/openai_llm.py b/sdk/nexent/core/models/openai_llm.py
index 2e03925fd0..258202c5dc 100644
--- a/sdk/nexent/core/models/openai_llm.py
+++ b/sdk/nexent/core/models/openai_llm.py
@@ -28,9 +28,10 @@
from .capacity_budget import (
CallerMaxTokensOverrideForbidden,
- ContextBudgetCapacityMismatch,
+ ContextBudgetCapacityMismatch as ContextBudgetCapacityMismatch,
ContextBudgetSnapshot,
parse_context_budget_snapshot,
+ verify_context_budget_capacity,
)
from ..utils.observer import MessageObserver, ProcessType
from .prompt_cache import (
@@ -49,7 +50,7 @@
ProviderContextOverflowRetryUnsafe,
is_provider_context_overflow,
)
-from .model_concurrency import ModelConcurrencyExceeded, model_concurrency_limiter
+from nexent.core.model_service.concurrency import ModelConcurrencyExceeded, model_concurrency_limiter
from .sampling import DEFAULT_TEMPERATURE, DEFAULT_TOP_P, resolve_sampling
from .retry import (
DEFAULT_MODEL_RETRY,
@@ -1465,31 +1466,9 @@ def _verify_w1_w2_consistency(
rows without capacity columns, where W2 already does not produce a
snapshot.
"""
- if not capacity_snapshot:
- return
- w1_fingerprint = capacity_snapshot.get("capacity_fingerprint")
- provider = capacity_snapshot.get("provider")
- model_name = capacity_snapshot.get("model_name")
- if not w1_fingerprint and not provider and not model_name:
- return
- if w1_fingerprint and w1_fingerprint != budget_snapshot.w1_fingerprint:
- raise ContextBudgetCapacityMismatch(
- field="w1_fingerprint",
- expected=w1_fingerprint,
- actual=budget_snapshot.w1_fingerprint,
- )
- if provider and provider != budget_snapshot.provider:
- raise ContextBudgetCapacityMismatch(
- field="provider",
- expected=provider,
- actual=budget_snapshot.provider,
- )
- if model_name and model_name != budget_snapshot.model_name:
- raise ContextBudgetCapacityMismatch(
- field="model_name",
- expected=model_name,
- actual=budget_snapshot.model_name,
- )
+ verify_context_budget_capacity(
+ budget_snapshot=budget_snapshot, capacity_snapshot=capacity_snapshot,
+ )
@staticmethod
def _coerce_context_budget_snapshot(
diff --git a/test/sdk/core/agents/test_core_agent.py b/test/sdk/core/agents/test_core_agent.py
index 0e58faa3df..db781dbece 100644
--- a/test/sdk/core/agents/test_core_agent.py
+++ b/test/sdk/core/agents/test_core_agent.py
@@ -2493,11 +2493,11 @@ def test_step_stream_turns_exhausted_empty_response_into_silent_protocol_repair(
agent.model = MagicMock(side_effect=terminal)
action_step = MagicMock(model_output=None)
- with pytest.raises(module.ModelOutputProtocolError) as exc_info:
+ with pytest.raises(module.ModelInvocationTerminalError) as exc_info:
next(agent._step_stream(action_step))
- assert exc_info.value.reason == module.ProtocolErrorReason.EMPTY_VISIBLE_CONTENT
- assert exc_info.value.__cause__ is terminal
+ assert exc_info.value is terminal
+ agent.model.assert_called_once()
def test_step_stream_closed_protocol_omits_legacy_provider_stop_sequences(
self, monkeypatch
@@ -3232,18 +3232,17 @@ def test_oc_035_disabled_direct_empty_content_finishes_with_hint(self, content):
agent.observer.rollback_model_attempt.assert_not_called()
assert response.model_attempt_commit_deferred is False
- def test_oc_034_disabled_adapter_empty_content_finishes_with_hint(self):
+ def test_adapter_empty_exhaustion_stays_terminal_without_protocol_repair(self):
agent, action_step, _response = self._create_cmsr_007_step_agent("unused")
agent.observer.lang = "zh"
agent.model.side_effect = core_agent_module.ModelInvocationTerminalError(
core_agent_module.ModelErrorCode.EMPTY_RESPONSE_EXHAUSTED, 1
)
- with pytest.raises(core_agent_module.RuntimeFinalAnswer) as exc_info:
+ with pytest.raises(core_agent_module.ModelInvocationTerminalError) as exc_info:
list(agent._step_stream(action_step))
- assert exc_info.value.source == "empty_model_output"
- assert exc_info.value.answer == "*模型返回了空内容。*"
+ assert exc_info.value.error_code == core_agent_module.ModelErrorCode.EMPTY_RESPONSE_EXHAUSTED
agent.model.assert_called_once()
agent.observer.commit_model_attempt.assert_not_called()
agent.observer.rollback_model_attempt.assert_not_called()
diff --git a/test/sdk/core/agents/test_nexent_agent.py b/test/sdk/core/agents/test_nexent_agent.py
index b8e3c299ae..3204b2ff25 100644
--- a/test/sdk/core/agents/test_nexent_agent.py
+++ b/test/sdk/core/agents/test_nexent_agent.py
@@ -263,10 +263,12 @@ class _MockToolSign:
mock_nexent_core_utils_module.tools_common_message = mock_tools_common_message_module
mock_nexent_core_models_module = types.ModuleType("nexent.core.models")
+mock_nexent_core_models_module.__path__ = [str(SDK_SOURCE_ROOT / "nexent" / "core" / "models")]
mock_nexent_core_models_module.OpenAILongContextModel = MagicMock()
mock_nexent_core_models_module.OpenAIVLModel = MagicMock()
mock_nexent_core_module = types.ModuleType("nexent.core")
+mock_nexent_core_module.__path__ = [str(SDK_SOURCE_ROOT / "nexent" / "core")]
mock_nexent_core_module.utils = mock_nexent_core_utils_module
mock_nexent_core_module.models = mock_nexent_core_models_module
mock_nexent_core_module.MessageObserver = _MockMessageObserver
@@ -336,6 +338,7 @@ class _MockToolSign:
"nexent.core.management.services.agent.prompt": mock_prompt_template_utils_module,
"nexent.core.utils.tools_common_message": mock_tools_common_message_module,
"nexent.core.models": mock_nexent_core_models_module,
+ "sdk.nexent.core.models": mock_nexent_core_models_module,
"nexent.storage": mock_nexent_storage_module,
"nexent.multi_modal": mock_nexent_multi_modal_module,
"nexent.multi_modal.load_save_object": mock_nexent_load_save_module,
@@ -6013,7 +6016,7 @@ async def forward(self, q="hello"):
with patch.object(nexent_agent, "get_monitoring_manager", return_value=mm):
wrapped = _wrap_tool_with_monitoring(tool, "agent_async")
- result = asyncio.get_event_loop().run_until_complete(
+ result = asyncio.run(
wrapped.forward(q="world")
)
@@ -6070,7 +6073,7 @@ async def async_func(val=0):
with patch.object(nexent_agent, "get_monitoring_manager", return_value=mm):
wrapped = _wrap_tool_with_monitoring(async_func, "agent_ac")
- result = asyncio.get_event_loop().run_until_complete(
+ result = asyncio.run(
wrapped(val=5)
)
diff --git a/test/sdk/core/agents/test_run_agent.py b/test/sdk/core/agents/test_run_agent.py
index e9914d25c6..3f84c0c740 100644
--- a/test/sdk/core/agents/test_run_agent.py
+++ b/test/sdk/core/agents/test_run_agent.py
@@ -1,3 +1,5 @@
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
import asyncio
import types
import json
@@ -6,6 +8,7 @@
import importlib
import sys
from types import ModuleType
+from pathlib import Path
from unittest.mock import MagicMock, patch
from threading import Event
@@ -223,6 +226,7 @@ def __init__(self, *args, **kwargs):
# Mock nexent.skills module for run_skill_script_tool
mock_nexent = ModuleType("nexent")
+mock_nexent.__path__ = [str(Path(__file__).resolve().parents[4] / "sdk" / "nexent")]
mock_nexent.skills = ModuleType("nexent.skills")
mock_nexent.skills.SkillManager = MagicMock(name="SkillManager")
sys.modules["nexent"] = mock_nexent
diff --git a/test/sdk/core/model_service/test_agent_model_construction.py b/test/sdk/core/model_service/test_agent_model_construction.py
new file mode 100644
index 0000000000..b79c022e0a
--- /dev/null
+++ b/test/sdk/core/model_service/test_agent_model_construction.py
@@ -0,0 +1,71 @@
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
+"""Actual Agent factory accepts a tenant-owned resolved outlet snapshot."""
+
+from threading import Event
+
+import pytest
+from nexent.core.agents.agent_model import ModelConfig
+from nexent.core.agents.nexent_agent import NexentAgent
+from nexent.core.concurrency.cancellation import RunCancellationScope
+from nexent.core.model_service.compatibility.invocation_options import (
+ LegacyInvocationOptions,
+)
+from nexent.core.model_service.compatibility.legacy_chat_model import LegacyChatModel
+from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile
+from nexent.core.models.retry import DEFAULT_MODEL_RETRY
+from nexent.core.utils.observer import MessageObserver
+
+
+def agent_for(profile, scope=None):
+ agent = NexentAgent.__new__(NexentAgent)
+ agent.tenant_id = profile.identity.tenant_id
+ agent.stop_event = Event()
+ agent.cancellation_scope = scope
+ agent.observer = MessageObserver()
+ agent.model_config_list = [ModelConfig(
+ cite_name='chosen', model_name=profile.identity.request_model_id,
+ url=profile.connection.base_url, api_key='controlled-key', resolved_profile=profile,
+ )]
+ return agent
+
+
+@pytest.mark.parametrize('provided_scope', [False, True])
+def test_agent_factory_uses_resolved_defaults_and_preserves_cancellation_owner(openai_profile, provided_scope):
+ scope = RunCancellationScope() if provided_scope else None
+ agent = agent_for(openai_profile, scope)
+ model = agent.create_model('chosen')
+ assert isinstance(model, LegacyChatModel)
+ assert model.profile == openai_profile and model.profile is not openai_profile
+ assert model.observer is agent.observer and model.service._policy is DEFAULT_MODEL_RETRY
+ assert model.stop_event is (scope.stop_event if provided_scope else agent.stop_event)
+ if provided_scope:
+ assert model.cancellation_scope is scope
+ agent.model_config_list[0].temperature = 0.9
+ assert model.profile.parameter_values == openai_profile.parameter_values
+ model.stop_event.set()
+ assert model.cancellation_scope.cancelled
+
+
+@pytest.mark.parametrize('field,value', [('tenant_id', 'other'), ('model_name', 'other'), ('url', 'https://other.test')])
+def test_agent_factory_rejects_mismatched_tenant_model_or_endpoint(openai_profile, field, value):
+ agent = agent_for(openai_profile)
+ if field == 'tenant_id':
+ agent.tenant_id = value
+ else:
+ setattr(agent.model_config_list[0], field, value)
+ with pytest.raises(ValueError, match='outlet snapshot'):
+ agent.create_model('chosen')
+
+
+def test_factory_captures_credentials_before_configuration_object_changes(openai_profile):
+ values = openai_profile.model_dump()
+ values['bindings']['auth'] = {'ref': 'bearer_token_v1', 'version': 1}
+ profile = ResolvedModelProfile.model_validate(values)
+ agent = agent_for(profile)
+ model = agent.create_model('chosen')
+ agent.model_config_list[0].api_key = 'later-key'
+ request, _ = model._request([{'role': 'user', 'content': 'hello'}], LegacyInvocationOptions({}), None)
+ prepared = model.service._registry.resolve(profile.bindings.adapter).prepare_http_request(request, profile)
+ sender = model.service._sender_factory(model.profile).freeze_credentials(prepared)
+ assert sender._headers(prepared)['authorization'] == 'Bearer controlled-key'
diff --git a/test/sdk/core/model_service/test_context_recovery.py b/test/sdk/core/model_service/test_context_recovery.py
new file mode 100644
index 0000000000..711ed7686c
--- /dev/null
+++ b/test/sdk/core/model_service/test_context_recovery.py
@@ -0,0 +1,219 @@
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
+"""Context-only rebuilds reuse the same frozen sender and physical attempt budget."""
+
+import asyncio
+import json
+import time
+from types import SimpleNamespace
+
+import pytest
+from nexent.core.concurrency.cancellation import RunCancellationScope, RunTerminated
+from nexent.core.model_service.contracts import ModelMessage, ToolDefinition
+from nexent.core.model_service.http_contracts import (
+ ModelTransportError,
+ RawHttpResponse,
+)
+from nexent.core.model_service.service import ModelServiceError
+from nexent.core.model_service.sse import SSEEvent
+from test_model_service import Sender, frame
+from test_model_service import setup as setup # noqa: PLC0414 - Shared pytest fixture.
+
+
+def overflow():
+ return ModelTransportError('HTTP_RESPONSE_STATUS', response=RawHttpResponse(
+ 400, {}, b'{"error":{"code":"context_length_exceeded","message":"private-detail"}}',
+ ))
+
+
+async def consume(model, request, profile, asynchronous, **kwargs):
+ if asynchronous:
+ async with model.astream(request, profile, **kwargs) as events:
+ return [event async for event in events]
+ with model.stream(request, profile, **kwargs) as events:
+ return list(events)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('asynchronous', [False, True])
+@pytest.mark.parametrize('succeeds', [False, True])
+async def test_context_recovery_shares_http_retry_budget_after_releasing_permit(setup, asynchronous, succeeds):
+ profile, request, limiter, factory = setup
+ request = request.model_copy(update={'stream': True})
+ final = [frame({'content': 'answer'}), frame(finish='stop'), SSEEvent('[DONE]')]
+ sender = Sender([ModelTransportError('HTTP_NETWORK_ERROR'), overflow(), final if succeeds else overflow()])
+ rebuilds = []
+
+ def rebuild(error):
+ assert not limiter._entries and sender.closed == 2
+ assert error.attempt == 2 and error.category == 'MODEL_CONTEXT_EXCEEDED'
+ rebuilds.append(error)
+ return request.model_copy(update={'messages': [ModelMessage(semantic_role='user', content_parts=[{'type': 'text', 'text': 'short'}])]})
+
+ async def arebuild(error):
+ return rebuild(error)
+
+ events = await consume(factory(sender), request, profile, asynchronous, request_rebuild=arebuild if asynchronous else rebuild)
+ assert events[-1].type == ('message_completed' if succeeds else 'error')
+ assert events[-1].attempt == 3
+ assert len(sender.calls) == 3 and len(rebuilds) == sender.frozen == 1
+ assert sender.calls[0].body_bytes == sender.calls[1].body_bytes
+ assert json.loads(sender.calls[2].body_bytes)['messages'] == [{'role': 'user', 'content': 'short'}]
+ assert not limiter._entries
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('asynchronous', [False, True])
+async def test_context_recovery_is_limited_to_two_even_with_larger_http_budget(setup, asynchronous):
+ profile, request, limiter, factory = setup
+ request = request.model_copy(update={'stream': True})
+ sender = Sender([overflow(), overflow(), overflow()])
+ model = factory(sender)
+ model._policy = SimpleNamespace(max_attempts=5, calculate_backoff=lambda _: 0)
+ rebuilds = []
+
+ def rebuild(error):
+ rebuilds.append(error)
+ return request
+
+ async def arebuild(error):
+ return rebuild(error)
+
+ events = await consume(model, request, profile, asynchronous, request_rebuild=arebuild if asynchronous else rebuild)
+ assert len(sender.calls) == 3 and len(rebuilds) == 2
+ assert events[-1].error.category == 'MODEL_CONTEXT_EXCEEDED' and not events[-1].error.will_retry
+ assert not limiter._entries
+
+
+@pytest.mark.parametrize('change', [None, {'request_id': 'other'}, {'outlet_id': 7}, {'purpose': 'other'}, {'config_revision': 2}, {'stream': False}, {'tools': [ToolDefinition(name='changed', description='changed tool', input_schema={'type': 'object'})]}])
+def test_invalid_rebuild_never_dispatches_another_request(setup, change):
+ profile, request, limiter, factory = setup
+ request = request.model_copy(update={'stream': True})
+ sender = Sender([overflow()])
+ callback = lambda _: None if change is None else request.model_copy(update=change)
+ with pytest.raises((TypeError, ValueError)), factory(sender).stream(request, profile, request_rebuild=callback) as events:
+ list(events)
+ assert len(sender.calls) == 1 and sender.closed == 1 and not limiter._entries
+
+
+def test_cancellation_during_sync_rebuild_never_dispatches_again(setup):
+ profile, request, limiter, factory = setup
+ sender = Sender([overflow()])
+ scope = RunCancellationScope()
+
+ def rebuild(_error):
+ scope.cancel()
+ return request
+
+ with pytest.raises(RunTerminated):
+ factory(sender).invoke(request, profile, scope=scope, request_rebuild=rebuild)
+ assert len(sender.calls) == 1 and not limiter._entries
+
+
+@pytest.mark.asyncio
+async def test_async_rebuild_is_bounded_by_original_run_deadline(setup):
+ profile, request, limiter, factory = setup
+ sender = Sender([overflow()])
+ exited = asyncio.Event()
+
+ async def rebuild(_error):
+ try:
+ await asyncio.Event().wait()
+ finally:
+ exited.set()
+
+ with pytest.raises(ModelServiceError) as caught:
+ await factory(sender).ainvoke(request, profile, request_rebuild=rebuild, deadline=time.monotonic() + 0.1)
+ assert caught.value.error.category == 'MODEL_RUN_DEADLINE'
+ assert exited.is_set() and len(sender.calls) == 1 and not limiter._entries
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('asynchronous', [False, True])
+async def test_visible_reasoning_forbids_context_rebuild(setup, asynchronous):
+ profile, request, limiter, factory = setup
+ request = request.model_copy(update={'stream': True})
+ sender = Sender([[frame({'reasoning_content': 'thought'}), overflow()]])
+ called = []
+
+ def rebuild(error):
+ called.append(error)
+ return request
+
+ async def arebuild(error):
+ return rebuild(error)
+
+ events = await consume(factory(sender), request, profile, asynchronous, request_rebuild=arebuild if asynchronous else rebuild)
+ assert events[0].type == 'reasoning_delta'
+ assert events[-1].error.category == 'MODEL_CONTEXT_EXCEEDED'
+ assert not called and len(sender.calls) == 1 and not limiter._entries
+
+
+@pytest.mark.asyncio
+async def test_wrong_callback_type_is_rejected_before_credentials_or_send(setup):
+ profile, request, limiter, factory = setup
+ sender = Sender([])
+ model = factory(sender)
+
+ async def asynchronous(_error):
+ return request
+
+ with pytest.raises(TypeError, match='callback'):
+ model.invoke(request, profile, request_rebuild=asynchronous)
+ with pytest.raises(TypeError, match='callback'):
+ await model.ainvoke(request, profile, request_rebuild=lambda _: request)
+ assert not sender.calls and not sender.frozen and not limiter._entries
+
+
+@pytest.mark.asyncio
+async def test_scope_cancel_interrupts_suspended_async_rebuild_and_unregisters_hook(setup):
+ profile, request, limiter, factory = setup
+ sender = Sender([overflow()])
+ scope = RunCancellationScope()
+ started, exited = asyncio.Event(), asyncio.Event()
+
+ async def rebuild(_error):
+ started.set()
+ try:
+ await asyncio.Event().wait()
+ finally:
+ exited.set()
+
+ async def cancel():
+ await started.wait()
+ scope.cancel()
+
+ async with asyncio.TaskGroup() as group:
+ group.create_task(cancel())
+ with pytest.raises(RunTerminated):
+ await factory(sender).ainvoke(request, profile, scope=scope, request_rebuild=rebuild)
+ assert exited.is_set() and len(sender.calls) == 1
+ assert not limiter._entries and not scope._closers
+
+
+def test_callback_error_is_not_retried_as_an_http_error(setup):
+ profile, request, limiter, factory = setup
+ sender = Sender([overflow()])
+
+ def rebuild(_error):
+ raise RuntimeError('context construction failed')
+
+ with pytest.raises(RuntimeError, match='context construction failed'):
+ factory(sender).invoke(request, profile, request_rebuild=rebuild)
+ assert len(sender.calls) == 1 and not limiter._entries
+
+
+def test_sync_rebuild_cannot_dispatch_after_original_deadline(setup, monkeypatch):
+ profile, request, limiter, factory = setup
+ sender = Sender([overflow()])
+ now = [time.monotonic()]
+ monkeypatch.setattr('nexent.core.model_service.service.time.monotonic', lambda: now[0])
+
+ def rebuild(_error):
+ now[0] += 2
+ return request
+
+ with pytest.raises(ModelServiceError) as caught:
+ factory(sender).invoke(request, profile, request_rebuild=rebuild, deadline=now[0] + 1)
+ assert caught.value.error.category == 'MODEL_RUN_DEADLINE'
+ assert len(sender.calls) == 1 and not limiter._entries
diff --git a/test/sdk/core/model_service/test_gateway_long_text_outlet.py b/test/sdk/core/model_service/test_gateway_long_text_outlet.py
new file mode 100644
index 0000000000..df4a0e1f4c
--- /dev/null
+++ b/test/sdk/core/model_service/test_gateway_long_text_outlet.py
@@ -0,0 +1,50 @@
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
+"""Existing long-text entry points use the saved outlet sender."""
+
+import pytest
+from nexent.core.gateway.modality.llm.openai import OpenAILongContextLLMAdapter
+from nexent.core.gateway.model_context import LongContextLLMContext
+from nexent.core.model_service.compatibility.legacy_chat_model import LegacyChatModel
+
+
+def context(profile, context_type, modality):
+ return context_type(model_name=profile.identity.request_model_id, base_url=profile.connection.base_url,
+ api_key="controlled-key", tenant_id=profile.identity.tenant_id, modality=modality,
+ factory="openai", resolved_profile=profile)
+
+
+def test_long_text_preserves_preparation_and_calls_same_model(openai_profile, mocker):
+ adapter = OpenAILongContextLLMAdapter(context(openai_profile, LongContextLLMContext, "llm_long_context"))
+ adapter._build_model()
+ assert isinstance(adapter._model, LegacyChatModel)
+ assert adapter.max_context_tokens == openai_profile.capabilities.context.combined_window
+ mocker.patch.object(adapter, "_get_tokenizer", return_value=None)
+ generate = mocker.patch.object(adapter._model, "generate", return_value="answer")
+ result, percent = adapter.analyze_long_text("text", "system", "analyze")
+ assert (result, percent) == ("answer", "100")
+ assert generate.call_args.args[0] == [
+ {"role": "system", "content": "system"}, {"role": "user", "content": "analyze\n\ntext"},
+ ]
+
+
+def test_long_text_missing_capacity_does_not_guess_legacy_limit(openai_profile):
+ data = openai_profile.model_dump()
+ data["capabilities"]["context"]["combined_window"] = None
+ data["capabilities"]["context"]["input_limit"] = None
+ profile = openai_profile.__class__.model_validate(data)
+ ctx = context(profile, LongContextLLMContext, "llm_long_context")
+ ctx.max_tokens = 128000
+ with pytest.raises(ValueError, match="positive integer"):
+ OpenAILongContextLLMAdapter(ctx)._build_model()
+
+
+def test_long_text_insufficient_capacity_fails_before_model(openai_profile, mocker):
+ adapter = OpenAILongContextLLMAdapter(context(openai_profile, LongContextLLMContext, "llm_long_context"))
+ adapter._build_model()
+ adapter.max_context_tokens = 1
+ mocker.patch.object(adapter, "_get_tokenizer", return_value=None)
+ generate = mocker.patch.object(adapter._model, "generate")
+ with pytest.raises(ValueError, match="Insufficient tokens"):
+ adapter.analyze_long_text("text", "system", "analyze")
+ generate.assert_not_called()
diff --git a/test/sdk/core/model_service/test_gateway_resolved_model.py b/test/sdk/core/model_service/test_gateway_resolved_model.py
new file mode 100644
index 0000000000..48b6f8aa65
--- /dev/null
+++ b/test/sdk/core/model_service/test_gateway_resolved_model.py
@@ -0,0 +1,89 @@
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
+"""Resolved outlets retain the existing gateway interface over one transport."""
+
+from contextlib import asynccontextmanager
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+from nexent.core.concurrency.cancellation import RunCancellationScope, RunTerminated
+from nexent.core.gateway.modality.llm.llm_adapter import LLMRequest
+from nexent.core.gateway.modality.llm.openai import OpenAILLMAdapter
+from nexent.core.gateway.model_context import LLMContext
+from nexent.core.model_service.compatibility.legacy_chat_model import LegacyChatModel
+from nexent.core.model_service.contracts import TokenUsage
+
+
+def adapter_for(profile):
+ return OpenAILLMAdapter(LLMContext(
+ model_name=profile.identity.request_model_id, base_url=profile.connection.base_url,
+ api_key="controlled-key", modality="llm", factory="openai",
+ tenant_id=profile.identity.tenant_id, resolved_profile=profile,
+ cancellation_scope=RunCancellationScope(),
+ ))
+
+
+def test_gateway_constructs_shared_model_and_declares_profile_capabilities(openai_profile):
+ adapter = adapter_for(openai_profile)
+ adapter._build_model()
+ assert isinstance(adapter._model, LegacyChatModel)
+ assert adapter._model.cancellation_scope is adapter._context.cancellation_scope
+ info = adapter.get_model_info()
+ assert info.provider == openai_profile.identity.provider_id
+ assert info.model_id == openai_profile.identity.request_model_id
+ assert info.capabilities["tool_calling"] == (openai_profile.capabilities.tools.status == "supported")
+ assert info.capabilities["text"] == ("text" in openai_profile.capabilities.modalities.output)
+
+
+@pytest.mark.parametrize("field,value", [("tenant_id", "other"), ("model_name", "other"), ("base_url", "https://other.test")])
+def test_gateway_rejects_mixed_snapshot_identity(openai_profile, field, value):
+ adapter = adapter_for(openai_profile)
+ setattr(adapter._context, field, value)
+ with pytest.raises(ValueError, match="outlet snapshot"):
+ adapter._build_model()
+ assert adapter._model is None
+
+
+@pytest.mark.asyncio
+async def test_gateway_health_uses_shared_model_probe(openai_profile, monkeypatch):
+ adapter = adapter_for(openai_profile)
+ probe = AsyncMock(return_value=True)
+ monkeypatch.setattr(LegacyChatModel, "check_connectivity", probe)
+ assert await adapter.health_check() is True
+ probe.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("cancel", [False, True])
+async def test_gateway_async_stream_closes_transport_on_early_exit_or_cancel(openai_profile, monkeypatch, cancel):
+ adapter = adapter_for(openai_profile)
+ adapter._build_model()
+ opened, closed = [], []
+
+ @asynccontextmanager
+ async def transport(request, profile, *, scope):
+ opened.append((request, profile, scope))
+
+ async def events():
+ if cancel:
+ raise RunTerminated()
+ yield SimpleNamespace(type="text_delta", content_kind="text", delta="body")
+ yield SimpleNamespace(type="usage", usage=TokenUsage(input_tokens=10, output_tokens=2))
+
+ try:
+ yield events()
+ finally:
+ closed.append(True)
+
+ monkeypatch.setattr(adapter._model.service, "astream", transport)
+ stream = await adapter.stream(LLMRequest(messages=[{"role": "user", "content": "hello"}]))
+ if cancel:
+ with pytest.raises(RunTerminated):
+ await anext(stream)
+ else:
+ assert (await anext(stream)).content == "body"
+ await stream.aclose()
+ assert closed == [True] and len(opened) == 1
+ assert opened[0][2] is adapter._context.cancellation_scope
+ assert opened[0][0].stream is True
diff --git a/test/sdk/core/model_service/test_legacy_chat_model.py b/test/sdk/core/model_service/test_legacy_chat_model.py
new file mode 100644
index 0000000000..b936b43c45
--- /dev/null
+++ b/test/sdk/core/model_service/test_legacy_chat_model.py
@@ -0,0 +1,291 @@
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
+"""Existing smolagents interface and observer behavior with a controlled service."""
+
+import json
+from contextlib import contextmanager
+from types import SimpleNamespace
+
+import pytest
+from nexent.core.concurrency.cancellation import RunTerminated
+from nexent.core.model_errors import ModelErrorCode, ModelInvocationTerminalError
+from nexent.core.model_service.compatibility.legacy_chat_model import LegacyChatModel
+from nexent.core.model_service.contracts import AssistantMessage, ModelEvent, TokenUsage
+from nexent.core.model_service.errors import normalize_model_error
+from nexent.core.model_service.http_contracts import ModelTransportError
+from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError
+from nexent.core.utils.observer import MessageObserver
+from smolagents.models import ChatMessage, MessageRole
+
+
+@pytest.fixture
+def setup(openai_profile):
+ calls = []
+ state = {
+ "usage": TokenUsage(input_tokens=10, output_tokens=0, total_tokens=10),
+ "attempt": 2,
+ }
+
+ @contextmanager
+ def stream(request, profile, **kwargs):
+ calls.append((request, profile, kwargs))
+ if kwargs["scope"].cancelled:
+ raise RunTerminated()
+ if "error" in state:
+ yield iter(
+ [
+ ModelEvent(
+ request_id=request.request_id,
+ message_id=request.messages[0].message_id,
+ sequence=1,
+ type="error",
+ error=state["error"].model_copy(
+ update={"request_id": request.request_id}
+ ),
+ attempt=state["attempt"],
+ )
+ ]
+ )
+ return
+ message = AssistantMessage(
+ content_parts=[{"type": "text", "text": "answer"}],
+ reasoning_parts=[{"type": "text", "text": "thought"}],
+ finish_reason="stop",
+ usage=state["usage"],
+ )
+ yield iter(
+ [
+ ModelEvent(
+ request_id=request.request_id,
+ message_id=message.message_id,
+ sequence=1,
+ attempt=state["attempt"],
+ type="reasoning_delta",
+ delta="thought",
+ ),
+ ModelEvent(
+ request_id=request.request_id,
+ message_id=message.message_id,
+ sequence=2,
+ attempt=state["attempt"],
+ type="text_delta",
+ delta="answer",
+ ),
+ ModelEvent(
+ request_id=request.request_id,
+ message_id=message.message_id,
+ sequence=3,
+ attempt=state["attempt"],
+ type="usage",
+ usage=state["usage"],
+ ),
+ ModelEvent(
+ request_id=request.request_id,
+ message_id=message.message_id,
+ sequence=4,
+ attempt=state["attempt"],
+ type="message_completed",
+ message=message,
+ finish_reason="stop",
+ ),
+ ]
+ )
+
+ observer = MessageObserver()
+ model = LegacyChatModel(
+ openai_profile, SimpleNamespace(stream=stream), observer=observer
+ )
+ return model, state, calls, observer
+
+
+def test_call_returns_real_smolagents_message_with_actual_attempt_and_zero_usage(setup):
+ model, _state, calls, _observer = setup
+ result = model([{"role": "user", "content": "hello"}])
+ assert isinstance(result, ChatMessage)
+ assert result.role == MessageRole.ASSISTANT and result.content == "answer"
+ assert (
+ result.token_usage.input_tokens == 10 and result.token_usage.output_tokens == 0
+ )
+ assert result.model_attempt_number == 2 and model.last_attempt_number == 2
+ assert result.raw.reasoning_parts == [{"type": "text", "text": "thought"}]
+ assert calls[0][2]["scope"] is model.cancellation_scope
+ assert model.last_finish_reason == "stop"
+
+
+def test_code_history_roles_and_system_messages_preserve_media_and_input(setup):
+ model, _state, calls, _observer = setup
+ messages = [
+ {"role": "system", "content": "one"},
+ {"role": "system", "content": "two"},
+ ChatMessage(
+ role=MessageRole.TOOL_CALL,
+ content=[{"type": "text", "text": "x"}],
+ ),
+ ChatMessage(
+ role=MessageRole.TOOL_RESPONSE, content=[{"type": "text", "text": "result"}]
+ ),
+ {
+ "role": "user",
+ "content": [
+ {"type": "image_url", "image_url": {"url": "https://image.test"}}
+ ],
+ },
+ ]
+ model(messages)
+ request = calls[0][0]
+ assert request.messages[0].content_parts == [{"type": "text", "text": "one\n\ntwo"}]
+ assert request.messages[1].semantic_role == "assistant"
+ assert request.messages[1].content_parts == [
+ {"type": "text", "text": "x"}
+ ]
+ assert request.messages[2].semantic_role == "user"
+ assert request.messages[2].content_parts[-1]["type"] == "image_url"
+ assert messages[0]["content"] == "one" and messages[1]["content"] == "two"
+ assert messages[2].role == MessageRole.TOOL_CALL
+
+
+def test_thinking_and_content_go_to_distinct_existing_observer_methods(setup, mocker):
+ model, _state, _calls, observer = setup
+ thought = mocker.patch.object(observer, "add_model_reasoning_content")
+ content = mocker.patch.object(observer, "add_model_new_token")
+ model([{"role": "user", "content": "hello"}])
+ thought.assert_called_once_with("thought")
+ content.assert_called_once_with("answer")
+
+
+def test_stream_uses_smolagents_deltas_without_emitting_thought_as_content(setup):
+ model, _state, _calls, _observer = setup
+ deltas = list(model.generate_stream([{"role": "user", "content": "hello"}]))
+ assert [delta.content for delta in deltas] == ["answer", None]
+ assert deltas[-1].token_usage.input_tokens == 10
+
+
+def test_missing_usage_remains_unknown(setup):
+ model, state, _calls, _observer = setup
+ state["usage"] = TokenUsage()
+ result = model.generate([{"role": "user", "content": "hello"}])
+ assert result.token_usage is None and model.last_input_token_count is None
+ assert model.last_output_token_count is None
+
+
+def test_only_declared_parameter_overrides_are_accepted_without_mutating_saved_profile(
+ setup,
+):
+ model, _state, calls, _observer = setup
+ model([{"role": "user", "content": "hello"}], enable_thinking=True)
+ assert calls[0][1].parameter_values["enable_thinking"] is True
+ assert model.profile.parameter_values["enable_thinking"] is False
+ with pytest.raises(ParameterValidationError):
+ model([{"role": "user", "content": "hello"}], unknown_parameter="value")
+ assert len(calls) == 1
+
+
+def test_cancel_propagates_without_mode_fallback_or_model_error(setup):
+ model, _state, calls, _observer = setup
+ model.cancellation_scope.cancel()
+ with pytest.raises(RunTerminated):
+ model([{"role": "user", "content": "hello"}])
+ assert len(calls) == 1
+
+
+def test_service_error_maps_to_existing_terminal_error_contract(setup):
+ model, state, _calls, _observer = setup
+ state["error"] = normalize_model_error(
+ ModelTransportError("HTTP_TIMEOUT"),
+ request_id="request",
+ attempt=2,
+ max_attempts=2,
+ )
+ with pytest.raises(ModelInvocationTerminalError) as caught:
+ model([{"role": "user", "content": "hello"}])
+ assert caught.value.error_code == ModelErrorCode.TIMEOUT
+ assert caught.value.attempts == 2
+
+
+def controls(observer):
+ return [
+ json.loads(message)["phase"]
+ for message in observer.message_query
+ if json.loads(message)["type"] == "model_attempt_control"
+ ]
+
+
+@pytest.mark.parametrize("accepted", [False, True])
+def test_deferred_attempt_waits_for_caller_confirmation(setup, accepted):
+ model, _state, calls, observer = setup
+ result = model([{"role": "user", "content": "hello"}], _defer_attempt_commit=True)
+ assert result.model_attempt_commit_deferred is True
+ assert controls(observer) == ["begin"]
+ resolve = observer.commit_model_attempt if accepted else observer.rollback_model_attempt
+ resolve(result.model_attempt_id, result.model_attempt_number)
+ assert controls(observer) == ["begin", "commit" if accepted else "rollback"]
+ assert len(calls) == 1
+
+
+@pytest.mark.parametrize("deferred", [False, True])
+def test_suppressed_repair_preserves_response_but_emits_no_observer_messages(setup, deferred):
+ model, _state, calls, observer = setup
+ result = model(
+ [{"role": "user", "content": "repair"}],
+ _defer_attempt_commit=deferred, _suppress_attempt_stream=True,
+ )
+ assert result.content == "answer" and result.raw.reasoning_parts
+ assert result.model_attempt_number == 2 and result.model_attempt_commit_deferred is False
+ assert result.token_usage.input_tokens == 10 and observer.message_query == []
+ assert len(calls) == 1
+
+
+def test_suppressed_stream_returns_deltas_without_observer_side_effects(setup):
+ model, _state, calls, observer = setup
+ result = list(model.generate_stream(
+ [{"role": "user", "content": "repair"}], _suppress_attempt_stream=True,
+ ))
+ assert result[0].content == "answer" and result[-1].token_usage.input_tokens == 10
+ assert observer.message_query == [] and len(calls) == 1
+
+
+@pytest.mark.parametrize("suppressed", [False, True])
+def test_failed_attempt_rolls_back_only_visible_observer_stream(setup, suppressed):
+ model, state, calls, observer = setup
+ state["error"] = normalize_model_error(
+ ModelTransportError("HTTP_TIMEOUT"), request_id="request", attempt=2, max_attempts=2,
+ )
+ with pytest.raises(ModelInvocationTerminalError):
+ model([{"role": "user", "content": "hello"}], _suppress_attempt_stream=suppressed)
+ assert controls(observer) == ([] if suppressed else ["begin", "rollback"])
+ assert len(calls) == 1
+
+
+def test_early_stream_close_rolls_back_unfinished_attempt(setup):
+ model, _state, _calls, observer = setup
+ stream = model.generate_stream([{"role": "user", "content": "hello"}])
+ assert next(stream).content == "answer"
+ stream.close()
+ assert controls(observer) == ["begin", "rollback"]
+
+
+@pytest.mark.parametrize("options", [
+ {"_defer_attempt_commit": "true"}, {"_suppress_attempt_stream": 1},
+])
+def test_invalid_attempt_controls_fail_before_request(setup, options):
+ model, _state, calls, observer = setup
+ with pytest.raises(ValueError, match="booleans"):
+ model([{"role": "user", "content": "hello"}], **options)
+ assert calls == [] and observer.message_query == []
+
+
+@pytest.mark.parametrize('enabled,policy', [(False, 'fail'), (True, 'retry')])
+def test_empty_retry_control_is_not_a_model_parameter(setup, enabled, policy):
+ model, _state, calls, _observer = setup
+ result = model.generate([{'role': 'user', 'content': 'hello'}], _retry_empty_response=enabled)
+ assert result.content == 'answer'
+ assert calls[0][2]['empty_response_policy'] == policy
+ assert '_retry_empty_response' not in calls[0][1].parameter_values
+
+
+@pytest.mark.parametrize('value', [None, 0, 1, 'false'])
+def test_empty_retry_control_requires_boolean_before_service_call(setup, value):
+ model, _state, calls, _observer = setup
+ with pytest.raises(ValueError, match='booleans'):
+ model.generate([{'role': 'user', 'content': 'hello'}], _retry_empty_response=value)
+ assert calls == []
diff --git a/test/sdk/core/model_service/test_legacy_connectivity.py b/test/sdk/core/model_service/test_legacy_connectivity.py
new file mode 100644
index 0000000000..d672240e84
--- /dev/null
+++ b/test/sdk/core/model_service/test_legacy_connectivity.py
@@ -0,0 +1,35 @@
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
+"""Connection-only probes retain outlet defaults and cancellation semantics."""
+
+import asyncio
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+from nexent.core.concurrency.cancellation import RunTerminated
+from nexent.core.model_service.compatibility.legacy_chat_model import LegacyChatModel
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('failure', [None, RuntimeError('unavailable')])
+async def test_probe_uses_saved_parameters_without_invented_output_cap(openai_profile, failure):
+ invoke = AsyncMock(side_effect=failure)
+ model = LegacyChatModel(openai_profile, SimpleNamespace(ainvoke=invoke))
+ assert await model.check_connectivity() is (failure is None)
+ request, profile = invoke.call_args.args
+ assert not request.stream and not request.tools
+ assert request.messages[0].content_parts == [{'type': 'text', 'text': 'Hello'}]
+ assert profile.parameter_values == openai_profile.parameter_values
+ assert invoke.call_args.kwargs['scope'] is model.cancellation_scope
+ assert model.last_response_diagnostics is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('failure', [RunTerminated(), asyncio.CancelledError()])
+async def test_probe_does_not_convert_cancellation_to_unavailable(openai_profile, failure):
+ invoke = AsyncMock(side_effect=failure)
+ model = LegacyChatModel(openai_profile, SimpleNamespace(ainvoke=invoke))
+ with pytest.raises(type(failure)):
+ await model.check_connectivity()
+ assert invoke.await_count == 1
diff --git a/test/sdk/core/model_service/test_legacy_context_budget.py b/test/sdk/core/model_service/test_legacy_context_budget.py
new file mode 100644
index 0000000000..484fcbbb67
--- /dev/null
+++ b/test/sdk/core/model_service/test_legacy_context_budget.py
@@ -0,0 +1,142 @@
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
+"""Reuse current W1/W2 contracts at the compatibility request boundary."""
+
+from types import SimpleNamespace
+
+import pytest
+from nexent.core.model_service.adapters.openai_chat import OpenAIChatAdapter
+from nexent.core.model_service.compatibility.invocation_options import LegacyInvocationOptions
+from nexent.core.model_service.compatibility.legacy_chat_model import LegacyChatModel
+from nexent.core.model_service.contracts import ModelMessage, ModelRequest
+from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError
+from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile
+from nexent.core.models.capacity_budget import (
+ CallerMaxTokensOverrideForbidden,
+ CapacityReservePolicy,
+ ContextBudgetCalculator,
+ ContextBudgetCapacityMismatch,
+ ContextBudgetFingerprintMismatch,
+)
+from nexent.core.models.capacity_resolver import ModelCapacitySnapshot
+
+
+@pytest.fixture
+def budget():
+ capacity = ModelCapacitySnapshot(
+ provider="alibaba", model_name="qwen3.5-plus", context_window_tokens=32768,
+ max_input_tokens=None, max_output_tokens=8192, default_output_reserve_tokens=4096,
+ requested_output_tokens=4096, provider_input_limit_tokens=28672, tokenizer_family=None,
+ counting_mode="estimated", unknown_capabilities=[], field_sources={},
+ capability_profile_version=None, fingerprint="w1-test",
+ )
+ return ContextBudgetCalculator().calculate_context_budget(
+ capacity_snapshot=capacity, reserve_policy=CapacityReservePolicy(),
+ ).model_dump()
+
+
+@pytest.fixture
+def model(openai_profile, budget):
+ snapshot = openai_profile.model_dump()
+ snapshot["parameter_rules"] = [rule for rule in snapshot["parameter_rules"] if rule["key"] != "max_output_tokens"]
+ snapshot["parameter_rules"].append({
+ "key": "max_output_tokens", "schema": {"type": "integer", "minimum": 1},
+ "visible": True, "editable": True, "required": False,
+ "allowed_scopes": ["outlet"], "ui_group": "generation", "owner": "provider",
+ "source": {"ref": "controlled_fixture", "version": 1},
+ "default_value": 2048,
+ "api_binding": {"path": "/max_tokens", "transform_ref": "identity_v1", "transform_version": 1},
+ })
+ snapshot["parameter_values"]["max_output_tokens"] = 2048
+ return LegacyChatModel(
+ ResolvedModelProfile.model_validate(snapshot), SimpleNamespace(),
+ context_budget_snapshot=budget,
+ capacity_snapshot={"capacity_fingerprint": "w1-test", "provider": "alibaba", "model_name": "qwen3.5-plus"},
+ )
+
+
+def test_budget_fills_declared_output_cap_without_changing_outlet(model, mocker):
+ stream = mocker.patch.object(model.service, "stream", create=True, side_effect=RuntimeError("prepared"))
+ with pytest.raises(RuntimeError, match="prepared"):
+ model([{"role": "user", "content": "hello"}])
+ request, effective = stream.call_args.args
+ assert effective.parameter_values["max_output_tokens"] == 4096
+ assert model.profile.parameter_values["max_output_tokens"] == 2048
+ assert request.messages[0].content_parts == [{"type": "text", "text": "hello"}]
+
+
+@pytest.mark.parametrize("overrides", [{"max_tokens": 4095}, {"max_output_tokens": 4097}])
+def test_caller_cannot_override_trusted_output_cap(model, overrides):
+ with pytest.raises(CallerMaxTokensOverrideForbidden):
+ model([{"role": "user", "content": "hello"}], **overrides)
+ assert model.last_attempt_id is None
+
+
+@pytest.mark.parametrize("field,value", [
+ ("capacity_fingerprint", "stale"), ("provider", "foreign"), ("model_name", "foreign"),
+])
+def test_stale_or_cross_model_capacity_rejected_before_send(model, field, value):
+ model.capacity_snapshot[field] = value
+ with pytest.raises(ContextBudgetCapacityMismatch):
+ model([{"role": "user", "content": "hello"}])
+ assert model.last_attempt_id is None
+
+
+def test_budget_identity_checked_even_without_active_capacity(model):
+ model.capacity_snapshot = None
+ snapshot = model.profile.model_dump()
+ snapshot["identity"]["request_model_id"] = "another-model"
+ model.profile = ResolvedModelProfile.model_validate(snapshot)
+ with pytest.raises(ContextBudgetCapacityMismatch):
+ model([{"role": "user", "content": "hello"}])
+
+
+def test_corrupted_budget_and_invalid_type_rejected(model, budget):
+ budget["requested_output_tokens"] = 1
+ with pytest.raises(ContextBudgetFingerprintMismatch):
+ model([{"role": "user", "content": "hello"}], context_budget_snapshot=budget)
+ with pytest.raises(TypeError):
+ model([{"role": "user", "content": "hello"}], context_budget_snapshot="invalid")
+
+
+def test_undeclared_output_binding_is_not_created(model):
+ snapshot = model.profile.model_dump()
+ snapshot["parameter_rules"] = [r for r in snapshot["parameter_rules"] if r["key"] != "max_output_tokens"]
+ snapshot["parameter_values"].pop("max_output_tokens")
+ model.profile = ResolvedModelProfile.model_validate(snapshot)
+ with pytest.raises(ParameterValidationError, match="LEGACY_OUTPUT_BINDING_REQUIRED"):
+ model([{"role": "user", "content": "hello"}])
+
+
+def test_constructor_detaches_budget_input(model, budget):
+ budget["field_sources"]["requested_output_tokens"] = "changed"
+ assert model.context_budget_snapshot.field_sources["requested_output_tokens"] != "changed"
+
+
+def test_builtin_profile_binds_trusted_answer_budget(openai_profile, budget):
+ effective = LegacyInvocationOptions({}, context_budget_snapshot=budget).resolve(openai_profile)
+ request = ModelRequest(request_id="builtin-budget", outlet_id=effective.outlet_id,
+ config_revision=effective.config_revision,
+ messages=[ModelMessage(semantic_role="user", content_parts=[{"type": "text", "text": "hello"}])])
+ body = OpenAIChatAdapter().prepare_http_request(request, effective).json_body
+ assert body["max_tokens"] == budget["requested_output_tokens"]
+ assert "max_completion_tokens" not in body
+ assert "max_output_tokens" not in openai_profile.parameter_values
+
+
+@pytest.mark.parametrize("value", [0, -1, 65537, True, 1.5])
+def test_builtin_profile_rejects_invalid_answer_budget(openai_profile, value):
+ with pytest.raises(ParameterValidationError):
+ LegacyInvocationOptions({"max_tokens": value}).resolve(openai_profile)
+
+
+@pytest.mark.parametrize("provider", ["ALIBABA", "Alibaba"])
+def test_migrated_provider_casing_preserves_budget_identity(model, provider):
+ model.capacity_snapshot["provider"] = provider
+ effective = LegacyInvocationOptions(
+ {}, context_budget_snapshot=model.context_budget_snapshot,
+ capacity_snapshot=model.capacity_snapshot,
+ ).resolve(model.profile)
+ assert effective.parameter_values["max_output_tokens"] == 4096
+ assert model.capacity_snapshot["provider"] == provider
+ assert model.context_budget_snapshot.provider == "alibaba"
diff --git a/test/sdk/core/model_service/test_legacy_context_recovery.py b/test/sdk/core/model_service/test_legacy_context_recovery.py
new file mode 100644
index 0000000000..792160f3ab
--- /dev/null
+++ b/test/sdk/core/model_service/test_legacy_context_recovery.py
@@ -0,0 +1,83 @@
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
+"""Legacy FinalContext callbacks retain evidence, identity and attempt correlation."""
+
+import json
+from types import SimpleNamespace
+
+import pytest
+from nexent.core.model_service.compatibility.legacy_chat_model import LegacyChatModel
+from nexent.core.model_service.sse import SSEEvent
+from test_context_recovery import overflow
+from test_model_service import Sender, complete, frame
+from test_model_service import setup as setup # noqa: PLC0414 - Shared pytest fixture.
+
+
+def completion(text):
+ return [frame({'content': text}), frame(finish='stop'), SSEEvent('[DONE]')]
+
+
+@pytest.mark.parametrize('final_context', [False, True])
+def test_legacy_context_rebuild_uses_same_outlet_and_retains_evidence(setup, final_context):
+ profile, _request, limiter, factory = setup
+ sender = Sender([overflow(), completion('answer')])
+ model = LegacyChatModel(profile, factory(sender))
+ evidence = object()
+ messages = [{'role': 'user', 'content': 'short'}]
+ rebuilds = []
+
+ def rebuild():
+ assert not limiter._entries
+ rebuilds.append(True)
+ return SimpleNamespace(messages=messages, evidence=evidence) if final_context else messages
+
+ result = model.generate([{'role': 'user', 'content': 'long'}], context_rebuild=rebuild)
+ assert result.content == 'answer' and result.model_attempt_number == 2
+ assert model.last_context_evidence is (evidence if final_context else None)
+ assert len(rebuilds) == 1 and sender.frozen == 1 and len(sender.calls) == 2
+ assert sender.calls[0].request_id == sender.calls[1].request_id == result.model_attempt_id
+ assert json.loads(sender.calls[1].body_bytes)['messages'] == messages
+ assert all('context_rebuild' not in json.loads(call.body_bytes) for call in sender.calls)
+ assert not limiter._entries
+
+
+def test_nested_compression_call_does_not_change_outer_attempt_confirmation(setup):
+ profile, _request, limiter, factory = setup
+ sender = Sender([overflow(), completion('summary'), completion('answer')])
+ model = LegacyChatModel(profile, factory(sender))
+ nested = []
+
+ def rebuild():
+ nested.append(model.generate([{'role': 'user', 'content': 'compress'}]))
+ return [{'role': 'user', 'content': nested[-1].content}]
+
+ result = model.generate([{'role': 'user', 'content': 'long'}], context_rebuild=rebuild)
+ assert result.content == 'answer' and result.model_attempt_number == 2
+ assert result.model_attempt_id != nested[0].model_attempt_id
+ events = [json.loads(value) for value in model.observer.message_query]
+ phases = [(event['attempt_id'], event['phase'], event['attempt']) for event in events if 'phase' in event]
+ assert phases == [
+ (nested[0].model_attempt_id, 'begin', 1), (nested[0].model_attempt_id, 'commit', 1),
+ (result.model_attempt_id, 'begin', 2), (result.model_attempt_id, 'commit', 2),
+ ]
+ assert not limiter._entries
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('asynchronous', [False, True])
+async def test_nonstream_context_recovery_uses_matching_callback_api(setup, asynchronous):
+ profile, request, limiter, factory = setup
+ sender = Sender([overflow(), complete()])
+ model = factory(sender)
+
+ def rebuild(_error):
+ assert not limiter._entries
+ return request
+
+ async def arebuild(error):
+ return rebuild(error)
+
+ response = (await model.ainvoke(request, profile, request_rebuild=arebuild) if asynchronous
+ else model.invoke(request, profile, request_rebuild=rebuild))
+ assert response.content_parts == [{'type': 'text', 'text': 'answer'}]
+ assert len(sender.calls) == 2 and sender.frozen == 1 and not limiter._entries
diff --git a/test/sdk/core/model_service/test_legacy_response_observation.py b/test/sdk/core/model_service/test_legacy_response_observation.py
new file mode 100644
index 0000000000..86e2864c15
--- /dev/null
+++ b/test/sdk/core/model_service/test_legacy_response_observation.py
@@ -0,0 +1,64 @@
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
+"""Old diagnostic and cache consumers retain actual normalized response data."""
+
+import pytest
+from nexent.core.model_errors import ModelInvocationTerminalError
+from nexent.core.model_service.contracts import TokenUsage
+from nexent.core.model_service.errors import normalize_model_error
+from nexent.core.model_service.http_contracts import ModelTransportError
+from test_legacy_chat_model import setup as setup # noqa: PLC0414 - Shared pytest fixture.
+
+
+@pytest.mark.parametrize('suppress', [False, True])
+def test_response_diagnostics_are_available_even_when_observer_is_suppressed(setup, suppress):
+ model, _state, _calls, _observer = setup
+ model.generate([{'role': 'user', 'content': 'hello'}], _suppress_attempt_stream=suppress)
+ diagnostics = model.last_response_diagnostics
+ assert diagnostics['content_char_count'] == len('answer')
+ assert diagnostics['reasoning_char_count'] == len('thought')
+ assert diagnostics['content_chunk_count'] == diagnostics['reasoning_chunk_count'] == 1
+ assert diagnostics['input_tokens'] == 10 and diagnostics['output_tokens'] == 0
+ assert diagnostics['finish_reason'] == 'stop'
+ assert 'chunk_count' not in diagnostics
+ assert model.last_retry_count == 1 and model.last_reasoning_preview is None
+
+
+@pytest.mark.parametrize('semantics,input_tokens,cached,available', [
+ ('supported', 10, 3, True),
+ ('supported', 0, 0, True),
+ ('unknown', 10, 3, False),
+ ('unsupported', 10, 3, False),
+ ('supported', None, 3, False),
+ ('supported', 10, None, False),
+ ('supported', 10, 11, False),
+])
+def test_cache_statistics_require_reported_counts_and_declared_semantics(setup, semantics, input_tokens, cached, available):
+ model, state, _calls, _observer = setup
+ values = model.profile.model_dump()
+ values["capabilities"]["usage"]["input_includes_cached"] = semantics
+ model.profile = type(model.profile).model_validate(values)
+ state['usage'] = TokenUsage(input_tokens=input_tokens, cached_input_tokens=cached, origin='provider')
+ model.generate([{'role': 'user', 'content': 'hello'}])
+ assert model.last_cached_input_token_count == cached
+ if available:
+ usage = model.last_prompt_cache_usage
+ assert usage.cached_input_tokens == cached
+ assert usage.uncached_input_tokens == input_tokens - cached
+ assert usage.provider_cache_hit == (cached > 0)
+ assert usage.hit_ratio == (round(cached / input_tokens, 4) if input_tokens else 0.0)
+ assert usage.estimated_saved_input_tokens == 0
+ else:
+ assert model.last_prompt_cache_usage is None
+
+
+def test_failed_next_invocation_never_reuses_previous_response_usage_or_diagnostics(setup):
+ model, state, _calls, _observer = setup
+ model.generate([{'role': 'user', 'content': 'first'}])
+ state['error'] = normalize_model_error(ModelTransportError('HTTP_TLS_ERROR'), request_id='failure', attempt=2, max_attempts=3)
+ with pytest.raises(ModelInvocationTerminalError):
+ model.generate([{'role': 'user', 'content': 'second'}])
+ assert model.last_input_token_count is model.last_output_token_count is model.last_finish_reason is None
+ assert model.last_prompt_cache_usage is model.last_cached_input_token_count is None
+ assert model.last_response_diagnostics['content_char_count'] == 0
+ assert model.last_response_diagnostics['reasoning_char_count'] == 0
diff --git a/test/sdk/core/model_service/test_model_service.py b/test/sdk/core/model_service/test_model_service.py
new file mode 100644
index 0000000000..85fcc64060
--- /dev/null
+++ b/test/sdk/core/model_service/test_model_service.py
@@ -0,0 +1,450 @@
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
+"""Logical call policy with controlled transport failures and real adapter contracts."""
+
+import asyncio
+import json
+import time
+from contextlib import asynccontextmanager, contextmanager
+from types import SimpleNamespace
+
+import pytest
+from nexent.core.concurrency.cancellation import RunCancellationScope, RunTerminated
+from nexent.core.model_service.adapters.openai_chat import OpenAIChatAdapter
+from nexent.core.model_service.concurrency import ModelConcurrencyLimiter
+from nexent.core.model_service.contracts import ModelMessage, ModelRequest
+from nexent.core.model_service.http_contracts import (
+ ModelTransportError,
+ RawHttpResponse,
+)
+from nexent.core.model_service.service import (
+ ModelRequestRetryPolicy,
+ ModelService,
+ ModelServiceError,
+)
+from nexent.core.model_service.sse import SSEEvent
+
+
+def complete():
+ return RawHttpResponse(
+ 200,
+ {},
+ json.dumps(
+ {
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "answer",
+ },
+ "finish_reason": "stop",
+ }
+ ]
+ }
+ ).encode(),
+ )
+
+
+def frame(delta=None, finish=None):
+ return SSEEvent(
+ json.dumps(
+ {"choices": [{"index": 0, "delta": delta or {}, "finish_reason": finish}]}
+ )
+ )
+
+
+class Sender:
+ def __init__(self, outcomes):
+ self.outcomes = iter(outcomes)
+ self.calls = []
+ self.closed = 0
+ self.frozen = 0
+
+ def freeze_credentials(self, request):
+ self.frozen += 1
+ return self
+
+ def invoke(self, request, **kwargs):
+ self.calls.append(request)
+ outcome = next(self.outcomes)
+ if isinstance(outcome, BaseException):
+ raise outcome
+ return outcome
+
+ async def ainvoke(self, request, **kwargs):
+ return self.invoke(request, **kwargs)
+
+ @contextmanager
+ def stream(self, request, **kwargs):
+ def events():
+ for event in self.invoke(request):
+ if isinstance(event, BaseException):
+ raise event
+ yield event
+
+ try:
+ yield events()
+ finally:
+ self.closed += 1
+
+ @asynccontextmanager
+ async def astream(self, request, **kwargs):
+ async def events():
+ for event in self.invoke(request):
+ if isinstance(event, BaseException):
+ raise event
+ yield event
+
+ try:
+ yield events()
+ finally:
+ self.closed += 1
+
+
+@pytest.fixture
+def setup(openai_profile):
+ values = openai_profile.model_dump()
+ values["bindings"]["adapter"] = OpenAIChatAdapter.reference.model_dump()
+ values["connection"]["concurrency_limit"] = 1
+ profile = type(openai_profile).model_validate(values)
+ request = ModelRequest(
+ request_id="logical-request",
+ outlet_id=None,
+ config_revision=1,
+ messages=[
+ ModelMessage(
+ semantic_role="user", content_parts=[{"type": "text", "text": "hello"}]
+ ),
+ ],
+ )
+ limiter = ModelConcurrencyLimiter()
+
+ def service(sender, **kwargs):
+ return ModelService(
+ lambda _: sender,
+ limiter=limiter,
+ retry_policy=SimpleNamespace(
+ max_attempts=3,
+ calculate_backoff=lambda _: 0,
+ ),
+ **kwargs,
+ )
+
+ return profile, request, limiter, service
+
+
+@pytest.mark.asyncio
+async def test_sync_async_retry_preserves_snapshot_and_releases_permits(setup):
+ profile, request, limiter, service = setup
+ for asynchronous in [False, True]:
+ sender = Sender([ModelTransportError("HTTP_NETWORK_ERROR"), complete()])
+ model = service(sender)
+ result = (
+ await model.ainvoke(request, profile)
+ if asynchronous
+ else model.invoke(request, profile)
+ )
+ assert result.content_parts == [{"type": "text", "text": "answer"}]
+ assert len(sender.calls) == 2 and sender.frozen == 1
+ assert sender.calls[0].body_bytes == sender.calls[1].body_bytes
+ assert (
+ sender.calls[0].request_id
+ == sender.calls[1].request_id
+ == request.request_id
+ )
+ assert not limiter._entries
+
+
+@pytest.mark.parametrize("status", [401, 403, 400, 404, 422])
+def test_nonretryable_provider_error_is_safe_and_releases_permit(setup, status):
+ profile, request, limiter, service = setup
+ sender = Sender(
+ [
+ ModelTransportError(
+ "HTTP_STATUS_ERROR",
+ response=RawHttpResponse(
+ status,
+ {},
+ b'{"error":{"message":"private-key-123"}}',
+ ),
+ )
+ ]
+ )
+ with pytest.raises(ModelServiceError) as caught:
+ service(sender).invoke(request, profile)
+ assert caught.value.error.attempt == 1 and not caught.value.error.will_retry
+ assert "private-key" not in str(caught.value) + caught.value.error.model_dump_json()
+ assert len(sender.calls) == 1 and not limiter._entries
+
+
+def test_default_retry_count_is_three_and_exhaustion_is_explicit(setup):
+ profile, request, limiter, _ = setup
+ sender = Sender([ModelTransportError("HTTP_NETWORK_ERROR") for _ in range(3)])
+ policy = SimpleNamespace(
+ max_attempts=ModelRequestRetryPolicy().max_attempts,
+ calculate_backoff=lambda _: 0,
+ )
+ with pytest.raises(ModelServiceError) as caught:
+ ModelService(lambda _: sender, limiter=limiter, retry_policy=policy).invoke(
+ request, profile
+ )
+ assert len(sender.calls) == caught.value.error.attempt == 3
+ assert caught.value.error.retryable and not caught.value.error.will_retry
+ assert not limiter._entries
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "delta",
+ [
+ {"content": "visible"},
+ {"reasoning_content": "thinking"},
+ {
+ "tool_calls": [
+ {
+ "index": 0,
+ "id": "call-one",
+ "function": {"name": "tool", "arguments": "{"},
+ }
+ ],
+ },
+ ],
+)
+async def test_any_visible_fragment_prevents_sync_async_stream_replay(setup, delta):
+ profile, request, limiter, service = setup
+ request = request.model_copy(update={"stream": True})
+ for asynchronous in [False, True]:
+ sender = Sender([[frame(delta), ModelTransportError("HTTP_NETWORK_ERROR")]])
+ if asynchronous:
+ async with service(sender).astream(request, profile) as events:
+ result = [event async for event in events]
+ else:
+ with service(sender).stream(request, profile) as events:
+ result = list(events)
+ assert result[-1].type == "error" and not result[-1].error.will_retry
+ assert len(sender.calls) == 1 and sender.closed == 1
+ assert [event.sequence for event in result] == list(range(1, len(result) + 1))
+ assert not limiter._entries
+
+
+@pytest.mark.asyncio
+async def test_pre_output_stream_retry_then_completion_and_early_exit(setup):
+ profile, request, limiter, service = setup
+ request = request.model_copy(update={"stream": True})
+ for asynchronous in [False, True]:
+ sender = Sender(
+ [
+ ModelTransportError("HTTP_NETWORK_ERROR"),
+ [
+ frame({"content": "answer"}),
+ frame(finish="stop"),
+ SSEEvent("[DONE]"),
+ ],
+ ]
+ )
+ if asynchronous:
+ async with service(sender).astream(request, profile) as events:
+ result = [event async for event in events]
+ else:
+ with service(sender).stream(request, profile) as events:
+ result = list(events)
+ assert [event.type for event in result] == ["text_delta", "message_completed"]
+ assert [event.attempt for event in result] == [2, 2]
+ assert len(sender.calls) == sender.closed == 2 and not limiter._entries
+ sender = Sender(
+ [[frame({"content": "first"}), frame(finish="stop"), SSEEvent("[DONE]")]]
+ )
+ async with service(sender).astream(request, profile) as events:
+ assert (await anext(events)).type == "text_delta"
+ assert sender.closed == 1 and not limiter._entries
+ with pytest.raises(StopAsyncIteration):
+ await anext(events)
+
+
+@pytest.mark.asyncio
+async def test_cancelled_run_never_starts_or_retries(setup):
+ profile, request, limiter, service = setup
+ sender = Sender([])
+ scope = RunCancellationScope()
+ scope.cancel()
+ with pytest.raises(RunTerminated):
+ await service(sender).ainvoke(request, profile, scope=scope)
+ assert not sender.calls and not sender.frozen and not limiter._entries
+
+
+def test_retry_after_exceeding_run_budget_is_not_retried(setup):
+ profile, request, limiter, service = setup
+ sender = Sender(
+ [
+ ModelTransportError(
+ "HTTP_STATUS_ERROR",
+ response=RawHttpResponse(
+ 429,
+ {"Retry-After": "30"},
+ b"{}",
+ ),
+ )
+ ]
+ )
+ with pytest.raises(ModelServiceError) as caught:
+ service(sender).invoke(request, profile, deadline=time.monotonic() + 5)
+ assert (
+ caught.value.error.category == "MODEL_RATE_LIMITED"
+ and not caught.value.error.will_retry
+ )
+ assert len(sender.calls) == 1 and not limiter._entries
+
+
+@pytest.mark.asyncio
+async def test_error_before_first_model_event_has_valid_identity(setup):
+ profile, request, limiter, service = setup
+ request = request.model_copy(update={"stream": True})
+ for asynchronous in [False, True]:
+ sender = Sender([[SSEEvent("invalid-json")]])
+ if asynchronous:
+ async with service(sender).astream(request, profile) as events:
+ result = [event async for event in events]
+ else:
+ with service(sender).stream(request, profile) as events:
+ result = list(events)
+ assert len(result) == 1 and result[0].type == "error"
+ assert result[0].error.category == "MODEL_PROTOCOL_ERROR"
+ assert result[0].sequence == 1 and not limiter._entries
+
+
+@pytest.mark.asyncio
+async def test_async_task_cancellation_releases_admission(setup):
+ profile, request, limiter, service = setup
+ sender = Sender([])
+ entered = asyncio.Event()
+
+ async def blocked(*args, **kwargs):
+ entered.set()
+ await asyncio.Event().wait()
+
+ sender.ainvoke = blocked
+ task = asyncio.create_task(service(sender).ainvoke(request, profile))
+ await entered.wait()
+ task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await task
+ assert not limiter._entries
+
+
+@pytest.mark.asyncio
+async def test_async_run_deadline_interrupts_blocked_call_without_retry(setup, caplog):
+ profile, request, limiter, service = setup
+ sender = Sender([])
+ cancelled = asyncio.Event()
+
+ async def blocked(*args, **kwargs):
+ try:
+ await asyncio.Event().wait()
+ finally:
+ cancelled.set()
+
+ sender.ainvoke = blocked
+ with pytest.raises(ModelServiceError) as caught:
+ await service(sender).ainvoke(
+ request, profile, deadline=time.monotonic() + 0.05
+ )
+ assert caught.value.error.category == "MODEL_RUN_DEADLINE"
+ assert caught.value.error.attempt == 1 and not caught.value.error.will_retry
+ assert cancelled.is_set() and not limiter._entries
+ assert [record.message for record in caplog.records] == ["model_request_timeout"]
+ assert caplog.records[0].levelname == "WARNING"
+
+
+def test_stream_uses_snapshot_detached_at_context_entry(setup):
+ profile, request, limiter, service = setup
+ request = request.model_copy(update={"stream": True})
+ sender = Sender(
+ [[frame({"content": "answer"}), frame(finish="stop"), SSEEvent("[DONE]")]]
+ )
+ with service(sender).stream(request, profile) as events:
+ profile.parameter_values["enable_thinking"] = True
+ request.messages[0].content_parts[0]["text"] = "changed"
+ assert list(events)[-1].type == "message_completed"
+ body = json.loads(sender.calls[0].body_bytes)
+ assert body["enable_thinking"] is False
+ assert body["messages"][0]["content"] == "hello"
+ assert not limiter._entries
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('asynchronous', [False, True])
+@pytest.mark.parametrize('policy,expected_calls,terminal', [
+ ('accept', 1, 'message_completed'),
+ ('fail', 1, 'error'),
+ ('retry', 3, 'error'),
+])
+async def test_empty_response_policy_shares_physical_retry_budget(setup, asynchronous, policy, expected_calls, terminal):
+ profile, request, limiter, factory = setup
+ request = request.model_copy(update={'stream': True})
+ # A network failure and two empty completions consume the same three attempts.
+ empty = [frame(finish='stop'), SSEEvent('[DONE]')]
+ outcomes = [ModelTransportError('HTTP_NETWORK_ERROR'), empty, empty] if policy == 'retry' else [empty]
+ sender = Sender(outcomes)
+ model = factory(sender)
+ if asynchronous:
+ async with model.astream(request, profile, empty_response_policy=policy) as stream:
+ events = [event async for event in stream]
+ else:
+ with model.stream(request, profile, empty_response_policy=policy) as stream:
+ events = list(stream)
+ assert events[-1].type == terminal
+ if terminal == 'error':
+ assert events[-1].error.category == 'MODEL_EMPTY_RESPONSE'
+ assert events[-1].error.attempt == expected_calls
+ assert not events[-1].error.will_retry
+ assert len(sender.calls) == expected_calls and sender.frozen == 1
+ assert all(call.body_bytes == sender.calls[0].body_bytes for call in sender.calls)
+ assert not limiter._entries
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('asynchronous', [False, True])
+@pytest.mark.parametrize('delta,finish,terminal', [
+ ({'reasoning_content': 'thought'}, 'stop', 'error'),
+ ({'content': ' '}, 'stop', 'error'),
+ ({}, 'length', 'error'),
+ ({}, None, 'error'),
+ ({'refusal': 'cannot comply'}, 'stop', 'message_completed'),
+ ({'content': 'answer'}, 'stop', 'message_completed'),
+])
+async def test_empty_policy_never_replays_visible_output_or_nonstop_finish(setup, asynchronous, delta, finish, terminal):
+ profile, request, limiter, factory = setup
+ request = request.model_copy(update={'stream': True})
+ sender = Sender([[frame(delta), frame(finish=finish), SSEEvent('[DONE]')]])
+ model = factory(sender)
+ if asynchronous:
+ async with model.astream(request, profile, empty_response_policy='retry') as stream:
+ events = [event async for event in stream]
+ else:
+ with model.stream(request, profile, empty_response_policy='retry') as stream:
+ events = list(stream)
+ assert events[-1].type == terminal
+ assert len(sender.calls) == 1 and not limiter._entries
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("asynchronous", [False, True])
+@pytest.mark.parametrize("max_attempts", [1, 3, 5])
+async def test_empty_stop_has_only_one_retry_within_total_budget(setup, asynchronous, max_attempts):
+ profile, request, limiter, _factory = setup
+ request = request.model_copy(update={"stream": True})
+ sender = Sender([[frame(finish="stop"), SSEEvent("[DONE]")]] * max_attempts)
+ model = ModelService(lambda _: sender, limiter=limiter, retry_policy=SimpleNamespace(
+ max_attempts=max_attempts, calculate_backoff=lambda _: 0,
+ ))
+ if asynchronous:
+ async with model.astream(request, profile, empty_response_policy="retry") as stream:
+ events = [event async for event in stream]
+ else:
+ with model.stream(request, profile, empty_response_policy="retry") as stream:
+ events = list(stream)
+ assert len(sender.calls) == min(2, max_attempts)
+ assert events[-1].error.category == "MODEL_EMPTY_RESPONSE"
+ assert not events[-1].error.will_retry
+ assert not limiter._entries
diff --git a/test/sdk/core/model_service/test_model_service_observation.py b/test/sdk/core/model_service/test_model_service_observation.py
new file mode 100644
index 0000000000..f1799f78c2
--- /dev/null
+++ b/test/sdk/core/model_service/test_model_service_observation.py
@@ -0,0 +1,131 @@
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
+"""One service-owned trace across actual sync/async adapter calls and retries."""
+
+import json
+from contextlib import contextmanager
+from unittest.mock import Mock
+
+import pytest
+from nexent.core.model_errors import ModelErrorCode, ModelInvocationTerminalError
+from nexent.core.model_service.compatibility.legacy_chat_model import LegacyChatModel
+from nexent.core.model_service.http_contracts import (
+ ModelTransportError,
+ RawHttpResponse,
+)
+from nexent.core.model_service.service import ModelServiceError
+from nexent.core.model_service.sse import SSEEvent
+from test_model_service import Sender, frame
+from test_model_service import setup as setup # noqa: PLC0414 - Shared pytest fixture.
+
+
+@pytest.fixture
+def monitoring():
+ manager = Mock()
+ calls, active = [], []
+
+ @contextmanager
+ def trace(*args, **kwargs):
+ calls.append((args, kwargs))
+ active.append(True)
+ try:
+ yield 'span'
+ finally:
+ active.clear()
+
+ manager.trace_llm_request = trace
+ return manager, calls, active
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('asynchronous', [False, True])
+@pytest.mark.parametrize('streaming', [False, True])
+@pytest.mark.parametrize('usage', [None, {'prompt_tokens': 10}, {'prompt_tokens': 10, 'completion_tokens': 0}])
+async def test_usage_unknown_partial_and_zero_remain_distinct(setup, monitoring, asynchronous, streaming, usage):
+ profile, request, _limiter, factory = setup
+ manager, calls, active = monitoring
+ request = request.model_copy(update={'stream': streaming})
+ if streaming:
+ response = [frame({'content': 'answer'}), frame(finish='stop')]
+ if usage is not None:
+ response.append(SSEEvent(json.dumps({'choices': [], 'usage': usage})))
+ response.append(SSEEvent('[DONE]'))
+ else:
+ response = RawHttpResponse(200, {}, json.dumps({
+ 'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': 'answer'}, 'finish_reason': 'stop'}],
+ **({'usage': usage} if usage is not None else {}),
+ }).encode())
+ sender = Sender([response])
+ service = factory(sender, monitoring=manager)
+ if streaming:
+ if asynchronous:
+ async with service.astream(request, profile) as events:
+ assert active
+ result = [event async for event in events][-1].message
+ else:
+ with service.stream(request, profile) as events:
+ assert active
+ result = list(events)[-1].message
+ else:
+ result = await service.ainvoke(request, profile) if asynchronous else service.invoke(request, profile)
+ assert len(calls) == 1 and not active
+ assert calls[0][1]['model.request_id'] == request.request_id
+ assert 'base_url' not in repr(calls) and 'credential' not in repr(calls)
+ tracker = manager.create_token_tracker.return_value
+ assert tracker.record_first_token.call_count == int(streaming)
+ if usage is not None and 'completion_tokens' in usage:
+ tracker.record_completion.assert_called_once_with(10, 0, total_tokens=None)
+ else:
+ tracker.record_completion.assert_not_called()
+ tracker.record_token.assert_not_called()
+ assert result.usage.total_tokens is None
+ manager.set_openinference_output.assert_called_once_with('answer')
+
+
+def test_retry_and_early_close_share_trace_and_release_observation(setup, monitoring):
+ profile, request, limiter, factory = setup
+ manager, calls, active = monitoring
+ request = request.model_copy(update={'stream': True})
+ sender = Sender([ModelTransportError('HTTP_NETWORK_ERROR'), [frame({'content': 'partial'})]])
+ service = factory(sender, monitoring=manager)
+ with service.stream(request, profile) as events:
+ assert next(events).delta == 'partial' and active
+ assert len(calls) == 1 and not active and not limiter._entries
+ begins = [call.args for call in manager.add_span_event.call_args_list if call.args[0] == 'model_attempt_begin']
+ assert [args[1]['attempt'] for args in begins] == [1, 2]
+ manager.create_token_tracker.return_value.record_completion.assert_not_called()
+ assert sender.closed == 2
+
+
+def test_terminal_error_closes_trace_without_provider_detail(setup, monitoring):
+ profile, request, _limiter, factory = setup
+ manager, calls, active = monitoring
+ sender = Sender([ModelTransportError('HTTP_TLS_ERROR')])
+ with pytest.raises(ModelServiceError):
+ factory(sender, monitoring=manager).invoke(request, profile)
+ assert len(calls) == 1 and not active
+ manager.create_token_tracker.return_value.record_completion.assert_not_called()
+ assert manager.add_span_event.call_args.args[1]['category'] == 'MODEL_TLS_ERROR'
+
+
+def test_empty_length_retains_usage_event_and_legacy_diagnostics(setup, monitoring):
+ profile, _request, limiter, factory = setup
+ manager, _calls, active = monitoring
+ sender = Sender([[
+ frame({'reasoning_content': 'thought'}), frame(finish='length'),
+ SSEEvent(json.dumps({'choices': [], 'usage': {'prompt_tokens': 10, 'completion_tokens': 20}})),
+ SSEEvent('[DONE]'),
+ ]])
+ model = LegacyChatModel(profile, factory(sender, monitoring=manager))
+ with pytest.raises(ModelInvocationTerminalError) as caught:
+ model.generate([{'role': 'user', 'content': 'hello'}])
+ assert caught.value.error_code == ModelErrorCode.EMPTY_RESPONSE_EXHAUSTED
+ assert model.last_finish_reason == 'length'
+ diagnostics = model.last_response_diagnostics
+ assert diagnostics['reasoning_only_budget_exhausted']
+ assert diagnostics['reasoning_char_count'] == len('thought')
+ assert diagnostics['input_tokens'] == 10 and diagnostics['output_tokens'] == 20
+ assert len(sender.calls) == 1 and not limiter._entries and not active
+ usages = [call.args[1] for call in manager.add_span_event.call_args_list if call.args[0] == 'model_usage']
+ assert usages[0]['input_tokens'] == 10 and usages[0]['attempt'] == 1
+ manager.create_token_tracker.return_value.record_completion.assert_not_called()
diff --git a/test/sdk/core/model_service/test_outlet_binding.py b/test/sdk/core/model_service/test_outlet_binding.py
new file mode 100644
index 0000000000..ab15519244
--- /dev/null
+++ b/test/sdk/core/model_service/test_outlet_binding.py
@@ -0,0 +1,91 @@
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
+"""ModelID selects one tenant-scoped snapshot without network or shared run state."""
+
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+import pytest
+from nexent.core.model_service.service import ModelService
+
+
+@pytest.fixture
+def saved_profile(openai_profile):
+ data = openai_profile.model_dump()
+ data["outlet_id"] = 42
+ data["connection"]["credential_ref"]["model_id"] = 42
+ return openai_profile.__class__.model_validate(data)
+
+
+def test_selection_reads_once_detaches_snapshot_and_does_not_resolve_credentials(saved_profile):
+ reader = Mock(return_value=saved_profile)
+ sender = Mock(side_effect=AssertionError("Selection must not send HTTP or resolve credentials"))
+ service = ModelService(sender, outlet_reader=reader)
+ context = SimpleNamespace(tenant_id=saved_profile.identity.tenant_id)
+ selected = service.bind_outlet(42, context)
+ reader.assert_called_once_with(42, context)
+ sender.assert_not_called()
+ assert selected == saved_profile and selected is not saved_profile
+ saved_profile.provenance.provider_metadata["later"] = "updated"
+ assert "later" not in selected.provenance.provider_metadata
+
+
+@pytest.mark.parametrize("model_id", [True, None, 0, -1, 2**31, "42"])
+def test_invalid_ids_fail_before_reader_or_sender(model_id):
+ reader, sender = Mock(), Mock()
+ service = ModelService(sender, outlet_reader=reader)
+ with pytest.raises(ValueError, match="MODEL_OUTLET_SELECTION_INVALID"):
+ service.bind_outlet(model_id, SimpleNamespace(tenant_id="tenant"))
+ reader.assert_not_called()
+ sender.assert_not_called()
+
+
+@pytest.mark.parametrize("tenant", [None, "", " "])
+def test_missing_tenant_does_not_use_global_default(tenant):
+ reader = Mock()
+ with pytest.raises(ValueError, match="SELECTION_INVALID"):
+ ModelService(Mock(), outlet_reader=reader).bind_outlet(42, SimpleNamespace(tenant_id=tenant))
+ reader.assert_not_called()
+
+
+@pytest.mark.parametrize("wrong", ["tenant", "id"])
+def test_other_tenant_or_outlet_cannot_be_bound(saved_profile, wrong):
+ context = SimpleNamespace(tenant_id="other" if wrong == "tenant" else saved_profile.identity.tenant_id)
+ sender = Mock()
+ with pytest.raises(ValueError, match="SNAPSHOT_MISMATCH"):
+ ModelService(sender, outlet_reader=lambda *_: saved_profile).bind_outlet(43 if wrong == "id" else 42, context)
+ sender.assert_not_called()
+
+
+def test_missing_reader_missing_row_and_reader_error_are_explicit_and_safe():
+ context = SimpleNamespace(tenant_id="tenant")
+ with pytest.raises(ValueError, match="READER_UNCONFIGURED"):
+ ModelService(Mock()).bind_outlet(42, context)
+ with pytest.raises(ValueError, match="NOT_FOUND"):
+ ModelService(Mock(), outlet_reader=lambda *_: None).bind_outlet(42, context)
+ reader = Mock(side_effect=RuntimeError("sensitive database detail"))
+ with pytest.raises(ValueError, match="RESOLUTION_FAILED") as caught:
+ ModelService(Mock(), outlet_reader=reader).bind_outlet(42, context)
+ assert "sensitive" not in str(caught.value) and caught.value.__suppress_context__
+ with pytest.raises(ValueError, match="SNAPSHOT_INVALID"):
+ ModelService(Mock(), outlet_reader=lambda *_: {}).bind_outlet(42, context)
+
+
+def test_async_reader_requires_backend_preparation():
+ async def reader(*_):
+ return None
+ with pytest.raises(TypeError, match="synchronous"):
+ ModelService(Mock(), outlet_reader=reader)
+
+
+def test_one_service_does_not_overwrite_another_runs_selection(saved_profile):
+ data = saved_profile.model_dump()
+ data["outlet_id"] = data["connection"]["credential_ref"]["model_id"] = 43
+ data["identity"]["tenant_id"] = "other"
+ other = saved_profile.__class__.model_validate(data)
+ rows = {42: saved_profile, 43: other}
+ service = ModelService(Mock(), outlet_reader=lambda model_id, _ctx: rows[model_id])
+ first = service.bind_outlet(42, SimpleNamespace(tenant_id="tenant"))
+ second = service.bind_outlet(43, SimpleNamespace(tenant_id="other"))
+ assert first.outlet_id == 42 and first.identity.tenant_id == "tenant"
+ assert second.outlet_id == 43 and second.identity.tenant_id == "other"
diff --git a/test/sdk/core/models/test_model_concurrency.py b/test/sdk/core/models/test_model_concurrency.py
index 2c4a3971cc..3deb602fbe 100644
--- a/test/sdk/core/models/test_model_concurrency.py
+++ b/test/sdk/core/models/test_model_concurrency.py
@@ -1,13 +1,23 @@
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
import threading
import pytest
-from nexent.core.models.model_concurrency import (
+from nexent.core.model_service.concurrency import (
ModelConcurrencyExceeded,
ModelConcurrencyLimiter,
)
from nexent.core.models.retry import get_retry_after_seconds
+def test_legacy_model_and_model_service_share_one_global_limiter():
+ from nexent.core.model_service import concurrency, service
+ from nexent.core.models import openai_llm
+
+ assert service.model_concurrency_limiter is concurrency.model_concurrency_limiter
+ assert openai_llm.model_concurrency_limiter is concurrency.model_concurrency_limiter
+
+
def test_ut_sdk_tlm_028_same_model_key_has_hard_concurrency_limit():
limiter = ModelConcurrencyLimiter()
stop_event = threading.Event()
diff --git a/test/sdk/core/models/test_model_concurrency_async.py b/test/sdk/core/models/test_model_concurrency_async.py
new file mode 100644
index 0000000000..9069de94bb
--- /dev/null
+++ b/test/sdk/core/models/test_model_concurrency_async.py
@@ -0,0 +1,78 @@
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
+"""Async admission shares legacy permits without blocking or spawning workers."""
+
+import asyncio
+import threading
+
+import pytest
+from nexent.core.model_service.concurrency import (
+ ModelConcurrencyExceeded,
+ ModelConcurrencyLimiter,
+)
+
+
+@pytest.mark.asyncio
+async def test_sync_holder_blocks_async_admission_but_not_the_event_loop():
+ limiter = ModelConcurrencyLimiter()
+ stop = threading.Event()
+ key = ("tenant", "provider", "model")
+ held = limiter.acquire(key, 1, 1, stop)
+ pending = asyncio.create_task(limiter.acquire_async(key, 1, 1, stop))
+ await asyncio.sleep(0)
+ assert not pending.done()
+ other = await limiter.acquire_async(("other", "provider", "model"), 1, 1, stop)
+ other.release()
+ held.release()
+ acquired = await asyncio.wait_for(pending, 1)
+ with pytest.raises(ModelConcurrencyExceeded):
+ limiter.acquire(key, 1, 0.001, stop)
+ acquired.release()
+ acquired.release()
+ assert limiter._entries == {}
+
+
+@pytest.mark.asyncio
+async def test_async_timeout_and_task_cancel_release_waiter_references():
+ limiter = ModelConcurrencyLimiter()
+ stop = threading.Event()
+ key = ("tenant", "provider", "model")
+ held = limiter.acquire(key, 1, 1, stop)
+ with pytest.raises(ModelConcurrencyExceeded):
+ await limiter.acquire_async(key, 1, 0.001, stop)
+ assert limiter._entries[key].references == 1
+ waiting = asyncio.create_task(limiter.acquire_async(key, 1, 1, stop))
+ await asyncio.sleep(0)
+ waiting.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await waiting
+ assert limiter._entries[key].references == 1
+ held.release()
+ assert limiter._entries == {}
+
+
+@pytest.mark.asyncio
+async def test_stop_event_and_conflicting_limit_never_acquire_another_permit():
+ limiter = ModelConcurrencyLimiter()
+ stop = threading.Event()
+ key = ("tenant", "provider", "model")
+ held = limiter.acquire(key, 1, 1, stop)
+ with pytest.raises(ValueError, match="conflicting concurrency limits"):
+ await limiter.acquire_async(key, 2, 1, stop)
+ stop.set()
+ with pytest.raises(RuntimeError, match="cancelled"):
+ await limiter.acquire_async(key, 1, 1, stop)
+ assert limiter._entries[key].references == 1
+ held.release()
+ assert limiter._entries == {}
+
+
+@pytest.mark.parametrize("limit,timeout", [(0, 1), (1, 0)])
+@pytest.mark.asyncio
+async def test_invalid_async_limits_fail_before_retaining_entries(limit, timeout):
+ limiter = ModelConcurrencyLimiter()
+ with pytest.raises(ValueError):
+ await limiter.acquire_async(
+ ("tenant", "provider", "model"), limit, timeout, threading.Event()
+ )
+ assert limiter._entries == {}
diff --git a/test/sdk/core/models/test_openai_long_context_model.py b/test/sdk/core/models/test_openai_long_context_model.py
index 6cb812bd45..35afee61ad 100644
--- a/test/sdk/core/models/test_openai_long_context_model.py
+++ b/test/sdk/core/models/test_openai_long_context_model.py
@@ -1,7 +1,11 @@
-import pytest
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
+import logging
import sys
from unittest.mock import MagicMock, patch
+import pytest
+
mock_smolagents = MagicMock()
mock_models_module = MagicMock()
@@ -116,7 +120,7 @@ def test_truncate_text_no_truncation_needed(long_context_model):
assert long_context_model.truncate_text("short text", 20) == "short text"
-@patch.object(openai_long_context_model.logging, "getLogger")
+@patch.object(logging, "getLogger")
def test_truncate_text_start_strategy_with_tiktoken(mock_logger, long_context_model, mock_tokenizer):
with patch.object(long_context_model, '_get_tokenizer', return_value=mock_tokenizer):
long_context_model.count_tokens = MagicMock(return_value=100)
@@ -124,7 +128,7 @@ def test_truncate_text_start_strategy_with_tiktoken(mock_logger, long_context_mo
assert long_context_model.truncate_text("long text", 5) == "decoded text"
-@patch.object(openai_long_context_model.logging, "getLogger")
+@patch.object(logging, "getLogger")
def test_truncate_text_middle_strategy_with_tiktoken(mock_logger, long_context_model, mock_tokenizer):
with patch.object(long_context_model, '_get_tokenizer', return_value=mock_tokenizer):
long_context_model.truncation_strategy = "middle"
@@ -134,7 +138,7 @@ def test_truncate_text_middle_strategy_with_tiktoken(mock_logger, long_context_m
mock_tokenizer.decode.assert_called_once_with([1, 2, 3, 8, 9, 10])
-@patch.object(openai_long_context_model.logging, "getLogger")
+@patch.object(logging, "getLogger")
def test_truncate_text_end_strategy_with_tiktoken(mock_logger, long_context_model, mock_tokenizer):
with patch.object(long_context_model, '_get_tokenizer', return_value=mock_tokenizer):
long_context_model.truncation_strategy = "end"
@@ -144,7 +148,7 @@ def test_truncate_text_end_strategy_with_tiktoken(mock_logger, long_context_mode
mock_tokenizer.decode.assert_called_once_with([6, 7, 8, 9, 10])
-@patch.object(openai_long_context_model.logging, "getLogger")
+@patch.object(logging, "getLogger")
def test_truncate_text_without_tiktoken_start_strategy(mock_logger, long_context_model):
with patch.object(long_context_model, '_get_tokenizer', return_value=None):
long_context_model.count_tokens = MagicMock(return_value=100)
@@ -152,7 +156,7 @@ def test_truncate_text_without_tiktoken_start_strategy(mock_logger, long_context
assert result == "x" * 40
-@patch.object(openai_long_context_model.logging, "getLogger")
+@patch.object(logging, "getLogger")
def test_truncate_text_without_tiktoken_middle_strategy(mock_logger, long_context_model):
with patch.object(long_context_model, '_get_tokenizer', return_value=None):
long_context_model.truncation_strategy = "middle"
@@ -161,7 +165,7 @@ def test_truncate_text_without_tiktoken_middle_strategy(mock_logger, long_contex
assert "[Content truncated...]" in result
-@patch.object(openai_long_context_model.logging, "getLogger")
+@patch.object(logging, "getLogger")
def test_truncate_text_without_tiktoken_end_strategy(mock_logger, long_context_model):
with patch.object(long_context_model, '_get_tokenizer', return_value=None):
long_context_model.count_tokens = MagicMock(return_value=100)
diff --git a/test/sdk/monitor/test_reported_token_usage.py b/test/sdk/monitor/test_reported_token_usage.py
new file mode 100644
index 0000000000..0d1e415f18
--- /dev/null
+++ b/test/sdk/monitor/test_reported_token_usage.py
@@ -0,0 +1,32 @@
+# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.
+
+"""Provider-reported totals remain distinct from historical inferred totals."""
+
+import json
+from unittest.mock import Mock
+
+import pytest
+from nexent.monitor.monitoring import LLMTokenTracker
+
+
+@pytest.mark.parametrize('total', [None, 0, 7])
+def test_explicit_provider_total_does_not_infer_or_replace_missing_values(total):
+ manager, span = Mock(), Mock()
+ manager.is_enabled = True
+ tracker = LLMTokenTracker(manager, 'controlled', span)
+ tracker.record_completion(10, 0, total_tokens=total)
+ attributes = span.set_attributes.call_args.args[0]
+ details = json.loads(attributes['llm.usage_details'])
+ assert attributes['llm.token_count.prompt'] == 10
+ assert attributes['llm.token_count.completion'] == 0
+ if total is None:
+ assert 'llm.token_count.total' not in attributes and 'total' not in details
+ else:
+ assert attributes['llm.token_count.total'] == details['total'] == total
+
+
+def test_existing_callers_keep_their_total_count_contract():
+ manager, span = Mock(), Mock()
+ manager.is_enabled = True
+ LLMTokenTracker(manager, 'controlled', span).record_completion(10, 5)
+ assert span.set_attributes.call_args.args[0]['llm.token_count.total'] == 15