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