diff --git a/backend/consts/exceptions.py b/backend/consts/exceptions.py index 7f5aba2741..f289b848de 100644 --- a/backend/consts/exceptions.py +++ b/backend/consts/exceptions.py @@ -531,6 +531,27 @@ class RuntimeCapacityExceededError(RuntimeError): retry_after_seconds = 1 +class ModelOutletConflictError(ValueError): + """An absent, stale or unavailable outlet cannot be overwritten.""" + + def __init__(self): + super().__init__("MODEL_OUTLET_VERSION_CONFLICT") + + +class ModelOutletValidationError(ValueError): + """Invalid storage input without parameter or credential details.""" + + def __init__(self): + super().__init__("MODEL_OUTLET_INVALID") + + +class ModelOutletDatabaseError(RuntimeError): + """Safe database diagnostic excluding SQL bound values.""" + + def __init__(self): + super().__init__("MODEL_OUTLET_DATABASE_ERROR") + + class RuntimeQueueTimeoutError(RuntimeError): """Raised when an accepted Runtime agent request expires in the queue.""" diff --git a/backend/database/db_models.py b/backend/database/db_models.py index 314e31d4ca..e9a5f77a5c 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -483,6 +483,24 @@ class ModelRecord(TableBase): extra_params = Column( JSONB, doc="Fixed inference params without dedicated columns (key-value pairs constrained by FIXED_INFERENCE_FIELDS_BY_TYPE). NULL means no extra params.") + # Schema extension only: NULL capability values remain unknown until outlet migration. + reasoning_supported = Column(Boolean, comment="Reasoning support: true supported, false unsupported, NULL unknown.") + reasoning_controls = Column(JSONB, comment="Declared reasoning control domains; NULL unknown, empty array no controls.") + native_tool_call_supported = Column(Boolean, comment="Native tool call support; NULL unknown, not an execution guarantee.") + structured_output_supported = Column(Boolean, comment="Structured output support; NULL unknown, formats require binding.") + temperature_supported = Column(Boolean, comment="Temperature control support; NULL unknown, separate from parameter value.") + top_p_supported = Column(Boolean, comment="Top-p control support; NULL unknown, separate from parameter value.") + input_modalities = Column(JSONB, comment="Unique supported input modality names; NULL unknown, empty array no modalities.") + output_modalities = Column(JSONB, comment="Unique supported output modality names; NULL unknown, empty array no modalities.") + enable_thinking = Column(Boolean, comment="Saved thinking switch; NULL omitted unless marked as explicit JSON null.") + reasoning_effort = Column(String(30), comment="Saved reasoning effort; NULL omitted unless marked as explicit JSON null.") + reasoning_budget_tokens = Column(Integer, comment="Saved reasoning token budget; sentinel values require explicit declaration.") + protocol_id = Column(String(30), comment="Registered model protocol; NULL only before resolved outlet migration completes.") + resolved_profile = Column(JSONB, comment="Remaining resolved rules, bindings and provenance; no copies of dedicated column values.") + profile_schema_version = Column(Integer, comment="Persisted resolved profile schema version; NULL for pre-migration rows.") + config_revision = Column(Integer, nullable=False, default=1, server_default=text("1"), + comment="Outlet configuration revision for optimistic updates; initial value 1.") + class ModelMonitoringRecord(SimpleTableBase): """ diff --git a/backend/database/model_management_db.py b/backend/database/model_management_db.py index 11487575ec..9f1829ac41 100644 --- a/backend/database/model_management_db.py +++ b/backend/database/model_management_db.py @@ -12,6 +12,24 @@ logger = logging.getLogger("database.model_management_db") +def create_resolved_model_record(*args, **kwargs): + """Create a resolved outlet through the transactional storage implementation.""" + from .model_outlet_db import create_resolved_model_record as create_resolved + return create_resolved(*args, **kwargs) + + +def get_resolved_model_record(*args, **kwargs): + """Load a tenant-owned resolved outlet without applying legacy parameter filters.""" + from .model_outlet_db import get_resolved_model_record as get_resolved + return get_resolved(*args, **kwargs) + + +def update_resolved_model_record(*args, **kwargs): + """Update a resolved outlet with an expected configuration revision.""" + from .model_outlet_db import update_resolved_model_record as update_resolved + return update_resolved(*args, **kwargs) + + def create_model_record(model_data: Dict[str, Any], user_id: str, tenant_id: str) -> bool: """ Create a model record diff --git a/backend/database/model_outlet_db.py b/backend/database/model_outlet_db.py new file mode 100644 index 0000000000..c5e402082b --- /dev/null +++ b/backend/database/model_outlet_db.py @@ -0,0 +1,271 @@ +"""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): + if ( + not isinstance(value, str) + or not value.strip() + or len(value) > maximum + or "\x00" in value + ): + raise ModelOutletValidationError() + try: + value.encode("utf-8") + except UnicodeError: + raise ModelOutletValidationError() from None + + +def _credential(value): + if value is None: + return + if not isinstance(value, str) or len(value) > 500 or "\x00" in value: + raise ModelOutletValidationError() + try: + value.encode("utf-8") + except UnicodeError: + raise ModelOutletValidationError() from None + + +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, + 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 ( + 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, + 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): + 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(current, 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 = ( + current.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(current.parameter_rules) + validate_saved_parameter_values(compiled, proposed.parameter_values) + rules = {rule["key"]: rule for rule in compiled.declarations} + 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] + ) + ) + if not unchanged and ( + not rules[key]["editable"] or "outlet" not in rules[key]["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) + 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 + 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 diff --git a/deploy/sql/migrations/v2.7.1_002_model_outlet_profile_columns.sql b/deploy/sql/migrations/v2.7.1_002_model_outlet_profile_columns.sql new file mode 100644 index 0000000000..abd022edfc --- /dev/null +++ b/deploy/sql/migrations/v2.7.1_002_model_outlet_profile_columns.sql @@ -0,0 +1,34 @@ +-- Add resolved outlet storage without inferring legacy model capabilities. +-- Existing configuration values remain in their original columns. +ALTER TABLE nexent.model_record_t + ADD COLUMN IF NOT EXISTS reasoning_supported BOOLEAN, + ADD COLUMN IF NOT EXISTS reasoning_controls JSONB, + ADD COLUMN IF NOT EXISTS native_tool_call_supported BOOLEAN, + ADD COLUMN IF NOT EXISTS structured_output_supported BOOLEAN, + ADD COLUMN IF NOT EXISTS temperature_supported BOOLEAN, + ADD COLUMN IF NOT EXISTS top_p_supported BOOLEAN, + ADD COLUMN IF NOT EXISTS input_modalities JSONB, + ADD COLUMN IF NOT EXISTS output_modalities JSONB, + ADD COLUMN IF NOT EXISTS enable_thinking BOOLEAN, + ADD COLUMN IF NOT EXISTS reasoning_effort VARCHAR(30), + ADD COLUMN IF NOT EXISTS reasoning_budget_tokens INTEGER, + ADD COLUMN IF NOT EXISTS protocol_id VARCHAR(30), + ADD COLUMN IF NOT EXISTS resolved_profile JSONB, + ADD COLUMN IF NOT EXISTS profile_schema_version INTEGER, + ADD COLUMN IF NOT EXISTS config_revision INTEGER NOT NULL DEFAULT 1; + +COMMENT ON COLUMN nexent.model_record_t.reasoning_supported IS 'Reasoning support: true supported, false unsupported, NULL unknown.'; +COMMENT ON COLUMN nexent.model_record_t.reasoning_controls IS 'Declared reasoning control domains; NULL unknown, empty array no controls.'; +COMMENT ON COLUMN nexent.model_record_t.native_tool_call_supported IS 'Native tool call support; NULL unknown, not an execution guarantee.'; +COMMENT ON COLUMN nexent.model_record_t.structured_output_supported IS 'Structured output support; NULL unknown, formats require binding.'; +COMMENT ON COLUMN nexent.model_record_t.temperature_supported IS 'Temperature control support; NULL unknown, separate from parameter value.'; +COMMENT ON COLUMN nexent.model_record_t.top_p_supported IS 'Top-p control support; NULL unknown, separate from parameter value.'; +COMMENT ON COLUMN nexent.model_record_t.input_modalities IS 'Unique supported input modality names; NULL unknown, empty array no modalities.'; +COMMENT ON COLUMN nexent.model_record_t.output_modalities IS 'Unique supported output modality names; NULL unknown, empty array no modalities.'; +COMMENT ON COLUMN nexent.model_record_t.enable_thinking IS 'Saved thinking switch; NULL omitted unless marked as explicit JSON null.'; +COMMENT ON COLUMN nexent.model_record_t.reasoning_effort IS 'Saved reasoning effort; NULL omitted unless marked as explicit JSON null.'; +COMMENT ON COLUMN nexent.model_record_t.reasoning_budget_tokens IS 'Saved reasoning token budget; sentinel values require explicit declaration.'; +COMMENT ON COLUMN nexent.model_record_t.protocol_id IS 'Registered model protocol; NULL only before resolved outlet migration completes.'; +COMMENT ON COLUMN nexent.model_record_t.resolved_profile IS 'Remaining resolved rules, bindings and provenance; no copies of dedicated column values.'; +COMMENT ON COLUMN nexent.model_record_t.profile_schema_version IS 'Persisted resolved profile schema version; NULL for pre-migration rows.'; +COMMENT ON COLUMN nexent.model_record_t.config_revision IS 'Outlet configuration revision for optimistic updates; initial value 1.'; diff --git a/sdk/nexent/core/model_service/profiles/composition.py b/sdk/nexent/core/model_service/profiles/composition.py index 3952220178..781d53f1df 100644 --- a/sdk/nexent/core/model_service/profiles/composition.py +++ b/sdk/nexent/core/model_service/profiles/composition.py @@ -26,14 +26,14 @@ from nexent.core.model_service.profiles.supplements import SupplementCatalog -COMPOSER_REF = "profile_policy_composer_v1" +COMPOSER_REF = "profile_policy_composer_v2" _CAPABILITY_PATHS = frozenset( f"/{group}/{field}" for group, fields in { "context": ("combined_window", "input_limit", "output_limit"), "reasoning": ("status", "controls", "response_paths"), "tools": ("status", "schema_constraints", "result_format"), - "sampling": ("temperature",), + "sampling": ("temperature", "top_p"), "structured_output": ("status",), "modalities": ("input", "output"), "response": ("supported_finish_reasons", "stream_format"), diff --git a/sdk/nexent/core/model_service/profiles/contracts.py b/sdk/nexent/core/model_service/profiles/contracts.py index 291b95052b..873b5ffad5 100644 --- a/sdk/nexent/core/model_service/profiles/contracts.py +++ b/sdk/nexent/core/model_service/profiles/contracts.py @@ -187,9 +187,10 @@ class ToolCapabilities(ProfileContract): class SamplingCapabilities(ProfileContract): - """Source temperature support is not a configured numeric temperature.""" + """Sampling support is independent of configured numeric parameter values.""" temperature: CapabilityFlag = Field(default_factory=CapabilityFlag) + top_p: CapabilityFlag = Field(default_factory=CapabilityFlag) class ModalityCapabilities(ProfileContract): diff --git a/sdk/nexent/core/model_service/profiles/outlet_capabilities.py b/sdk/nexent/core/model_service/profiles/outlet_capabilities.py new file mode 100644 index 0000000000..9ebfde240b --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/outlet_capabilities.py @@ -0,0 +1,140 @@ +"""Version-one capability partition for the existing model-record row.""" + +import json +from dataclasses import dataclass +from typing import Mapping + +from pydantic import ValidationError + +from nexent.core.model_service.profiles.contracts import ProfileCapabilities +from nexent.core.model_service.profiles.parameter_schema import _json_copy + + +_CONTEXT_COLUMNS = { + "combined_window": "context_window_tokens", + "input_limit": "max_input_tokens", + "output_limit": "max_output_tokens", +} +_STATUS_COLUMNS = { + "reasoning": "reasoning_supported", + "tools": "native_tool_call_supported", + "structured_output": "structured_output_supported", + "temperature": "temperature_supported", + "top_p": "top_p_supported", +} +CAPABILITY_COLUMN_KEYS = frozenset( + { + *_CONTEXT_COLUMNS.values(), + *_STATUS_COLUMNS.values(), + "reasoning_controls", + "input_modalities", + "output_modalities", + } +) + + +class CapabilityStorageError(ValueError): + """A fixed diagnostic without arbitrary stored values or Pydantic inputs.""" + + def __init__(self): + super().__init__("PROFILE_STORAGE_INVALID") + + +@dataclass +class StoredOutletCapabilities: + """Detached columns and remaining details; neither contains duplicate values.""" + + columns: dict[str, object] + capability_details: dict[str, object] + + +def _clone(value: object): + try: + copied = _json_copy(value) + encoded = json.dumps(copied, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8") + if len(encoded) > 1024 * 1024: + raise CapabilityStorageError() + return copied + except (ValueError, TypeError, RecursionError, UnicodeError): + raise CapabilityStorageError() from None + + +def _validate(value: object) -> dict: + try: + result = ProfileCapabilities.model_validate(value).model_dump(mode="json") + for number in result["context"].values(): + if number is not None and number >= 2**31: + raise CapabilityStorageError() + return result + except (ValidationError, ValueError, TypeError, UnicodeError): + raise CapabilityStorageError() from None + + +def _status(value: object) -> str: + if value is None: + return "unknown" + if type(value) is not bool: + raise CapabilityStorageError() + return "supported" if value else "unsupported" + + +def split_outlet_capabilities(capabilities: ProfileCapabilities) -> StoredOutletCapabilities: + """Partition a validated complete capability object using fixed field ownership.""" + if not isinstance(capabilities, ProfileCapabilities): + raise CapabilityStorageError() + value = _validate(_clone(capabilities.model_dump(mode="json"))) + columns = {column: value["context"][field] for field, column in _CONTEXT_COLUMNS.items()} + for group, column in _STATUS_COLUMNS.items(): + status = value["sampling"][group]["status"] if group in {"temperature", "top_p"} else value[group]["status"] + columns[column] = {"supported": True, "unsupported": False, "unknown": None}[status] + columns["reasoning_controls"] = value["reasoning"]["controls"] + columns["input_modalities"] = value["modalities"]["input"] + columns["output_modalities"] = value["modalities"]["output"] + details = { + "reasoning": {"response_paths": value["reasoning"]["response_paths"]}, + "tools": {key: value["tools"][key] for key in ("schema_constraints", "result_format")}, + "response": value["response"], + "usage": value["usage"], + } + return StoredOutletCapabilities(columns, details) + + +def restore_outlet_capabilities( + *, + columns: Mapping[str, object], + capability_details: Mapping[str, object], +) -> ProfileCapabilities: + """Restore strictly from row-owned fields; no catalog or default composition.""" + columns, details = _clone(columns), _clone(capability_details) + if not isinstance(columns, dict) or set(columns) != CAPABILITY_COLUMN_KEYS: + raise CapabilityStorageError() + if not isinstance(details, dict) or set(details) != {"reasoning", "tools", "response", "usage"}: + raise CapabilityStorageError() + expected = { + "reasoning": {"response_paths"}, + "tools": {"schema_constraints", "result_format"}, + "response": {"supported_finish_reasons", "stream_format"}, + "usage": {"semantics_id", "input_includes_cached", "output_includes_reasoning"}, + } + for group, keys in expected.items(): + if not isinstance(details[group], dict) or set(details[group]) != keys: + raise CapabilityStorageError() + value = { + "context": {key: columns[column] for key, column in _CONTEXT_COLUMNS.items()}, + "reasoning": { + "status": _status(columns["reasoning_supported"]), + "controls": columns["reasoning_controls"], + **details["reasoning"], + }, + "tools": {"status": _status(columns["native_tool_call_supported"]), **details["tools"]}, + "structured_output": {"status": _status(columns["structured_output_supported"])}, + "sampling": { + key: {"status": _status(columns[column])} + for key, column in _STATUS_COLUMNS.items() + if key in {"temperature", "top_p"} + }, + "modalities": {"input": columns["input_modalities"], "output": columns["output_modalities"]}, + "response": details["response"], + "usage": details["usage"], + } + return ProfileCapabilities.model_validate(_validate(_clone(value))) diff --git a/sdk/nexent/core/model_service/profiles/outlet_parameters.py b/sdk/nexent/core/model_service/profiles/outlet_parameters.py new file mode 100644 index 0000000000..4b4908693a --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/outlet_parameters.py @@ -0,0 +1,95 @@ +"""Single-location parameter storage, independent of database sessions and TOML.""" + +import math +from dataclasses import dataclass +from typing import Mapping + +from nexent.core.model_service.profiles.parameter_rules import ( + CompiledParameterRules, + validate_saved_parameter_values, +) +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError + + +DEDICATED_PARAMETER_KEYS = frozenset( + {"temperature", "top_p", "enable_thinking", "reasoning_effort", "reasoning_budget_tokens"} +) + + +@dataclass +class StoredOutletParameters: + """Detached fields to embed in a row, with no duplicate parameter values.""" + + columns: dict[str, object] + parameter_values: dict[str, object] + explicit_null_parameters: list[str] + + +def _validate_column(key: str, value: object) -> None: + if value is None: + return + valid = False + if key in {"temperature", "top_p"}: + try: + valid = type(value) in {int, float} and math.isfinite(value) + if valid and type(value) is int: + valid = float(value) == value + except OverflowError: + valid = False + elif key == "enable_thinking": + valid = type(value) is bool + elif key == "reasoning_effort": + valid = isinstance(value, str) and bool(value.strip()) and len(value) <= 30 + elif key == "reasoning_budget_tokens": + valid = type(value) is int and -(2**31) <= value < 2**31 + if not valid: + raise ParameterValidationError("PARAMETER_COLUMN_INVALID", "/" + key) + + +def split_outlet_parameters( + compiled: CompiledParameterRules, + values: Mapping[str, object], +) -> StoredOutletParameters: + """Split already resolved values; applying creation defaults is the caller's job.""" + validated = validate_saved_parameter_values(compiled, values) + columns = {key: None for key in sorted(DEDICATED_PARAMETER_KEYS)} + remaining, nulls = {}, [] + for key, value in validated.items(): + if key in DEDICATED_PARAMETER_KEYS: + _validate_column(key, value) + columns[key] = value + if value is None: + nulls.append(key) + else: + remaining[key] = value + return StoredOutletParameters(columns, remaining, sorted(nulls)) + + +def restore_outlet_parameters( + compiled: CompiledParameterRules, + *, + columns: Mapping[str, object], + parameter_values: Mapping[str, object], + explicit_null_parameters: list[str], +) -> dict: + """Restore exactly the saved key presence; reject corruption instead of repairing it.""" + if not isinstance(columns, Mapping) or set(columns) != DEDICATED_PARAMETER_KEYS: + raise ParameterValidationError("PARAMETER_COLUMNS_INVALID") + if ( + not isinstance(explicit_null_parameters, list) + or any(type(key) is not str for key in explicit_null_parameters) + or len(explicit_null_parameters) != len(set(explicit_null_parameters)) + or not set(explicit_null_parameters) <= DEDICATED_PARAMETER_KEYS + ): + raise ParameterValidationError("PARAMETER_NULL_MARKERS_INVALID") + if not isinstance(parameter_values, Mapping) or DEDICATED_PARAMETER_KEYS & parameter_values.keys(): + raise ParameterValidationError("PARAMETER_STORAGE_DUPLICATE") + values = dict(parameter_values) + nulls = set(explicit_null_parameters) + for key, value in columns.items(): + _validate_column(key, value) + if key in nulls and value is not None: + raise ParameterValidationError("PARAMETER_NULL_MARKERS_INVALID", "/" + key) + if value is not None or key in nulls: + values[key] = value + return validate_saved_parameter_values(compiled, values) diff --git a/sdk/nexent/core/model_service/profiles/outlet_rules.py b/sdk/nexent/core/model_service/profiles/outlet_rules.py new file mode 100644 index 0000000000..2a89c7624e --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/outlet_rules.py @@ -0,0 +1,130 @@ +"""Store rule constraints as explicit references to same-row control domains.""" + +import json +from typing import Mapping + +from pydantic import ValidationError + +from nexent.core.model_service.profiles.contracts import ReasoningCapabilities +from nexent.core.model_service.profiles.parameter_rules import ( + CompiledParameterRules, + compile_parameter_rules, + validate_saved_parameter_values, +) +from nexent.core.model_service.profiles.parameter_schema import _json_copy, json_values_equal + + +class RuleStorageError(ValueError): + """A fixed storage diagnostic that never includes private constraint values.""" + + def __init__(self): + super().__init__("PROFILE_STORAGE_INVALID") + + +def _clone(value): + try: + copied = _json_copy(value) + encoded = json.dumps(copied, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8") + if len(encoded) > 1024 * 1024: + raise RuleStorageError() + return copied + except (ValueError, TypeError, RecursionError, UnicodeError): + raise RuleStorageError() from None + + +def _domains(controls): + try: + reasoning = ReasoningCapabilities.model_validate({"controls": _clone(controls)}) + except (ValidationError, ValueError, TypeError): + raise RuleStorageError() from None + domains = {} + for control in reasoning.controls or []: + value = control.model_dump(mode="json") + if control.mode in {"toggle", "effort"}: + keywords = {"enum": value["values"]} + else: + keywords = { + keyword: value[field] + for keyword, field in (("minimum", "min"), ("maximum", "max")) + if value[field] is not None + } + domains[control.parameter_key] = (control.mode, keywords) + return domains + + +def store_parameter_rules( + compiled: CompiledParameterRules, + *, + reasoning_controls: object, + saved_values: Mapping[str, object], +) -> list[dict]: + """Remove only exact duplicate domain/fixed values, preserving extra constraints.""" + try: + saved = validate_saved_parameter_values(compiled, saved_values) + rules = _clone(compiled.declarations) + domains = _domains(reasoning_controls) + for rule in rules: + key = rule["key"] + if key in domains: + mode, domain = domains[key] + keywords = sorted( + keyword + for keyword, value in domain.items() + if keyword in rule["schema"] and json_values_equal(rule["schema"][keyword], value) + ) + if keywords: + rule["value_domain_ref"] = {"mode": mode, "keywords": keywords} + for keyword in keywords: + del rule["schema"][keyword] + if "fixed_value" in rule and key in saved: + rule.pop("fixed_value") + rule["fixed_to_saved_value"] = True + return _clone(rules) + except (ValueError, TypeError, KeyError, AttributeError): + raise RuleStorageError() from None + + +def restore_parameter_rules( + declarations: object, + *, + reasoning_controls: object, + saved_values: Mapping[str, object], +) -> CompiledParameterRules: + """Restore constraints from row-owned domains; never apply creation defaults.""" + try: + rules, saved = _clone(declarations), _clone(saved_values) + if not isinstance(rules, list) or not isinstance(saved, dict): + raise RuleStorageError() + domains = _domains(reasoning_controls) + for rule in rules: + if not isinstance(rule, dict) or not isinstance(rule.get("schema"), dict): + raise RuleStorageError() + key = rule.get("key") + has_reference = "value_domain_ref" in rule + reference = rule.pop("value_domain_ref", None) + if has_reference: + if not isinstance(reference, dict) or set(reference) != {"mode", "keywords"} or key not in domains: + raise RuleStorageError() + mode, domain = domains[key] + keywords = reference["keywords"] + if ( + reference["mode"] != mode + or not isinstance(keywords, list) + or not keywords + or any(type(word) is not str for word in keywords) + or len(set(keywords)) != len(keywords) + or not set(keywords) <= domain.keys() + or set(keywords) & rule["schema"].keys() + ): + raise RuleStorageError() + for keyword in keywords: + rule["schema"][keyword] = domain[keyword] + if "fixed_to_saved_value" in rule: + if rule.pop("fixed_to_saved_value") is not True or "fixed_value" in rule or key not in saved: + raise RuleStorageError() + rule["fixed_value"] = saved[key] + compiled = compile_parameter_rules(_clone(rules)) + validate_saved_parameter_values(compiled, saved) + return compiled + except (ValueError, TypeError, KeyError): + raise RuleStorageError() from None diff --git a/sdk/nexent/core/model_service/profiles/outlet_storage.py b/sdk/nexent/core/model_service/profiles/outlet_storage.py new file mode 100644 index 0000000000..300bcb4e15 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/outlet_storage.py @@ -0,0 +1,211 @@ +"""Fixed single-row storage codec; never loads source catalogs or credentials.""" + +from typing import Mapping, Sequence + +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.profiles.outlet_capabilities import ( + CAPABILITY_COLUMN_KEYS, + restore_outlet_capabilities, + split_outlet_capabilities, +) +from nexent.core.model_service.profiles.outlet_parameters import DEDICATED_PARAMETER_KEYS, split_outlet_parameters +from nexent.core.model_service.profiles.outlet_rules import restore_parameter_rules, store_parameter_rules +from nexent.core.model_service.profiles.parameter_bindings import validate_parameter_bindings +from nexent.core.model_service.profiles.parameter_rules import compile_parameter_rules +from nexent.core.model_service.profiles.parameter_schema import _json_copy +from nexent.core.model_service.profiles.request_parameters import apply_extra_api_parameters +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile + + +_IDENTITY = { + "tenant_id": "tenant_id", + "display_name": "display_name", + "model_type": "model_type", + "provider_id": "model_factory", + "request_model_id": "model_name", +} +_CONNECTION = ("base_url", "ssl_verify", "timeout_seconds", "concurrency_limit") +_CONTEXT = { + "output_reserve_tokens": "default_output_reserve_tokens", + "tokenizer_family": "tokenizer_family", + "capacity_source": "capacity_source", + "capability_profile_version": "capability_profile_version", +} +_REMAINDER = { + "parameter_rules", + "instruction_mapping", + "capability_details", + "bindings", + "provenance", + "connection_rules", + "explicit_null_parameters", +} + + +class ProfileStorageError(ValueError): + def __init__(self): + super().__init__("PROFILE_STORAGE_INVALID") + + +def _validate_available(profile, registered_references, protected_paths): + for key, reference in profile.bindings: + if key == "role_mapping": + continue # The executable local instruction registry already validates this declaration. + if reference not in registered_references.get(key, ()): + raise ProfileStorageError() + compiled = compile_parameter_rules(profile.parameter_rules) + bindings = validate_parameter_bindings( + compiled, registered_references.get("parameter_transform", ()), protected_paths=protected_paths + ) + apply_extra_api_parameters( + {}, + profile.extra_api_parameters, + parameter_paths=[binding["path"] for binding in bindings.values()], + protected_paths=protected_paths, + ) + return compiled + + +def encode_resolved_model_profile( + profile: ResolvedModelProfile, + *, + registered_references: Mapping[str, Sequence[VersionedReference]], + protected_paths: Sequence[str] = (), +) -> dict: + """Return row-owned fields; credential values and audit fields belong to the caller.""" + try: + profile = ResolvedModelProfile.model_validate(_json_copy(profile.model_dump(mode="json"))) + compiled = _validate_available(profile, registered_references, protected_paths) + capabilities = split_outlet_capabilities(profile.capabilities) + parameters = split_outlet_parameters(compiled, profile.parameter_values) + values = profile.model_dump(mode="json") + bindings = values["bindings"] + protocol = bindings.pop("protocol") + bindings["protocol_version"] = protocol["version"] + remainder = { + "parameter_rules": store_parameter_rules( + compiled, + reasoning_controls=capabilities.columns["reasoning_controls"], + saved_values=profile.parameter_values, + ), + "instruction_mapping": values["instruction_mapping"], + "capability_details": capabilities.capability_details, + "bindings": bindings, + "provenance": values["provenance"], + "connection_rules": values["connection_rules"], + "explicit_null_parameters": parameters.explicit_null_parameters, + } + row = {column: values["identity"][field] for field, column in _IDENTITY.items()} + row.update({key: values["connection"][key] for key in _CONNECTION}) + row.update({column: values["context_configuration"][field] for field, column in _CONTEXT.items()}) + row.update(capabilities.columns) + row.update(parameters.columns) + row.update( + profile_schema_version=profile.schema_version, + config_revision=profile.config_revision, + protocol_id=protocol["ref"], + resolved_profile=remainder, + extra_params={ + "parameter_values": parameters.parameter_values, + "api_parameters": values["extra_api_parameters"], + }, + ) + if profile.outlet_id is not None: + row["model_id"] = profile.outlet_id + return _json_copy(row) + except (ValueError, TypeError, KeyError, AttributeError, UnicodeError): + raise ProfileStorageError() from None + + +def decode_resolved_model_profile( + row: Mapping[str, object], + *, + registered_references: Mapping[str, Sequence[VersionedReference]], + protected_paths: Sequence[str] = (), +) -> ResolvedModelProfile: + """Restore this row only; reject incomplete migration states and unavailable versions.""" + try: + if type(row["model_id"]) is not int or not 0 < row["model_id"] < 2**31: + raise ProfileStorageError() + remainder, extra = _json_copy(row["resolved_profile"]), _json_copy(row["extra_params"]) + if not isinstance(remainder, dict) or set(remainder) != _REMAINDER: + raise ProfileStorageError() + if not isinstance(extra, dict) or set(extra) != {"parameter_values", "api_parameters"}: + raise ProfileStorageError() + capability_columns = {key: row[key] for key in CAPABILITY_COLUMN_KEYS} + capabilities = restore_outlet_capabilities( + columns=capability_columns, capability_details=remainder["capability_details"] + ) + # Recover presence first; the restored declaration will validate schemas and fixed constraints. + saved = _json_copy(extra["parameter_values"]) + nulls = remainder["explicit_null_parameters"] + if ( + not isinstance(saved, dict) + or DEDICATED_PARAMETER_KEYS & saved.keys() + or not isinstance(nulls, list) + or any(type(key) is not str for key in nulls) + or len(nulls) != len(set(nulls)) + or not set(nulls) <= DEDICATED_PARAMETER_KEYS + ): + raise ProfileStorageError() + for key in DEDICATED_PARAMETER_KEYS: + value = row[key] + if key in nulls and value is not None: + raise ProfileStorageError() + if value is not None or key in nulls: + saved[key] = value + for declaration in remainder["parameter_rules"]: + key = declaration.get("key") + if key not in {"temperature", "top_p"}: + continue + declared_type = declaration.get("schema", {}).get("type") + integer_only = declared_type == "integer" or ( + isinstance(declared_type, list) + and "integer" in declared_type + and set(declared_type) <= {"integer", "null"} + ) + value = saved.get(key) + if integer_only and type(value) is float and value.is_integer(): + saved[key] = int(value) + compiled = restore_parameter_rules( + remainder["parameter_rules"], + reasoning_controls=capability_columns["reasoning_controls"], + saved_values=saved, + ) + # The common parameter codec enforces SQL column types, in addition to the restored schemas. + split_outlet_parameters(compiled, saved) + bindings = remainder["bindings"] + if not isinstance(bindings, dict) or "protocol" in bindings or "protocol_version" not in bindings: + raise ProfileStorageError() + bindings["protocol"] = {"ref": row["protocol_id"], "version": bindings.pop("protocol_version")} + connection = {key: row[key] for key in _CONNECTION} + connection["credential_ref"] = { + "kind": remainder["connection_rules"]["credential_kind"], + "model_id": row["model_id"], + } + identity = {key: row[column] for key, column in _IDENTITY.items()} + for field, atom in (("provider_key", "provider"), ("model_profile_key", "model")): + reference = remainder["provenance"]["atomic_refs"][atom] + identity[field] = reference["key"] if reference is not None else None + profile = ResolvedModelProfile.model_validate( + { + "schema_version": row["profile_schema_version"], + "outlet_id": row["model_id"], + "config_revision": row["config_revision"], + "identity": identity, + "connection": connection, + "connection_rules": remainder["connection_rules"], + "context_configuration": {key: row[column] for key, column in _CONTEXT.items()}, + "capabilities": capabilities.model_dump(mode="json"), + "instruction_mapping": remainder["instruction_mapping"], + "parameter_rules": compiled.declarations, + "parameter_values": saved, + "extra_api_parameters": extra["api_parameters"], + "bindings": bindings, + "provenance": remainder["provenance"], + } + ) + _validate_available(profile, registered_references, protected_paths) + return profile + except (ValueError, TypeError, KeyError, AttributeError, UnicodeError): + raise ProfileStorageError() from None diff --git a/sdk/nexent/core/model_service/profiles/parameter_rules.py b/sdk/nexent/core/model_service/profiles/parameter_rules.py index 6468d6444f..9ad3083a6b 100644 --- a/sdk/nexent/core/model_service/profiles/parameter_rules.py +++ b/sdk/nexent/core/model_service/profiles/parameter_rules.py @@ -187,3 +187,31 @@ def apply_parameter_values( elif rule["required"] and key not in values: raise ParameterValidationError("PARAMETER_VALUE_REQUIRED", _pointer(key)) return values + + +def validate_saved_parameter_values( + compiled: CompiledParameterRules, + saved_values: Mapping[str, object], +) -> dict: + """Validate a persisted snapshot without applying defaults or changing presence.""" + validate_parameter_value(compile_parameter_schema({"type": "object"}), saved_values) + values = json.loads(json.dumps(dict(saved_values))) + rules = {rule["key"]: rule for rule in compiled.declarations} + schemas = dict(compiled._schemas) + for key in values: + if key not in rules: + raise ParameterValidationError("PARAMETER_UNKNOWN", _pointer(key)) + validate_parameter_value(schemas[key], values[key], _pointer(key)) + for key in compiled._order: + rule = rules[key] + active = _condition_matches(rule.get("condition"), values) + if not active: + if key in values: + raise ParameterValidationError("PARAMETER_DEPENDENCY", _pointer(key)) + continue + if "fixed_value" in rule: + if key not in values or not json_values_equal(values[key], rule["fixed_value"]): + raise ParameterValidationError("FIXED_PARAMETER", _pointer(key)) + elif rule["required"] and key not in values: + raise ParameterValidationError("PARAMETER_VALUE_REQUIRED", _pointer(key)) + return values diff --git a/sdk/nexent/core/model_service/profiles/resolved_profile.py b/sdk/nexent/core/model_service/profiles/resolved_profile.py new file mode 100644 index 0000000000..8cfd548574 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/resolved_profile.py @@ -0,0 +1,200 @@ +"""Strict complete outlet snapshot, with safe credential references only.""" + +import json +from typing import Annotated, Literal, Self + +from pydantic import Field, JsonValue, StringConstraints, field_validator, model_validator + +from nexent.core.model_service.profiles.contracts import ( + AtomicProfileReference, + InstructionMappingDefinition, + NonBlankString, + ProfileCapabilities, + ProfileContract, + VersionedReference, +) +from nexent.core.model_service.profiles.instruction_mapping import validate_instruction_mapping +from nexent.core.model_service.profiles.parameter_bindings import validate_parameter_bindings +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_copy, json_values_equal +from nexent.core.model_service.profiles.request_parameters import apply_extra_api_parameters + + +PositiveInt4 = Annotated[int, Field(gt=0, lt=2**31)] +Name = Annotated[str, StringConstraints(min_length=1, max_length=100, pattern=r"\S")] + + +class OutletIdentity(ProfileContract): + tenant_id: Name + display_name: Name + model_type: Name + provider_id: Name + request_model_id: Name + provider_key: NonBlankString | None + model_profile_key: NonBlankString | None + + +class CredentialReference(ProfileContract): + kind: Literal["model_record", "none"] + model_id: PositiveInt4 | None + + +class OutletConnection(ProfileContract): + base_url: Annotated[str, StringConstraints(min_length=1, max_length=500, pattern=r"\S")] + ssl_verify: bool + timeout_seconds: PositiveInt4 + concurrency_limit: PositiveInt4 | None + credential_ref: CredentialReference + + +class ConnectionRules(ProfileContract): + credential_kind: Literal["model_record", "none"] + relative_path: NonBlankString + method: Literal["POST", "GET"] + headers: dict[NonBlankString, str] + + +class OutletBindings(ProfileContract): + protocol: VersionedReference + auth: VersionedReference + adapter: VersionedReference + code_converter: VersionedReference + native_converter: VersionedReference + role_mapping: VersionedReference + + +class AtomicReferences(ProfileContract): + model: AtomicProfileReference | None + provider: AtomicProfileReference | None + provider_model: AtomicProfileReference | None + + +class InterpretationVersions(ProfileContract): + source_parser: VersionedReference | None + source_mapping: VersionedReference | None + supplement_schema: VersionedReference | None + composer: VersionedReference | None + + +class OverrideRecord(ProfileContract): + path: NonBlankString + source: NonBlankString + original_value: JsonValue = Field(repr=False) + reason: NonBlankString + evidence: NonBlankString | list[NonBlankString] + + +class FieldSourceRecord(ProfileContract): + target_path: NonBlankString + source_path: NonBlankString + atom_kind: Literal["model", "provider", "provider_model"] + rule_id: NonBlankString + rule_version: PositiveInt4 + + +class OutletProvenance(ProfileContract): + atomic_refs: AtomicReferences + interpretation_versions: InterpretationVersions + overrides: list[OverrideRecord] + field_sources: list[FieldSourceRecord] + provider_metadata: dict[str, JsonValue] = Field(repr=False) + + +class ContextConfiguration(ProfileContract): + output_reserve_tokens: PositiveInt4 | None + tokenizer_family: Name | None + capacity_source: Name | None + capability_profile_version: Name | None + + +class ResolvedModelProfile(ProfileContract): + """A configuration snapshot; declarations do not prove registry availability.""" + + schema_version: Literal[1] + outlet_id: PositiveInt4 | None + config_revision: PositiveInt4 + identity: OutletIdentity + connection: OutletConnection + connection_rules: ConnectionRules + context_configuration: ContextConfiguration + capabilities: ProfileCapabilities + instruction_mapping: InstructionMappingDefinition + parameter_rules: list[dict] = Field(repr=False) + parameter_values: dict[str, JsonValue] = Field(repr=False) + extra_api_parameters: list[dict] = Field(repr=False) + bindings: OutletBindings + provenance: OutletProvenance + + @field_validator("schema_version", mode="before") + @classmethod + def validate_schema_version(cls, value): + if type(value) is not int: + raise ValueError("Schema version must be a strict integer") + return value + + @property + def provider_key(self) -> str | None: + reference = self.provenance.atomic_refs.provider + return reference.key if reference else None + + @property + def model_profile_key(self) -> str | None: + reference = self.provenance.atomic_refs.model + return reference.key if reference else None + + @model_validator(mode="after") + def validate_snapshot(self) -> Self: + credential = self.connection.credential_ref + if credential.model_id != self.outlet_id or credential.kind != self.connection_rules.credential_kind: + raise ValueError("Credential reference does not belong to this outlet") + if ( + self.identity.provider_key != self.provider_key + or self.identity.model_profile_key != self.model_profile_key + ): + raise ValueError("Atomic identity does not match its provenance") + if len(self.bindings.protocol.ref) > 30: + raise ValueError("Protocol reference exceeds its database column") + if validate_instruction_mapping(self.instruction_mapping) != self.bindings.role_mapping: + raise ValueError("Instruction mapping reference does not match its definition") + compiled = compile_parameter_rules(self.parameter_rules) + validate_saved_parameter_values(compiled, self.parameter_values) + declarations = {rule["key"]: rule for rule in compiled.declarations} + for control in self.capabilities.reasoning.controls or []: + key = control.parameter_key + if key not in declarations: + raise ValueError("Reasoning control has no parameter declaration") + if key not in self.parameter_values: + continue + value = self.parameter_values[key] + if control.mode in {"toggle", "effort"}: + valid = any(json_values_equal(value, candidate) for candidate in control.values) + else: + valid = type(value) is int and ( + value in control.special_values + or ( + (control.min is None or value >= control.min) and (control.max is None or value <= control.max) + ) + ) + if not valid: + raise ValueError("Saved value contradicts its reasoning control domain") + transforms = [] + for rule in self.parameter_rules: + binding = rule.get("api_binding") + if binding is not None: + if not isinstance(binding, dict) or set(binding) != {"path", "transform_ref", "transform_version"}: + raise ValueError("Invalid parameter binding declaration") + transforms.append( + VersionedReference.model_validate( + {"ref": binding["transform_ref"], "version": binding["transform_version"]} + ) + ) + # This checks declaration syntax only; storage also requires a trusted availability snapshot. + bound = validate_parameter_bindings(compiled, transforms) + parameter_paths = [binding["path"] for binding in bound.values()] + apply_extra_api_parameters({}, self.extra_api_parameters, parameter_paths=parameter_paths) + encoded = json.dumps(_json_copy(self.model_dump(mode="json")), ensure_ascii=False, allow_nan=False).encode( + "utf-8" + ) + if len(encoded) > 1024 * 1024: + raise ValueError("Profile exceeds the 1 MiB configuration limit") + return self diff --git a/test-e2e/cases/MOP-D3-001/case.yaml b/test-e2e/cases/MOP-D3-001/case.yaml new file mode 100644 index 0000000000..96f4e8ded9 --- /dev/null +++ b/test-e2e/cases/MOP-D3-001/case.yaml @@ -0,0 +1,57 @@ +schema_version: '1.0' +stage: D3 +module: MODEL-SERVICE +case: + case_id: MOP-D3-001 + feature_id: BE-MODEL-OUTLET-PERSISTENCE + business_rule_ids: [BE-MODEL-OUTLET-PERSISTENCE-RULE-01, BE-MODEL-OUTLET-PERSISTENCE-RULE-02, BE-MODEL-OUTLET-PERSISTENCE-RULE-03] + title: Persist isolated outlets with transactional optimistic updates + type: INTEGRATION + priority: P1 + status: active + automation: automated + objective: Exercise actual PostgreSQL storage, snapshot codecs and shared transaction behavior. + integration_boundary: + - Actual PostgreSQL plus backend CRUD, shared session ownership and SDK snapshot codecs; implementation availability is a controlled test registry, no provider request occurs. + observations: [Persisted rows and revision values across separate sessions, SQL exception category and sanitized public diagnostics] + cleanup: [Drop only the isolated run-owned database and close test-owned sessions] + preconditions: [Isolated run-owned PostgreSQL database with current schema, Product database modules and SDK available at locked revision] + test_data: + configuration: Two synthetic tenants and outlets with zero/false/null parameter values and non-secret dummy credentials. + steps: + - order: 1 + action: Create and read a resolved outlet in one caller-owned transaction, commit it, then restore it through a new session. + expected: Dedicated/JSON values match and audit/id/revision fields are correct; only the API key column stores the dummy credential. + - order: 2 + action: Load the same revision for two writers, commit the first update and submit the second stale update; read through another tenant and after soft deletion. + expected: One update increments revision, stale update fails without overwriting; wrong tenant and deleted rows are unavailable. + - order: 3 + action: Create/update inside a transaction that fails later, roll it back and read through another session; provoke an actual SQL error with a dummy private value. + expected: No partially committed row/configuration survives and public error/log text excludes the dummy credential. + expected_results: + - Full profile survives actual PostgreSQL commit/load with single-location values and safe credential references. + - Tenant, active-row and revision constraints prevent unauthorized or stale writes, preserving the winner configuration. + - Transaction rollback restores prior state and query failures have sanitized diagnostics. + forbidden_side_effects: + - No production connections, provider calls, source-file updates or real credentials in artifacts. + - No commits or session closure by CRUD when the caller owns the transaction. + execution: + profiles: + - name: real_smoke + required_services: [] + acceptance: + - id: AC-01 + description: Actual committed row reconstructs the correct profile and credential ownership. + steps: [1] + expected_results: [1] + forbidden_side_effects: [1, 2] + - id: AC-02 + description: Concurrency, tenant and deletion checks preserve valid state. + steps: [2] + expected_results: [2] + forbidden_side_effects: [1, 2] + - id: AC-03 + description: Rollback and sanitized failure hold at the actual database boundary. + steps: [3] + expected_results: [3] + forbidden_side_effects: [1, 2] diff --git a/test-e2e/cases/MOP-D3-001/execution.yaml b/test-e2e/cases/MOP-D3-001/execution.yaml new file mode 100644 index 0000000000..24896c5c7d --- /dev/null +++ b/test-e2e/cases/MOP-D3-001/execution.yaml @@ -0,0 +1,16 @@ +schema_version: '1.0' +case_id: MOP-D3-001 +implementations: +- framework: pytest + file: test.py + selector: MOP-D3-001 + profiles: [real_smoke] +acceptance_bindings: + AC-01: [test_committed_snapshot_and_transaction_ownership] + AC-02: [test_stale_writer_tenant_and_deleted_row] + AC-03: [test_rollback_and_sanitized_actual_database_failure] +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/MOP-D3-001/test.py b/test-e2e/cases/MOP-D3-001/test.py new file mode 100644 index 0000000000..dccef4425a --- /dev/null +++ b/test-e2e/cases/MOP-D3-001/test.py @@ -0,0 +1,161 @@ +"""Real PostgreSQL, product sessions and resolved outlet snapshot persistence.""" + +import json +from pathlib import Path + +import pytest +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.profiles.instruction_mapping import ( + resolve_instruction_mapping, +) +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile +from shared.factories.scratch_database import isolated_product_database +from shared.postgres import apply_product_postgres_env +from sqlalchemy import select, text, update +from sqlalchemy.exc import DBAPIError + +pytestmark = [pytest.mark.case_id("MOP-D3-001"), pytest.mark.stage("D3")] +REF = VersionedReference(ref="db_boundary_v1", version=1) +REGISTRY = {key: [REF] for key in ( + "protocol", "auth", "adapter", "code_converter", "native_converter", "parameter_transform" +)} +DUMMY = "non-secret-test-credential" + + +def parameter(key, schema): + return { + "key": key, "schema": schema, "visible": True, "editable": True, + "required": False, "allowed_scopes": ["outlet", "conversation"], + "ui_group": "common", "owner": "provider", "source": {"rule_id": "db-fixture"}, + "api_binding": {"path": "/" + key, "transform_ref": REF.ref, "transform_version": 1}, + } + + +def profile(): + role = VersionedReference(ref="system_only_v1", version=1) + return ResolvedModelProfile.model_validate({ + "schema_version": 1, "outlet_id": None, "config_revision": 1, + "identity": {"tenant_id": "db-tenant-a", "display_name": "outlet fixture", + "model_type": "llm", "provider_id": "custom-provider", + "request_model_id": "Exact/Model", "provider_key": None, "model_profile_key": None}, + "connection": {"base_url": "https://fixture.invalid/v1", "ssl_verify": False, + "timeout_seconds": 120, "concurrency_limit": None, + "credential_ref": {"kind": "model_record", "model_id": None}}, + "connection_rules": {"credential_kind": "model_record", "relative_path": "/chat/completions", + "method": "POST", "headers": {}}, + "context_configuration": {"output_reserve_tokens": None, "tokenizer_family": None, + "capacity_source": "operator", "capability_profile_version": None}, + "capabilities": {}, "instruction_mapping": resolve_instruction_mapping(role).model_dump(mode="json"), + "parameter_rules": [parameter("temperature", {"type": "integer"}), + parameter("custom_switch", {"type": "boolean"}), + parameter("custom_null", {"type": "null"})], + "parameter_values": {"temperature": 0, "custom_switch": False, "custom_null": None}, + "extra_api_parameters": [{"path": "/vendor/option", "value": False}], + "bindings": {**{key: REF.model_dump() for key in REGISTRY if key != "parameter_transform"}, + "role_mapping": role.model_dump()}, + "provenance": {"atomic_refs": {"model": None, "provider": None, "provider_model": None}, + "interpretation_versions": {"source_parser": None, "source_mapping": None, + "supplement_schema": None, "composer": None}, + "overrides": [], "field_sources": [], "provider_metadata": {}}, + }) + + +@pytest.fixture +def database(monkeypatch): + monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[3] / "backend")) + apply_product_postgres_env() + from consts.exceptions import ModelOutletConflictError, ModelOutletDatabaseError + from database import client, model_outlet_db + from database.db_models import ModelRecord + + with isolated_product_database(): + yield client, model_outlet_db, ModelRecord, ModelOutletConflictError, ModelOutletDatabaseError + + +def create(db, session=None): + return db.create_resolved_model_record(profile(), "creator", "db-tenant-a", + registered_references=REGISTRY, api_key=DUMMY, db_session=session) + + +def read(db, identifier, session=None, tenant="db-tenant-a"): + return db.get_resolved_model_record(identifier, tenant, registered_references=REGISTRY, db_session=session) + + +def change(db, snapshot, session=None): + return db.update_resolved_model_record(snapshot, snapshot.config_revision, "editor", "db-tenant-a", + registered_references=REGISTRY, db_session=session) + + +def test_committed_snapshot_and_transaction_ownership(database): + client, db, model, _, _ = database + with client.db_client.session_maker() as session: + identifier = create(db, session) + loaded = read(db, identifier, session) + assert session.in_transaction() + assert loaded.outlet_id == identifier and loaded.config_revision == 1 + # A separate connection cannot see an uncommitted creation. + assert read(db, identifier) is None + session.commit() + restored = read(db, identifier) + assert restored.parameter_values == {"temperature": 0, "custom_switch": False, "custom_null": None} + assert type(restored.parameter_values["temperature"]) is int + assert restored.connection.credential_ref.model_id == identifier + with client.get_db_session() as session: + row = session.execute(select(model.__table__).where(model.model_id == identifier)).mappings().one() + assert row["api_key"] == DUMMY + assert DUMMY not in json.dumps(row["resolved_profile"]) + assert DUMMY not in json.dumps(row["extra_params"]) + assert row["temperature"] == 0 + assert "temperature" not in row["extra_params"]["parameter_values"] + assert row["created_by"] == row["updated_by"] == "creator" + assert row["create_time"] is not None and row["update_time"] is not None + assert row["delete_flag"] == "N" + + +def test_stale_writer_tenant_and_deleted_row(database): + client, db, model, conflict, _ = database + identifier = create(db) + with client.db_client.session_maker() as first, client.db_client.session_maker() as second: + winner, loser = read(db, identifier, first), read(db, identifier, second) + winner.parameter_values["temperature"] = 1 + loser.parameter_values["temperature"] = 2 + assert change(db, winner, first) == 2 + first.commit() + with pytest.raises(conflict): + change(db, loser, second) + second.rollback() + current = read(db, identifier) + assert current.config_revision == 2 and current.parameter_values["temperature"] == 1 + assert read(db, identifier, tenant="db-tenant-b") is None + with client.get_db_session() as session: + row = session.execute(select(model.__table__).where(model.model_id == identifier)).mappings().one() + assert row["created_by"] == "creator" and row["updated_by"] == "editor" + session.execute(update(model).where(model.model_id == identifier).values(delete_flag="Y")) + assert read(db, identifier) is None + + +def test_rollback_and_sanitized_actual_database_failure(database, caplog): + client, db, _, _, database_error = database + identifier = create(db) + with client.db_client.session_maker() as session: + transient = create(db, session) + changed = read(db, identifier, session) + changed.parameter_values["temperature"] = 3 + assert change(db, changed, session) == 2 + # Force a real server-side failure after both writes. + with pytest.raises(DBAPIError) as failure: + session.execute(text("SELECT 1 / 0")) + assert "division by zero" in str(failure.value) + session.rollback() + assert read(db, transient) is None + stable = read(db, identifier) + assert stable.config_revision == 1 and stable.parameter_values["temperature"] == 0 + # A scratch-only constraint causes an actual INSERT error containing a bound dummy value. + with client.get_db_session() as session: + session.execute(text("ALTER TABLE nexent.model_record_t ADD CONSTRAINT outlet_fixture_reject CHECK (api_key IS NULL) NOT VALID")) + with pytest.raises(database_error) as sanitized: + create(db) + assert str(sanitized.value) == "MODEL_OUTLET_DATABASE_ERROR" + assert sanitized.value.__suppress_context__ + assert DUMMY not in str(sanitized.value) and DUMMY not in caplog.text + assert read(db, identifier).config_revision == 1 diff --git a/test-e2e/cases/MOS-D5-001/case.yaml b/test-e2e/cases/MOS-D5-001/case.yaml new file mode 100644 index 0000000000..edc9b47c16 --- /dev/null +++ b/test-e2e/cases/MOS-D5-001/case.yaml @@ -0,0 +1,57 @@ +schema_version: '1.0' +stage: D5 +module: MODEL-SERVICE +case: + case_id: MOS-D5-001 + feature_id: BE-MODEL-OUTLET-SCHEMA + business_rule_ids: [BE-MODEL-OUTLET-SCHEMA-RULE-01, BE-MODEL-OUTLET-SCHEMA-RULE-02, BE-MODEL-OUTLET-SCHEMA-RULE-03] + title: Upgrade and repeat model outlet schema without data loss + type: DEPLOYMENT + priority: P1 + status: active + automation: automated + objective: Exercise incremental SQL and PostgreSQL metadata/data behavior against a pre-upgrade model table. + preconditions: + - Isolated PostgreSQL database created for the current run; no production database is permitted. + - Repository migration runner and SQL are available at the locked product revision. + test_data: + configuration: Synthetic model rows with non-secret connection values; transaction-scoped failure injection. + steps: + - order: 1 + action: Prepare the prior model table with representative existing endpoint, model, capacity, false/zero inference values and an extra parameter bag; execute the new schema SQL transactionally. + expected: Existing values are unchanged and new capabilities remain unknown; config_revision becomes 1. + - order: 2 + action: Query PostgreSQL column types, nullability, defaults and comments; repeat the SQL and compare existing rows and metadata. + expected: All new columns match the design and repeat execution preserves metadata and values. + - order: 3 + action: Inject a deliberate SQL failure after migration inside a transaction, confirm rollback, then run the unchanged migration successfully. + expected: Failed transaction leaves no partial new schema; the successful retry restores the complete extension. + expected_results: + - Prior model configuration values are unchanged; new capability/profile fields remain NULL and config_revision is 1. + - Actual PostgreSQL types, nullability, default and English comments match ORM/design; repeated execution is idempotent. + - Injected failure rolls back all migration changes and normal retry succeeds. + forbidden_side_effects: + - No production connection, real provider call or credential value in logs/artifacts. + - No new atomic profile tables, capability inference or modification of previously merged SQL. + risk_type: DEPLOYMENT + risk: Schema changes may rewrite model settings, lose unknown states or partially apply. + condition: [Isolated actual PostgreSQL with a pre-upgrade schema and existing model rows] + metrics: [Existing value mismatch count, New column metadata mismatch count, Partial schema count after rollback] + thresholds: [All mismatch counts are zero, Partial schema count is zero] + recovery_expectations: [The unchanged SQL succeeds after the intentionally failed transaction] + acceptance: + - id: AC-01 + description: Preserve legacy values and unknown capabilities during upgrade. + steps: [1] + expected_results: [1] + forbidden_side_effects: [1, 2] + - id: AC-02 + description: Validate actual metadata and idempotent repetition. + steps: [2] + expected_results: [2] + forbidden_side_effects: [1, 2] + - id: AC-03 + description: Roll back failed execution and recover by retry. + steps: [3] + expected_results: [3] + forbidden_side_effects: [1, 2] diff --git a/test-e2e/cases/MOS-D5-001/execution.yaml b/test-e2e/cases/MOS-D5-001/execution.yaml new file mode 100644 index 0000000000..72aa6b6d07 --- /dev/null +++ b/test-e2e/cases/MOS-D5-001/execution.yaml @@ -0,0 +1,16 @@ +schema_version: '1.0' +case_id: MOS-D5-001 +implementations: +- framework: pytest + file: test.py + selector: MOS-D5-001 + profiles: [real_smoke] +acceptance_bindings: + AC-01: [test_upgrade_preserves_values_and_repeats] + AC-02: [test_upgrade_preserves_values_and_repeats] + AC-03: [test_failed_upgrade_rolls_back_and_recovers] +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/MOS-D5-001/test.py b/test-e2e/cases/MOS-D5-001/test.py new file mode 100644 index 0000000000..e4a9cca0eb --- /dev/null +++ b/test-e2e/cases/MOS-D5-001/test.py @@ -0,0 +1,111 @@ +"""Actual PostgreSQL upgrade, idempotence and transactional recovery.""" + +import json +from pathlib import Path + +import psycopg2 +import pytest +from shared.factories.scratch_database import isolated_schema_database +from shared.postgres import postgres_connect_kwargs, postgres_scalar, postgres_sql + +pytestmark = [pytest.mark.case_id("MOS-D5-001"), pytest.mark.stage("D5")] +ROOT = Path(__file__).resolve().parents[3] +COLUMNS = { + "reasoning_supported": "boolean", + "reasoning_controls": "jsonb", + "native_tool_call_supported": "boolean", + "structured_output_supported": "boolean", + "temperature_supported": "boolean", + "top_p_supported": "boolean", + "input_modalities": "jsonb", + "output_modalities": "jsonb", + "enable_thinking": "boolean", + "reasoning_effort": "character varying", + "reasoning_budget_tokens": "integer", + "protocol_id": "character varying", + "resolved_profile": "jsonb", + "profile_schema_version": "integer", + "config_revision": "integer", +} + + +def migration_sql(): + # D5 deliberately locks the deployment artifact, rather than a business API. + candidates = list((ROOT / "deploy/sql/migrations").glob("*_model_outlet_profile_columns.sql")) + assert len(candidates) == 1 + return candidates[0].read_text(encoding="utf-8") + + +def prepare_previous_table(): + postgres_sql("TRUNCATE nexent.model_record_t CASCADE;") + postgres_sql("ALTER TABLE nexent.model_record_t " + ", ".join( + f'DROP COLUMN IF EXISTS "{name}"' for name in COLUMNS + ) + ";") + postgres_sql(""" + INSERT INTO nexent.model_record_t + (model_name, model_factory, base_url, context_window_tokens, + max_input_tokens, max_output_tokens, ssl_verify, temperature, top_p, + extra_params, tenant_id, delete_flag) + VALUES ('Exact/OLD', 'custom-provider', 'https://fixture.invalid/v1', + 200000, 180000, 20000, false, 0, 0, + '{"enable_thinking":false,"vendor_limit":0}'::jsonb, + 'schema-test', 'N'); + """) + return json.loads(postgres_scalar( + "SELECT row_to_json(t) FROM nexent.model_record_t t;" + )) + + +def metadata(): + return json.loads(postgres_scalar(""" + SELECT json_agg(json_build_object( + 'name', a.attname, 'type', format_type(a.atttypid, a.atttypmod), + 'not_null', a.attnotnull, + 'default', pg_get_expr(d.adbin, d.adrelid), + 'comment', col_description(a.attrelid, a.attnum)) ORDER BY a.attname) + FROM pg_attribute a LEFT JOIN pg_attrdef d + ON d.adrelid=a.attrelid AND d.adnum=a.attnum + WHERE a.attrelid='nexent.model_record_t'::regclass + AND a.attnum>0 AND NOT a.attisdropped; + """)) + + +def test_upgrade_preserves_values_and_repeats(): + with isolated_schema_database(): + before = prepare_previous_table() + postgres_sql("BEGIN;\n" + migration_sql() + "\nCOMMIT;") + after = json.loads(postgres_scalar("SELECT row_to_json(t) FROM nexent.model_record_t t;")) + assert {key: after[key] for key in before} == before + assert after["config_revision"] == 1 + assert all(after[key] is None for key in COLUMNS if key != "config_revision") + initial_metadata = metadata() + fields = {field["name"]: field for field in initial_metadata} + for name, expected_type in COLUMNS.items(): + field = fields[name] + expected = "character varying(30)" if expected_type == "character varying" else expected_type + assert field["type"] == expected + assert field["not_null"] is (name == "config_revision") + assert field["default"] == ("1" if name == "config_revision" else None) + assert field["comment"] and field["comment"].isascii() + postgres_sql("BEGIN;\n" + migration_sql() + "\nCOMMIT;") + assert metadata() == initial_metadata + assert json.loads(postgres_scalar("SELECT row_to_json(t) FROM nexent.model_record_t t;")) == after + + +def test_failed_upgrade_rolls_back_and_recovers(): + with isolated_schema_database(): + before = prepare_previous_table() + connection = psycopg2.connect(**postgres_connect_kwargs()) + try: + with pytest.raises(psycopg2.errors.DivisionByZero), connection, connection.cursor() as cursor: + cursor.execute(migration_sql()) + cursor.execute("SELECT 1 / 0;") + finally: + connection.close() + assert not set(COLUMNS).intersection(field["name"] for field in metadata()) + assert json.loads(postgres_scalar("SELECT row_to_json(t) FROM nexent.model_record_t t;")) == before + postgres_sql("BEGIN;\n" + migration_sql() + "\nCOMMIT;") + assert set(COLUMNS).issubset(field["name"] for field in metadata()) + restored = json.loads(postgres_scalar("SELECT row_to_json(t) FROM nexent.model_record_t t;")) + assert {key: restored[key] for key in before} == before + assert restored["config_revision"] == 1 diff --git a/test-e2e/changes/requirements/MODEL-OUTLET-CAPABILITY-STORAGE-20261013.yaml b/test-e2e/changes/requirements/MODEL-OUTLET-CAPABILITY-STORAGE-20261013.yaml new file mode 100644 index 0000000000..a800340e88 --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-OUTLET-CAPABILITY-STORAGE-20261013.yaml @@ -0,0 +1,27 @@ +schema_version: '1.0' +change: + change_id: MODEL-OUTLET-CAPABILITY-STORAGE-20261013 + change_type: requirement + requirement_id: MODEL-OUTLET-CAPABILITY-STORAGE-20261013 + title: Preserve capability column ownership on outlet load + description: Strict capability partition and restoration from a single-row field set without catalog access. + affected_features: + existing: [SDK-MODEL-OUTLET-STORAGE] + added: [] + modified: [SDK-MODEL-OUTLET-STORAGE] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Preserve strict BOOLEAN/NULL status conversion, unknown/empty domains and detached round-trip values. + - Reject duplicated, incomplete, inconsistent, non-JSON or oversized capability storage without leaking values. + - Enforce positive INT4 context limits and existing typed reasoning/modality/response contracts after restoration. + reason: Existing contracts validate capabilities in memory, not their persisted ownership. Pure partition/restore belongs in UT; no extra D1 adds necessary component evidence. + test_selectors: [test/sdk/core/model_service/test_outlet_capabilities.py] + no_case_change_reason: This pure codec does not connect database or runtime paths. Preserve existing Cases; complete-row/database and migration verification remains required separately. + notes: Designed in TECHNICAL-DESIGN before implementation. This does not prove complete ResolvedModelProfile storage or real database execution. diff --git a/test-e2e/changes/requirements/MODEL-OUTLET-PARAMETER-STORAGE-20261013.yaml b/test-e2e/changes/requirements/MODEL-OUTLET-PARAMETER-STORAGE-20261013.yaml new file mode 100644 index 0000000000..8fbb3c3112 --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-OUTLET-PARAMETER-STORAGE-20261013.yaml @@ -0,0 +1,27 @@ +schema_version: '1.0' +change: + change_id: MODEL-OUTLET-PARAMETER-STORAGE-20261013 + change_type: requirement + requirement_id: MODEL-OUTLET-PARAMETER-STORAGE-20261013 + title: Preserve single-location outlet parameter values + description: Add strict saved-value validation and dedicated-column parameter split/restore without default application or TOML reads. + affected_features: + existing: [] + added: [SDK-MODEL-OUTLET-STORAGE] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Preserve omission, false, zero and explicit null without defaults or input mutation. + - Reject invalid saved schemas, dependencies, required or fixed values, duplicate storage and null markers. + - Enforce dedicated boolean, string length, finite number and INT4 column limits without exposing values in errors. + reason: Existing parameter tests cover configuration creation, not storage presence or strict snapshot loading. Pure codec proof belongs in UT; no duplicate D1 is created. + test_selectors: [test/sdk/core/model_service/test_outlet_parameters.py] + no_case_change_reason: No database, transport or user journey is connected. Preserve all existing formal Cases; design real database/deployment proof before implementing those boundaries. + notes: Only a storage prerequisite, not a complete ResolvedModelProfile or migration delivery. Documented in TECHNICAL-DESIGN before product implementation. diff --git a/test-e2e/changes/requirements/MODEL-OUTLET-PERSISTENCE-20261013.yaml b/test-e2e/changes/requirements/MODEL-OUTLET-PERSISTENCE-20261013.yaml new file mode 100644 index 0000000000..695da561ae --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-OUTLET-PERSISTENCE-20261013.yaml @@ -0,0 +1,30 @@ +schema_version: '1.0' +change: + change_id: MODEL-OUTLET-PERSISTENCE-20261013 + change_type: requirement + requirement_id: MODEL-OUTLET-PERSISTENCE-20261013 + title: Persist resolved model outlets transactionally + description: Shared-session creation/read/update with stable identity, tenant and revision checks. + affected_features: + existing: [] + added: [BE-MODEL-OUTLET-PERSISTENCE] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [MOP-D3-001] + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Validate new-record identity, credentials and shared-session lifecycle. + - Compile SQL with tenant, active state and expected revision predicates, preserving declaration immutability and parameter permissions. + - Sanitize SQL failures and reject stale or invalid updates without value disclosure. + - Recover integer sampling semantics from existing FLOAT columns and reject lossy integer storage. + reason: Existing CRUD tests do not exercise resolved snapshots. UT proves query/validation logic; D3 must prove actual transaction/CAS/database behavior. + test_selectors: + - test/backend/database/test_model_outlet_db.py + - test/sdk/core/model_service/test_outlet_storage.py + - test/sdk/core/model_service/test_outlet_parameters.py + notes: D3 designed before database implementation. Unit tests now exist; actual database script/execution remain pending. A FLOAT schema regression was reproduced before repair. No database transaction pass is claimed. diff --git a/test-e2e/changes/requirements/MODEL-OUTLET-ROW-STORAGE-20261013.yaml b/test-e2e/changes/requirements/MODEL-OUTLET-ROW-STORAGE-20261013.yaml new file mode 100644 index 0000000000..702ec330a9 --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-OUTLET-ROW-STORAGE-20261013.yaml @@ -0,0 +1,27 @@ +schema_version: '1.0' +change: + change_id: MODEL-OUTLET-ROW-STORAGE-20261013 + change_type: requirement + requirement_id: MODEL-OUTLET-ROW-STORAGE-20261013 + title: Encode and restore complete resolved outlet rows + description: Compose existing codecs under a strict ResolvedModelProfile and same-row restoration with trusted version availability checks. + affected_features: + existing: [SDK-MODEL-OUTLET-STORAGE] + added: [] + modified: [SDK-MODEL-OUTLET-STORAGE] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Strict full profile and row round trips preserve identity, saved values, same-row domains and detached snapshots without defaults or source reads. + - Credentials are references to this outlet; raw API keys and copied protocol/identity/value fields are excluded from generated storage. + - Invalid or incomplete rows, wrong versions, unavailable implementations, conflicting extra parameters, oversized profiles and corrupted domains fail explicitly. + reason: Existing group codecs cannot prove complete object ownership and restoration. These SDK codec tests remain UT; database integration and deployment proof add the necessary higher boundary instead of duplicating inputs in a D1 wrapper. + test_selectors: [test/sdk/core/model_service/test_outlet_storage.py] + no_case_change_reason: No database transaction or runtime provider request is connected in this slice. Those boundaries still require separate formal implementation and execution; no existing Case is removed. + notes: This does not establish actual transport registration or provider qualification. The tests supply a controlled availability snapshot; production must use its real registry. diff --git a/test-e2e/changes/requirements/MODEL-OUTLET-RULE-STORAGE-20261013.yaml b/test-e2e/changes/requirements/MODEL-OUTLET-RULE-STORAGE-20261013.yaml new file mode 100644 index 0000000000..acfef557be --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-OUTLET-RULE-STORAGE-20261013.yaml @@ -0,0 +1,27 @@ +schema_version: '1.0' +change: + change_id: MODEL-OUTLET-RULE-STORAGE-20261013 + change_type: requirement + requirement_id: MODEL-OUTLET-RULE-STORAGE-20261013 + title: Restore saved parameter rules without duplicated domains + description: Replace exact duplicate schema domains and saved fixed values with strict same-row references. + affected_features: + existing: [SDK-MODEL-OUTLET-STORAGE] + added: [] + modified: [SDK-MODEL-OUTLET-STORAGE] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Exact enum and numeric domain references restore the original schema while extra restrictions remain unchanged. + - Fixed false, zero and null use saved key presence; inactive rules retain their independent constraints without defaults applied. + - Missing, malformed, duplicate or inconsistent references and oversized/non-JSON data fail without changing declarations or exposing values. + reason: Existing rule tests validate effective policies, not storage reference syntax or lossless reconstruction. Pure codec proof stays in UT; no duplicate D1 is needed. + test_selectors: [test/sdk/core/model_service/test_outlet_rules.py] + no_case_change_reason: Database and runtime are not connected by this codec. Full-row and database transaction verification remain required separately; historical formal Cases are preserved. + notes: Reference syntax was documented before implementation. The change is not a complete outlet storage or migration acceptance claim. diff --git a/test-e2e/changes/requirements/MODEL-OUTLET-SCHEMA-20261013.yaml b/test-e2e/changes/requirements/MODEL-OUTLET-SCHEMA-20261013.yaml new file mode 100644 index 0000000000..76770a9dec --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-OUTLET-SCHEMA-20261013.yaml @@ -0,0 +1,28 @@ +schema_version: '1.0' +change: + change_id: MODEL-OUTLET-SCHEMA-20261013 + change_type: requirement + requirement_id: MODEL-OUTLET-SCHEMA-20261013 + title: Extend model outlet storage schema + description: Add capability/configuration columns and strict top_p capability support without legacy data conversion. + affected_features: + existing: [] + added: [BE-MODEL-OUTLET-SCHEMA] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [MOS-D5-001] + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - ORM columns expose the reviewed types, unknown defaults and emitted database comments. + - top_p capability has strict supported/unsupported/unknown semantics and a versioned explicit override path. + reason: Existing unit tests do not cover outlet schema metadata or top_p; actual SQL execution and failure recovery require distinct D5 proof. + test_selectors: + - test/backend/database/test_model_outlet_schema.py + - test/sdk/core/model_service/test_profile_capabilities.py + - test/sdk/core/model_service/test_composition.py + notes: Designed before schema implementation. Unit selectors now exist; formal script and actual PostgreSQL verification remain pending. No SQL execution pass is claimed. diff --git a/test-e2e/features/BE-MODEL-OUTLET-PERSISTENCE/cases.md b/test-e2e/features/BE-MODEL-OUTLET-PERSISTENCE/cases.md new file mode 100644 index 0000000000..5cd0ff0a1f --- /dev/null +++ b/test-e2e/features/BE-MODEL-OUTLET-PERSISTENCE/cases.md @@ -0,0 +1,5 @@ +# BE-MODEL-OUTLET-PERSISTENCE 关联用例 + +此页由结构化用例生成,不手工编辑。 + +- D3 [MOP-D3-001](../../cases/MOP-D3-001/case.yaml) · active diff --git a/test-e2e/features/BE-MODEL-OUTLET-PERSISTENCE/feature.yaml b/test-e2e/features/BE-MODEL-OUTLET-PERSISTENCE/feature.yaml new file mode 100644 index 0000000000..af9be613ef --- /dev/null +++ b/test-e2e/features/BE-MODEL-OUTLET-PERSISTENCE/feature.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' +module: + id: MODEL-SERVICE + name: Model service +feature: + feature_id: BE-MODEL-OUTLET-PERSISTENCE + name: Transactional resolved outlet persistence + status: planned + description: Persist and restore complete model configuration with tenant isolation and optimistic updates. + business_rules: + - rule_id: BE-MODEL-OUTLET-PERSISTENCE-RULE-01 + description: Creation and restoration use one row and a caller-owned transaction without source composition or duplicate credentials. + - rule_id: BE-MODEL-OUTLET-PERSISTENCE-RULE-02 + description: Reads and updates enforce tenant and active state; updates check expected revision and reject immutable identity/rule changes. + - rule_id: BE-MODEL-OUTLET-PERSISTENCE-RULE-03 + description: SQL failures and stale updates cannot partially write configuration; errors do not expose credentials. + required_test_stages: [D3] + source: + requirement_ids: [MODEL-OUTLET-PERSISTENCE-20261013] + notes: Backend storage boundary; UI, request transport and legacy conversion remain separate work. diff --git a/test-e2e/features/BE-MODEL-OUTLET-SCHEMA/cases.md b/test-e2e/features/BE-MODEL-OUTLET-SCHEMA/cases.md new file mode 100644 index 0000000000..677e8060df --- /dev/null +++ b/test-e2e/features/BE-MODEL-OUTLET-SCHEMA/cases.md @@ -0,0 +1,5 @@ +# BE-MODEL-OUTLET-SCHEMA 关联用例 + +此页由结构化用例生成,不手工编辑。 + +- D5 [MOS-D5-001](../../cases/MOS-D5-001/case.yaml) · active diff --git a/test-e2e/features/BE-MODEL-OUTLET-SCHEMA/feature.yaml b/test-e2e/features/BE-MODEL-OUTLET-SCHEMA/feature.yaml new file mode 100644 index 0000000000..7362855be0 --- /dev/null +++ b/test-e2e/features/BE-MODEL-OUTLET-SCHEMA/feature.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' +module: + id: MODEL-SERVICE + name: Model service +feature: + feature_id: BE-MODEL-OUTLET-SCHEMA + name: Model outlet schema extension + status: planned + description: Add single-row capability and parameter storage without changing existing model configuration values. + business_rules: + - rule_id: BE-MODEL-OUTLET-SCHEMA-RULE-01 + description: Capability columns preserve unknown as NULL; schema upgrade does not infer support or copy existing values. + - rule_id: BE-MODEL-OUTLET-SCHEMA-RULE-02 + description: Types, comments and revision default match the reviewed storage contract; no atomic profile tables or redundant indexes are added. + - rule_id: BE-MODEL-OUTLET-SCHEMA-RULE-03 + description: Repeated schema execution preserves existing data and metadata; failed transactional execution rolls back and permits a normal retry. + required_test_stages: [D5] + source: + requirement_ids: [MODEL-OUTLET-SCHEMA-20261013] + notes: Schema only; saved-profile codec, runtime consumption and legacy data migration remain separate requirements. diff --git a/test-e2e/features/SDK-MODEL-OUTLET-STORAGE/cases.md b/test-e2e/features/SDK-MODEL-OUTLET-STORAGE/cases.md new file mode 100644 index 0000000000..50340484f7 --- /dev/null +++ b/test-e2e/features/SDK-MODEL-OUTLET-STORAGE/cases.md @@ -0,0 +1,5 @@ +# SDK-MODEL-OUTLET-STORAGE 关联用例 + +此页由结构化用例生成,不手工编辑。 + +当前测试基线未保留关联用例。 diff --git a/test-e2e/features/SDK-MODEL-OUTLET-STORAGE/feature.yaml b/test-e2e/features/SDK-MODEL-OUTLET-STORAGE/feature.yaml new file mode 100644 index 0000000000..396997e055 --- /dev/null +++ b/test-e2e/features/SDK-MODEL-OUTLET-STORAGE/feature.yaml @@ -0,0 +1,26 @@ +schema_version: '1.0' +module: + id: SDK-MODEL-SERVICE + name: SDK model service +feature: + feature_id: SDK-MODEL-OUTLET-STORAGE + name: Resolved outlet value storage + status: planned + description: Preserve saved parameter presence and capability ownership across dedicated columns and remaining JSON. + business_rules: + - rule_id: SDK-MODEL-OUTLET-STORAGE-RULE-01 + description: Loading never applies defaults or fixed values and never removes conflicting saved values; invalid snapshots fail. + - rule_id: SDK-MODEL-OUTLET-STORAGE-RULE-02 + description: Common parameters have one dedicated column; only other values enter the parameter bag. False, zero and explicit null remain distinct from omission. + - rule_id: SDK-MODEL-OUTLET-STORAGE-RULE-03 + description: Dedicated SQL null means omission unless its allowed key is explicitly marked. Duplicate, unknown or conflicting markers and duplicate storage fail. + - rule_id: SDK-MODEL-OUTLET-STORAGE-RULE-04 + description: Capability status, context, controls and modalities live only in their dedicated columns; remaining details are strict and complete, with no inference or duplicate storage. + - rule_id: SDK-MODEL-OUTLET-STORAGE-RULE-05 + description: Exact duplicated control domains and active fixed values use strict same-row references; restoration preserves additional restrictions and never applies defaults or repairs missing values. + - rule_id: SDK-MODEL-OUTLET-STORAGE-RULE-06 + description: Complete outlet identity, connection, instruction and implementation versions restore from the same row; raw credentials, source loading and fallback are excluded. Incomplete rows and unavailable versions fail. + required_test_stages: [] + source: + requirement_ids: [MODEL-OUTLET-PARAMETER-STORAGE-20261013, MODEL-OUTLET-CAPABILITY-STORAGE-20261013, MODEL-OUTLET-RULE-STORAGE-20261013, MODEL-OUTLET-ROW-STORAGE-20261013] + notes: SDK row codec of SPEC AC-01/03. Database writer, legacy data conversion and runtime consumption are not connected yet. Those boundaries require additional integration and deployment evidence. diff --git a/test/backend/database/test_model_outlet_db.py b/test/backend/database/test_model_outlet_db.py new file mode 100644 index 0000000000..15fa74b6a8 --- /dev/null +++ b/test/backend/database/test_model_outlet_db.py @@ -0,0 +1,489 @@ +"""Unit query/validation proof; actual PostgreSQL transactions belong to MOP-D3-001.""" + +import importlib.util +import sys +from contextlib import contextmanager +from pathlib import Path +from types import ModuleType +from unittest.mock import MagicMock + +import pytest +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.profiles.instruction_mapping import ( + resolve_instruction_mapping, +) +from nexent.core.model_service.profiles.outlet_storage import ( + encode_resolved_model_profile, +) +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile +from sqlalchemy.dialects import postgresql +from sqlalchemy.exc import SQLAlchemyError + +ROOT = Path(__file__).resolve().parents[3] +REFERENCE = VersionedReference(ref="fixture_v1", version=1) +REGISTRY = { + key: [REFERENCE] + for key in ( + "protocol", + "auth", + "adapter", + "code_converter", + "native_converter", + "parameter_transform", + ) +} + + +def load(name, path, monkeypatch): + spec = importlib.util.spec_from_file_location(name, ROOT / path) + module = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, name, module) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def database(monkeypatch): + # Use actual metadata/exception definitions without importing configured DB clients. + for package, directory in ( + ("_outlet_unit_consts", "backend/consts"), + ("_outlet_unit_db", "backend/database"), + ): + module = ModuleType(package) + module.__path__ = [str(ROOT / directory)] + monkeypatch.setitem(sys.modules, package, module) + exceptions = load( + "_outlet_unit_consts.exceptions", "backend/consts/exceptions.py", monkeypatch + ) + monkeypatch.setitem(sys.modules, "consts.exceptions", exceptions) + client = ModuleType("_outlet_unit_db.client") + client.sessions = [] + + @contextmanager + def get_session(session=None): + client.sessions.append(session) + yield session + + client.get_db_session = get_session + monkeypatch.setitem(sys.modules, client.__name__, client) + database = load( + "_outlet_unit_db.model_outlet_db", + "backend/database/model_outlet_db.py", + monkeypatch, + ) + return database, client, exceptions + + +@pytest.fixture +def profile(): + role = VersionedReference(ref="system_only_v1", version=1) + return ResolvedModelProfile.model_validate( + { + "schema_version": 1, + "outlet_id": 12, + "config_revision": 1, + "identity": { + "tenant_id": "tenant-one", + "display_name": "display", + "model_type": "llm", + "provider_id": "provider", + "request_model_id": "Exact/Model", + "provider_key": None, + "model_profile_key": None, + }, + "connection": { + "base_url": "http://localhost/v1", + "ssl_verify": False, + "timeout_seconds": 120, + "concurrency_limit": None, + "credential_ref": {"kind": "model_record", "model_id": 12}, + }, + "connection_rules": { + "credential_kind": "model_record", + "relative_path": "/chat/completions", + "method": "POST", + "headers": {}, + }, + "context_configuration": { + "output_reserve_tokens": None, + "tokenizer_family": None, + "capacity_source": None, + "capability_profile_version": None, + }, + "capabilities": {}, + "instruction_mapping": resolve_instruction_mapping(role).model_dump( + mode="json" + ), + "parameter_rules": [ + { + "key": "custom", + "schema": {}, + "visible": True, + "editable": True, + "required": False, + "allowed_scopes": ["outlet", "conversation"], + "ui_group": "common", + "owner": "provider", + "source": {}, + "api_binding": { + "path": "/custom", + "transform_ref": "fixture_v1", + "transform_version": 1, + }, + } + ], + "parameter_values": {"custom": False}, + "extra_api_parameters": [], + "bindings": { + **{ + key: REFERENCE.model_dump() + for key in REGISTRY + if key != "parameter_transform" + }, + "role_mapping": role.model_dump(), + }, + "provenance": { + "atomic_refs": { + "model": None, + "provider": None, + "provider_model": None, + }, + "interpretation_versions": { + "source_parser": None, + "source_mapping": None, + "supplement_schema": None, + "composer": None, + }, + "overrides": [], + "field_sources": [], + "provider_metadata": {}, + }, + } + ) + + +def row(profile): + return encode_resolved_model_profile(profile, registered_references=REGISTRY) + + +def statement(session, index=0): + return ( + session.execute.call_args_list[index] + .args[0] + .compile(dialect=postgresql.dialect()) + ) + + +def update(database, profile, session, **kwargs): + return database.update_resolved_model_record( + profile, + 1, + "actor", + "tenant-one", + registered_references=REGISTRY, + db_session=session, + **kwargs, + ) + + +def test_creation_returns_id_and_keeps_shared_session_owned_by_caller( + database, profile +): + db, client, _ = database + value = profile.model_dump(mode="json") + value["outlet_id"] = value["connection"]["credential_ref"]["model_id"] = None + session = MagicMock() + session.execute.return_value.scalar_one.return_value = 23 + assert ( + db.create_resolved_model_record( + ResolvedModelProfile.model_validate(value), + "actor", + "tenant-one", + registered_references=REGISTRY, + api_key="dummy-private", + db_session=session, + ) + == 23 + ) + compiled = statement(session) + assert compiled.params["api_key"] == "dummy-private" + assert "dummy-private" not in repr(compiled.params["resolved_profile"]) + assert compiled.params["delete_flag"] == "N" + assert compiled.params["created_by"] == compiled.params["updated_by"] == "actor" + assert client.sessions == [session] + session.commit.assert_not_called() + session.rollback.assert_not_called() + session.close.assert_not_called() + + +def test_reads_use_tenant_and_active_predicates(database, profile): + db, _, _ = database + session = MagicMock() + session.execute.return_value.mappings.return_value.first.return_value = row(profile) + result = db.get_resolved_model_record( + 12, "tenant-one", registered_references=REGISTRY, db_session=session + ) + assert result.model_dump() == profile.model_dump() + compiled = statement(session) + assert "model_record_t.model_id =" in str(compiled) + assert "model_record_t.tenant_id =" in str(compiled) + assert "model_record_t.delete_flag =" in str(compiled) + assert {12, "tenant-one", "N"} <= set(compiled.params.values()) + + +def test_missing_rows_do_not_construct_a_snapshot(database): + db, _, _ = database + session = MagicMock() + session.execute.return_value.mappings.return_value.first.return_value = None + assert ( + db.get_resolved_model_record( + 12, "tenant-one", registered_references=REGISTRY, db_session=session + ) + is None + ) + + +@pytest.mark.parametrize("api_key", [None, "updated-dummy", ""]) +def test_update_is_one_atomic_cas_write_with_optional_credential( + database, profile, api_key +): + db, client, _ = database + session = MagicMock() + read_result, write_result = MagicMock(), MagicMock() + read_result.mappings.return_value.first.return_value = row(profile) + write_result.scalar_one_or_none.return_value = 2 + session.execute.side_effect = [read_result, write_result] + value = profile.model_dump(mode="json") + value["parameter_values"]["custom"] = 0 + proposed = ResolvedModelProfile.model_validate(value) + assert update(db, proposed, session, api_key=api_key) == 2 + compiled = statement(session, 1) + assert "model_record_t.config_revision =" in str(compiled) + assert "model_record_t.tenant_id =" in str(compiled) + assert "model_record_t.delete_flag =" in str(compiled) + assert compiled.params["config_revision"] == 2 + assert compiled.params["config_revision_1"] == 1 + assert compiled.params["model_id_1"] == 12 + assert compiled.params["tenant_id_1"] == "tenant-one" + assert compiled.params["delete_flag_1"] == "N" + assert compiled.params["api_key"] == api_key + assert compiled.params["extra_params"]["parameter_values"] == {"custom": 0} + assert client.sessions == [session, session] + session.commit.assert_not_called() + session.close.assert_not_called() + + +def test_omitted_credential_is_not_cleared(database, profile): + db, _, _ = database + session = MagicMock() + read_result, write_result = MagicMock(), MagicMock() + read_result.mappings.return_value.first.return_value = row(profile) + write_result.scalar_one_or_none.return_value = 2 + session.execute.side_effect = [read_result, write_result] + update(db, profile, session) + assert "api_key" not in statement(session, 1).params + + +@pytest.mark.parametrize("current", [None, "stale"]) +def test_missing_or_stale_update_has_no_write(database, profile, current): + db, _, errors = database + session = MagicMock() + saved = row(profile) if current else None + if saved: + saved["config_revision"] = 2 + session.execute.return_value.mappings.return_value.first.return_value = saved + with pytest.raises(errors.ModelOutletConflictError): + update(db, profile, session) + assert session.execute.call_count == 1 + + +def test_cas_race_after_read_raises_conflict(database, profile): + db, _, errors = database + session = MagicMock() + read_result, write_result = MagicMock(), MagicMock() + read_result.mappings.return_value.first.return_value = row(profile) + write_result.scalar_one_or_none.return_value = None + session.execute.side_effect = [read_result, write_result] + with pytest.raises(errors.ModelOutletConflictError): + update(db, profile, session) + + +@pytest.mark.parametrize("bad", ["x" * 501, 0, "bad\x00key", "bad\ud800"]) +def test_invalid_credentials_are_rejected_before_any_query(database, profile, bad): + db, client, errors = database + session = MagicMock() + with pytest.raises(errors.ModelOutletValidationError): + update(db, profile, session, api_key=bad) + assert client.sessions == [] + session.execute.assert_not_called() + + +def test_sql_errors_are_sanitized_without_private_parameters(database): + db, _, errors = database + session = MagicMock() + session.execute.side_effect = SQLAlchemyError("dummy-credential-private") + with pytest.raises(errors.ModelOutletDatabaseError) as captured: + db.get_resolved_model_record( + 12, "tenant-one", registered_references=REGISTRY, db_session=session + ) + assert "dummy-credential-private" not in str(captured.value) + assert captured.value.__suppress_context__ is True + + +def test_immutable_definition_change_is_rejected(database, profile): + db, _, errors = database + session = MagicMock() + session.execute.return_value.mappings.return_value.first.return_value = row(profile) + value = profile.model_dump(mode="json") + value["identity"]["request_model_id"] = "another-model" + with pytest.raises(errors.ModelOutletValidationError): + update(db, ResolvedModelProfile.model_validate(value), session) + assert session.execute.call_count == 1 + + +def test_readonly_parameter_deletion_is_rejected(database, profile): + db, _, errors = database + value = profile.model_dump(mode="json") + value["parameter_rules"][0]["editable"] = False + current = ResolvedModelProfile.model_validate(value) + session = MagicMock() + session.execute.return_value.mappings.return_value.first.return_value = row(current) + value["parameter_values"] = {} + with pytest.raises(errors.ModelOutletValidationError): + update(db, ResolvedModelProfile.model_validate(value), session) + assert session.execute.call_count == 1 + + +@pytest.mark.parametrize("actor", [None, "", "x" * 101, "bad\x00actor", "bad\ud800"]) +def test_invalid_actors_never_enter_database_session(database, profile, actor): + db, client, errors = database + with pytest.raises(errors.ModelOutletValidationError): + db.update_resolved_model_record( + profile, + 1, + actor, + "tenant-one", + registered_references=REGISTRY, + db_session=MagicMock(), + ) + assert client.sessions == [] + + +@pytest.mark.parametrize("model_id", [None, True, 0, 2**31]) +def test_invalid_model_ids_are_rejected_before_read(database, model_id): + db, client, errors = database + with pytest.raises(errors.ModelOutletValidationError): + db.get_resolved_model_record( + model_id, + "tenant-one", + registered_references=REGISTRY, + db_session=MagicMock(), + ) + assert client.sessions == [] + + +@pytest.mark.parametrize("revision", [True, 0, 2, 2**31 - 1]) +def test_invalid_revision_does_not_query(database, profile, revision): + db, client, errors = database + with pytest.raises(errors.ModelOutletValidationError): + db.update_resolved_model_record( + profile, + revision, + "actor", + "tenant-one", + registered_references=REGISTRY, + db_session=MagicMock(), + ) + assert client.sessions == [] + + +def test_create_cannot_reuse_existing_identifier(database, profile): + db, client, errors = database + with pytest.raises(errors.ModelOutletValidationError): + db.create_resolved_model_record( + profile, + "actor", + "tenant-one", + registered_references=REGISTRY, + db_session=MagicMock(), + ) + assert client.sessions == [] + + +def test_snapshot_tenant_mismatch_is_not_silently_reassigned(database, profile): + db, client, errors = database + with pytest.raises(errors.ModelOutletValidationError): + db.update_resolved_model_record( + profile, + 1, + "actor", + "tenant-two", + registered_references=REGISTRY, + db_session=MagicMock(), + ) + assert client.sessions == [] + + +@pytest.mark.parametrize( + "change", + [ + lambda d: d["parameter_rules"][0].update(source={"changed": True}), + lambda d: d["connection_rules"]["headers"].update(Accept="new"), + lambda d: d["capabilities"].update(structured_output={"status": "supported"}), + ], +) +def test_saved_definitions_cannot_be_replaced_during_update(database, profile, change): + db, _, errors = database + value = profile.model_dump(mode="json") + change(value) + session = MagicMock() + session.execute.return_value.mappings.return_value.first.return_value = row(profile) + with pytest.raises(errors.ModelOutletValidationError): + update(db, ResolvedModelProfile.model_validate(value), session) + assert session.execute.call_count == 1 + + +def test_nul_inside_json_is_rejected_before_postgresql_can_log_query_parameters( + database, profile +): + db, client, errors = database + value = profile.model_dump(mode="json") + value["parameter_values"]["custom"] = {"nested": ["private\x00value"]} + with pytest.raises(errors.ModelOutletValidationError) as captured: + update(db, ResolvedModelProfile.model_validate(value), MagicMock()) + assert "private" not in str(captured.value) + assert client.sessions == [] + + +@pytest.mark.parametrize( + "name", + [ + "create_resolved_model_record", + "get_resolved_model_record", + "update_resolved_model_record", + ], +) +def test_model_management_entry_delegates_without_legacy_filter( + database, monkeypatch, name +): + db, client, _ = database + constants = ModuleType("consts.const") + constants.DEFAULT_EXPECTED_CHUNK_SIZE = 1024 + constants.DEFAULT_MAXIMUM_CHUNK_SIZE = 1536 + monkeypatch.setitem(sys.modules, "consts.const", constants) + model_constants = ModuleType("consts.model") + model_constants.filter_extra_params = MagicMock( + side_effect=AssertionError("New profile must not enter legacy filter") + ) + monkeypatch.setitem(sys.modules, "consts.model", model_constants) + client.as_dict, client.db_client = MagicMock(), MagicMock() + delegated = MagicMock(return_value=42) + monkeypatch.setattr(db, name, delegated) + facade = load( + "_outlet_unit_db.model_management_db", + "backend/database/model_management_db.py", + monkeypatch, + ) + assert getattr(facade, name)("profile-or-id", tenant_id="tenant-one") == 42 + delegated.assert_called_once_with("profile-or-id", tenant_id="tenant-one") + model_constants.filter_extra_params.assert_not_called() diff --git a/test/backend/database/test_model_outlet_schema.py b/test/backend/database/test_model_outlet_schema.py new file mode 100644 index 0000000000..fc73ace22f --- /dev/null +++ b/test/backend/database/test_model_outlet_schema.py @@ -0,0 +1,75 @@ +"""ORM schema contract; actual PostgreSQL migration execution is a separate D5 proof.""" + +import importlib.util +from pathlib import Path + +import pytest +from sqlalchemy.dialects import postgresql +from sqlalchemy.schema import CreateColumn, SetColumnComment + + +@pytest.fixture(scope="module") +def table(): + # This metadata module has no database clients or configuration side effects. + path = Path(__file__).resolve().parents[3] / "backend/database/db_models.py" + spec = importlib.util.spec_from_file_location("outlet_schema_metadata", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.ModelRecord.__table__ + + +@pytest.mark.parametrize( + "name,sql_type", + [ + ("reasoning_supported", "BOOLEAN"), + ("reasoning_controls", "JSONB"), + ("native_tool_call_supported", "BOOLEAN"), + ("structured_output_supported", "BOOLEAN"), + ("temperature_supported", "BOOLEAN"), + ("top_p_supported", "BOOLEAN"), + ("input_modalities", "JSONB"), + ("output_modalities", "JSONB"), + ("enable_thinking", "BOOLEAN"), + ("reasoning_effort", "VARCHAR(30)"), + ("reasoning_budget_tokens", "INTEGER"), + ("protocol_id", "VARCHAR(30)"), + ("resolved_profile", "JSONB"), + ("profile_schema_version", "INTEGER"), + ], +) +def test_new_fields_have_reviewed_types_and_no_inferred_default(table, name, sql_type): + column = table.c[name] + assert str(column.type.compile(dialect=postgresql.dialect())) == sql_type + assert column.nullable is True + assert column.default is None + assert column.server_default is None + assert column.comment and column.comment.isascii() + assert column.comment in str( + SetColumnComment(column).compile(dialect=postgresql.dialect()) + ) + + +def test_revision_has_database_default_for_old_and_new_rows(table): + column = table.c.config_revision + assert column.nullable is False + assert column.default.arg == 1 + assert str(column.server_default.arg) == "1" + assert "DEFAULT 1 NOT NULL" in str( + CreateColumn(column).compile(dialect=postgresql.dialect()) + ) + assert column.comment in str( + SetColumnComment(column).compile(dialect=postgresql.dialect()) + ) + + +def test_existing_value_columns_remain_single_storage_locations(table): + expected = { + "temperature": "FLOAT", + "top_p": "FLOAT", + "extra_params": "JSONB", + "api_key": "VARCHAR(500)", + } + for name, sql_type in expected.items(): + assert str(table.c[name].type.compile(dialect=postgresql.dialect())) == sql_type + assert not table.c.reasoning_controls.index + assert not table.c.native_tool_call_supported.index diff --git a/test/sdk/core/model_service/test_composition.py b/test/sdk/core/model_service/test_composition.py index 856b31b137..102944ecb6 100644 --- a/test/sdk/core/model_service/test_composition.py +++ b/test/sdk/core/model_service/test_composition.py @@ -74,6 +74,38 @@ def catalog(sources, *, model=None, provider=None, combo=None): ) +def test_top_p_override_has_explicit_support_and_versioned_revision( + sources, monkeypatch +): + from nexent.core.model_service.profiles import composition + + override = { + "path": "/sampling/top_p", + "value": {"status": "unsupported"}, + "reason": "Service-specific support", + "evidence": ["fixture"], + } + current = catalog(sources, combo={"capability_overrides": [override]}) + assert ( + current.provider_models["provider/exact"].capabilities["sampling"]["top_p"][ + "status" + ] + == "unsupported" + ) + assert ( + current.models["family/model"].capabilities["sampling"]["top_p"]["status"] + == "unknown" + ) + assert composition.COMPOSER_REF == "profile_policy_composer_v2" + monkeypatch.setattr(composition, "COMPOSER_REF", "profile_policy_composer_v1") + previous = catalog(sources, combo={"capability_overrides": [override]}) + assert current.revision != previous.revision + assert ( + current.models["family/model"].revision + != previous.models["family/model"].revision + ) + + def compose(catalog, **changes): options = { "provider_key": "provider", diff --git a/test/sdk/core/model_service/test_outlet_capabilities.py b/test/sdk/core/model_service/test_outlet_capabilities.py new file mode 100644 index 0000000000..7334bd21bf --- /dev/null +++ b/test/sdk/core/model_service/test_outlet_capabilities.py @@ -0,0 +1,236 @@ +"""Capability storage ownership preserves tri-state declarations and rejects corruption.""" + +from copy import deepcopy + +import pytest +from nexent.core.model_service.profiles.contracts import ProfileCapabilities +from nexent.core.model_service.profiles.outlet_capabilities import ( + CapabilityStorageError, + restore_outlet_capabilities, + split_outlet_capabilities, +) + + +def restore(stored): + return restore_outlet_capabilities( + columns=stored.columns, capability_details=stored.capability_details + ) + + +@pytest.mark.parametrize( + "status,column_value", + [("supported", True), ("unsupported", False), ("unknown", None)], +) +def test_all_support_columns_preserve_tri_state(status, column_value): + value = ProfileCapabilities.model_validate( + { + "reasoning": {"status": status}, + "tools": {"status": status}, + "structured_output": {"status": status}, + "sampling": { + "temperature": {"status": status}, + "top_p": {"status": status}, + }, + } + ) + stored = split_outlet_capabilities(value) + for column in ( + "reasoning_supported", + "native_tool_call_supported", + "structured_output_supported", + "temperature_supported", + "top_p_supported", + ): + assert stored.columns[column] is column_value + assert restore(stored).model_dump() == value.model_dump() + + +def test_round_trip_has_no_duplicate_capability_values_and_is_detached(): + value = ProfileCapabilities.model_validate( + { + "context": { + "combined_window": 200000, + "input_limit": 180000, + "output_limit": 10000, + }, + "reasoning": { + "status": "supported", + "controls": [{"mode": "toggle", "values": [False, True]}], + "response_paths": ["reasoning_content"], + }, + "tools": { + "status": "supported", + "schema_constraints": {"ref": "tools_v1", "version": 1}, + "result_format": "tool", + }, + "modalities": {"input": ["text", "image"], "output": ["text"]}, + "response": { + "supported_finish_reasons": ["stop", "tool_calls"], + "stream_format": "sse", + }, + "usage": { + "semantics_id": "tokens_v1", + "input_includes_cached": "supported", + "output_includes_reasoning": "unknown", + }, + } + ) + original = value.model_dump() + stored = split_outlet_capabilities(value) + assert stored.columns["context_window_tokens"] == 200000 + assert stored.columns["reasoning_controls"][0]["values"] == [False, True] + assert set(stored.capability_details) == {"reasoning", "tools", "response", "usage"} + assert set(stored.capability_details["reasoning"]) == {"response_paths"} + assert set(stored.capability_details["tools"]) == { + "schema_constraints", + "result_format", + } + assert restore(stored).model_dump() == original + restored = restore(stored) + restored.modalities.input.append("audio") + restored.reasoning.controls[0].values.pop() + assert value.model_dump() == original + assert stored.columns["input_modalities"] == ["text", "image"] + value.modalities.input.append("video") + assert stored.columns["input_modalities"] == ["text", "image"] + + +@pytest.mark.parametrize("controls,modalities", [(None, None), ([], [])]) +def test_unknown_and_explicit_empty_domains_remain_distinct(controls, modalities): + value = ProfileCapabilities.model_validate( + {"reasoning": {"controls": controls}, "modalities": {"input": modalities}} + ) + restored = restore(split_outlet_capabilities(value)) + assert restored.reasoning.controls == controls + assert restored.modalities.input == modalities + + +@pytest.mark.parametrize( + "column", + [ + "reasoning_supported", + "native_tool_call_supported", + "structured_output_supported", + "temperature_supported", + "top_p_supported", + ], +) +@pytest.mark.parametrize("bad", [0, 1, "false", "unknown", {}, []]) +def test_boolean_columns_never_coerce_unknown_or_false(column, bad): + stored = split_outlet_capabilities(ProfileCapabilities()) + stored.columns[column] = bad + with pytest.raises(CapabilityStorageError, match="^PROFILE_STORAGE_INVALID$"): + restore(stored) + + +@pytest.mark.parametrize( + "group,duplicate", + [ + ("reasoning", "status"), + ("reasoning", "controls"), + ("tools", "status"), + ("response", "unexpected"), + ("usage", "unexpected"), + ], +) +def test_duplicate_or_undeclared_detail_fields_fail(group, duplicate): + stored = split_outlet_capabilities(ProfileCapabilities()) + stored.capability_details[group][duplicate] = "secret-value" + with pytest.raises(CapabilityStorageError) as captured: + restore(stored) + assert "secret-value" not in str(captured.value) + + +@pytest.mark.parametrize( + "target,key", + [ + ("columns", "context_window_tokens"), + ("columns", "reasoning_controls"), + ("capability_details", "usage"), + ], +) +def test_missing_storage_fields_are_not_reconstructed_from_defaults(target, key): + stored = split_outlet_capabilities(ProfileCapabilities()) + getattr(stored, target).pop(key) + with pytest.raises(CapabilityStorageError): + restore(stored) + + +@pytest.mark.parametrize( + "column,value", + [ + ("context_window_tokens", 0), + ("max_input_tokens", True), + ("max_output_tokens", 2**31), + ("input_modalities", ["text", "text"]), + ("reasoning_controls", [{"mode": "toggle", "values": [0, 1]}]), + ], +) +def test_invalid_column_values_fail_before_profile_publication(column, value): + stored = split_outlet_capabilities(ProfileCapabilities()) + stored.columns[column] = value + original = deepcopy(stored) + with pytest.raises(CapabilityStorageError): + restore(stored) + assert stored == original + + +def test_unsupported_reasoning_with_saved_controls_fails(): + stored = split_outlet_capabilities(ProfileCapabilities()) + stored.columns["reasoning_supported"] = False + stored.columns["reasoning_controls"] = [{"mode": "toggle", "values": [True]}] + with pytest.raises(CapabilityStorageError): + restore(stored) + + +def test_capacity_relationships_are_validated_after_restore(): + stored = split_outlet_capabilities(ProfileCapabilities()) + stored.columns["context_window_tokens"] = 100 + stored.columns["max_input_tokens"] = 101 + with pytest.raises(CapabilityStorageError): + restore(stored) + + +@pytest.mark.parametrize("details", [None, [], {"unexpected": "secret"}]) +def test_invalid_detail_container_is_rejected(details): + stored = split_outlet_capabilities(ProfileCapabilities()) + with pytest.raises(CapabilityStorageError): + restore_outlet_capabilities(columns=stored.columns, capability_details=details) + + +def test_invalid_detail_group_is_rejected(): + stored = split_outlet_capabilities(ProfileCapabilities()) + stored.capability_details["usage"] = None + with pytest.raises(CapabilityStorageError): + restore(stored) + + +def test_int4_capacity_bound_and_configuration_size_are_checked_on_save(): + with pytest.raises(CapabilityStorageError): + split_outlet_capabilities( + ProfileCapabilities(context={"combined_window": 2**31}) + ) + value = ProfileCapabilities(context={"combined_window": 2**31 - 1}) + assert ( + restore(split_outlet_capabilities(value)).context.combined_window == 2**31 - 1 + ) + with pytest.raises(CapabilityStorageError): + split_outlet_capabilities( + ProfileCapabilities(usage={"semantics_id": "x" * (1024 * 1024)}) + ) + + +@pytest.mark.parametrize("bad", [None, {}, "secret"]) +def test_save_requires_a_complete_typed_object(bad): + with pytest.raises(CapabilityStorageError): + split_outlet_capabilities(bad) + + +def test_restore_rejects_non_json_and_cyclic_inputs(): + stored = split_outlet_capabilities(ProfileCapabilities()) + stored.capability_details["usage"]["semantics_id"] = float("nan") + with pytest.raises(CapabilityStorageError): + restore(stored) + stored.capability_details["usage"]["semantics_id"] = stored.capability_details + with pytest.raises(CapabilityStorageError): + restore(stored) diff --git a/test/sdk/core/model_service/test_outlet_parameters.py b/test/sdk/core/model_service/test_outlet_parameters.py new file mode 100644 index 0000000000..4bfe9cd0e4 --- /dev/null +++ b/test/sdk/core/model_service/test_outlet_parameters.py @@ -0,0 +1,240 @@ +"""Persisted parameter presence survives the single-location storage boundary.""" + +from copy import deepcopy + +import pytest +from nexent.core.model_service.profiles.outlet_parameters import ( + DEDICATED_PARAMETER_KEYS, + restore_outlet_parameters, + split_outlet_parameters, +) +from nexent.core.model_service.profiles.parameter_rules import ( + apply_parameter_values, + compile_parameter_rules, + validate_saved_parameter_values, +) +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError + + +def declaration(key, **changes): + value = { + "key": key, + "schema": {}, + "visible": True, + "editable": True, + "required": False, + "allowed_scopes": ["outlet", "conversation"], + "ui_group": "common", + "owner": "provider", + "source": {"rule_id": "test"}, + } + value.update(changes) + return value + + +def restore(compiled, stored): + return restore_outlet_parameters( + compiled, + columns=stored.columns, + parameter_values=stored.parameter_values, + explicit_null_parameters=stored.explicit_null_parameters, + ) + + +def test_creation_defaults_are_not_reapplied_on_saved_snapshot_load(): + rules = compile_parameter_rules( + [declaration("enable_thinking", default_value=True)] + ) + assert apply_parameter_values(rules, {}, scope="outlet", saved_values={}) == { + "enable_thinking": True + } + assert validate_saved_parameter_values(rules, {}) == {} + stored = split_outlet_parameters(rules, {}) + assert stored.explicit_null_parameters == [] + assert restore(rules, stored) == {} + + +@pytest.mark.parametrize("key", ["temperature", "top_p"]) +def test_float_columns_reject_integer_rounding(key): + rules = compile_parameter_rules([declaration(key)]) + with pytest.raises(ParameterValidationError, match="PARAMETER_COLUMN_INVALID"): + split_outlet_parameters(rules, {key: 2**53 + 1}) + assert restore(rules, split_outlet_parameters(rules, {key: 2**53})) == {key: 2**53} + + +@pytest.mark.parametrize( + "key,value", + [ + ("temperature", 0), + ("temperature", None), + ("top_p", 0), + ("top_p", None), + ("enable_thinking", False), + ("enable_thinking", None), + ("reasoning_effort", "none"), + ("reasoning_effort", None), + ("reasoning_budget_tokens", 0), + ("reasoning_budget_tokens", -1), + ("reasoning_budget_tokens", None), + ("custom", None), + ("custom", False), + ], +) +def test_round_trip_preserves_value_and_key_presence(key, value): + rules = compile_parameter_rules([declaration(key)]) + stored = split_outlet_parameters(rules, {key: value}) + assert restore(rules, stored) == {key: value} + if key in DEDICATED_PARAMETER_KEYS: + assert key not in stored.parameter_values + assert stored.explicit_null_parameters == ([key] if value is None else []) + else: + assert stored.parameter_values == {key: value} + assert stored.explicit_null_parameters == [] + + +def test_split_and_restore_return_detached_values_and_never_change_rules(): + declarations = [declaration("custom", default_value={"a": [1]})] + rules = compile_parameter_rules(declarations) + values = {"custom": {"a": [2]}} + stored = split_outlet_parameters(rules, values) + values["custom"]["a"].append(3) + result = restore(rules, stored) + result["custom"]["a"].append(4) + assert stored.parameter_values == {"custom": {"a": [2]}} + assert rules.declarations == declarations + + +@pytest.mark.parametrize( + "changes", + [ + {"explicit_null_parameters": ["temperature", "temperature"]}, + {"explicit_null_parameters": ["custom"]}, + {"explicit_null_parameters": [None]}, + {"explicit_null_parameters": "temperature"}, + {"explicit_null_parameters": {"temperature"}}, + {"columns": {}}, + {"columns": None}, + {"parameter_values": {"temperature": None}}, + {"parameter_values": None}, + ], +) +def test_corrupt_storage_is_rejected(changes): + rules = compile_parameter_rules([declaration("temperature")]) + stored = split_outlet_parameters(rules, {}) + arguments = { + "columns": stored.columns, + "parameter_values": stored.parameter_values, + "explicit_null_parameters": stored.explicit_null_parameters, + } + arguments.update(changes) + with pytest.raises(ParameterValidationError): + restore_outlet_parameters(rules, **arguments) + + +def test_non_null_column_cannot_have_a_null_marker(): + rules = compile_parameter_rules([declaration("temperature")]) + stored = split_outlet_parameters(rules, {"temperature": 0}) + stored.explicit_null_parameters = ["temperature"] + with pytest.raises( + ParameterValidationError, match="PARAMETER_NULL_MARKERS_INVALID" + ): + restore(rules, stored) + + +@pytest.mark.parametrize( + "key,value", + [ + ("temperature", True), + ("temperature", 10**1000), + ("top_p", "0.5"), + ("enable_thinking", 0), + ("reasoning_effort", "x" * 31), + ("reasoning_effort", " "), + ("reasoning_budget_tokens", True), + ("reasoning_budget_tokens", 1.5), + ("reasoning_budget_tokens", 2**31), + ("reasoning_budget_tokens", -(2**31) - 1), + ], +) +def test_dedicated_column_types_and_limits_are_enforced(key, value): + rules = compile_parameter_rules([declaration(key)]) + with pytest.raises(ParameterValidationError, match="PARAMETER_COLUMN_INVALID"): + split_outlet_parameters(rules, {key: value}) + stored = split_outlet_parameters(rules, {}) + stored.columns[key] = value + with pytest.raises(ParameterValidationError, match="PARAMETER_COLUMN_INVALID"): + restore(rules, stored) + + +@pytest.mark.parametrize("value", [-(2**31), 2**31 - 1]) +def test_int4_boundary_values_are_preserved_when_declared(value): + rules = compile_parameter_rules([declaration("reasoning_budget_tokens")]) + assert restore( + rules, split_outlet_parameters(rules, {"reasoning_budget_tokens": value}) + ) == { + "reasoning_budget_tokens": value, + } + + +@pytest.mark.parametrize( + "fields,values,category", + [ + ([declaration("custom", required=True)], {}, "PARAMETER_VALUE_REQUIRED"), + ([declaration("custom", fixed_value=0)], {}, "FIXED_PARAMETER"), + ([declaration("custom", fixed_value=False)], {"custom": 0}, "FIXED_PARAMETER"), + ([declaration("custom")], {"unknown": "secret"}, "PARAMETER_UNKNOWN"), + ( + [declaration("custom", schema={"type": "boolean"})], + {"custom": 0}, + "PARAMETER_VALUE_INVALID", + ), + ], +) +def test_saved_validation_rejects_invalid_snapshots_without_repair( + fields, values, category +): + original = deepcopy(values) + with pytest.raises(ParameterValidationError) as captured: + validate_saved_parameter_values(compile_parameter_rules(fields), values) + assert captured.value.category == category + assert "secret" not in str(captured.value) + assert values == original + + +def test_saved_inactive_values_fail_instead_of_disappearing(): + rules = compile_parameter_rules( + [ + declaration("enable_thinking", default_value=True), + declaration( + "reasoning_budget_tokens", + condition={"key": "enable_thinking", "op": "eq", "value": True}, + ), + ] + ) + assert validate_saved_parameter_values(rules, {"enable_thinking": False}) == { + "enable_thinking": False + } + for values in ( + {"reasoning_budget_tokens": 0}, + {"enable_thinking": False, "reasoning_budget_tokens": 0}, + ): + with pytest.raises(ParameterValidationError, match="PARAMETER_DEPENDENCY"): + validate_saved_parameter_values(rules, values) + + +def test_inactive_fixed_rule_does_not_create_a_value_on_load(): + rules = compile_parameter_rules( + [ + declaration("enabled"), + declaration( + "custom", + fixed_value=0, + condition={"key": "enabled", "op": "eq", "value": True}, + ), + ] + ) + assert validate_saved_parameter_values(rules, {}) == {} + assert validate_saved_parameter_values(rules, {"enabled": True, "custom": 0}) == { + "enabled": True, + "custom": 0, + } diff --git a/test/sdk/core/model_service/test_outlet_rules.py b/test/sdk/core/model_service/test_outlet_rules.py new file mode 100644 index 0000000000..49bcdd0338 --- /dev/null +++ b/test/sdk/core/model_service/test_outlet_rules.py @@ -0,0 +1,259 @@ +"""Same-row domain and fixed-value references preserve rule semantics.""" + +from copy import deepcopy + +import pytest +from nexent.core.model_service.profiles.outlet_rules import ( + RuleStorageError, + restore_parameter_rules, + store_parameter_rules, +) +from nexent.core.model_service.profiles.parameter_rules import ( + compile_parameter_rules, + validate_saved_parameter_values, +) + + +def declaration(key="enable_thinking", **changes): + result = { + "key": key, + "schema": {}, + "visible": True, + "editable": True, + "required": False, + "allowed_scopes": ["outlet", "conversation"], + "ui_group": "common", + "owner": "provider", + "source": {"rule_id": "fixture"}, + } + result.update(changes) + return result + + +@pytest.mark.parametrize( + "key,control,schema,value,keywords", + [ + ( + "enable_thinking", + {"mode": "toggle", "values": [False, True]}, + {"type": "boolean", "enum": [False, True]}, + False, + ["enum"], + ), + ( + "reasoning_effort", + {"mode": "effort", "values": [None, "low", "high"]}, + {"type": ["string", "null"], "enum": [None, "low", "high"]}, + None, + ["enum"], + ), + ( + "reasoning_budget_tokens", + {"mode": "budget_tokens", "min": 0, "max": 81920}, + {"type": "integer", "minimum": 0, "maximum": 81920}, + 0, + ["maximum", "minimum"], + ), + ], +) +def test_declared_domains_are_stored_once_and_restore_exact_schema( + key, control, schema, value, keywords +): + declarations = [declaration(key, schema=schema)] + compiled = compile_parameter_rules(declarations) + stored = store_parameter_rules( + compiled, reasoning_controls=[control], saved_values={key: value} + ) + assert stored[0]["value_domain_ref"] == { + "mode": control["mode"], + "keywords": keywords, + } + assert not set(keywords) & stored[0]["schema"].keys() + restored = restore_parameter_rules( + stored, reasoning_controls=[control], saved_values={key: value} + ) + assert restored.declarations == declarations + assert validate_saved_parameter_values(restored, {key: value}) == {key: value} + assert compiled.declarations == declarations + + +def test_more_restrictive_limits_and_unknown_bounds_are_not_expanded(): + controls = [{"mode": "budget_tokens", "max": 81920}] + schema = {"type": "integer", "minimum": 0, "maximum": 40000} + compiled = compile_parameter_rules( + [declaration("reasoning_budget_tokens", schema=schema)] + ) + stored = store_parameter_rules( + compiled, reasoning_controls=controls, saved_values={} + ) + assert "value_domain_ref" not in stored[0] + assert stored[0]["schema"] == schema + assert ( + restore_parameter_rules( + stored, reasoning_controls=controls, saved_values={} + ).declarations + == compiled.declarations + ) + + +@pytest.mark.parametrize("value", [False, 0, None, "", {"a": [1]}]) +def test_fixed_values_use_saved_presence_and_round_trip_without_value_copy(value): + compiled = compile_parameter_rules([declaration("custom", fixed_value=value)]) + stored = store_parameter_rules( + compiled, reasoning_controls=None, saved_values={"custom": value} + ) + assert "fixed_value" not in stored[0] + assert stored[0]["fixed_to_saved_value"] is True + restored = restore_parameter_rules( + stored, reasoning_controls=None, saved_values={"custom": value} + ) + assert restored.declarations == compiled.declarations + with pytest.raises(RuleStorageError): + restore_parameter_rules(stored, reasoning_controls=None, saved_values={}) + + +def test_inactive_fixed_constraint_is_preserved_and_default_is_not_applied(): + compiled = compile_parameter_rules( + [ + declaration("enabled", default_value=True), + declaration( + "custom", + fixed_value=0, + condition={"key": "enabled", "op": "eq", "value": True}, + ), + ] + ) + stored = store_parameter_rules(compiled, reasoning_controls=[], saved_values={}) + assert stored[1]["fixed_value"] == 0 + assert "fixed_to_saved_value" not in stored[1] + restored = restore_parameter_rules(stored, reasoning_controls=[], saved_values={}) + assert restored.declarations == compiled.declarations + assert validate_saved_parameter_values(restored, {}) == {} + + +@pytest.mark.parametrize( + "reference", + [ + None, + {}, + {"mode": "effort", "keywords": ["enum"]}, + {"mode": "toggle", "keywords": []}, + {"mode": "toggle", "keywords": ["enum", "enum"]}, + {"mode": "toggle", "keywords": [None]}, + {"mode": "toggle", "keywords": ["maximum"]}, + {"mode": "toggle", "keywords": ["enum"], "extra": True}, + ], +) +def test_invalid_domain_references_are_rejected(reference): + rule = declaration(value_domain_ref=reference) + with pytest.raises(RuleStorageError, match="^PROFILE_STORAGE_INVALID$"): + restore_parameter_rules( + [rule], + reasoning_controls=[{"mode": "toggle", "values": [False, True]}], + saved_values={}, + ) + + +def test_duplicate_domain_constraint_cannot_override_the_reference(): + rule = declaration( + schema={"enum": [False]}, + value_domain_ref={"mode": "toggle", "keywords": ["enum"]}, + ) + with pytest.raises(RuleStorageError): + restore_parameter_rules( + [rule], + reasoning_controls=[{"mode": "toggle", "values": [False, True]}], + saved_values={}, + ) + + +@pytest.mark.parametrize( + "key,controls,reference", + [ + ( + "custom", + [{"mode": "toggle", "values": [False, True]}], + {"mode": "toggle", "keywords": ["enum"]}, + ), + ( + "reasoning_budget_tokens", + [{"mode": "budget_tokens", "max": 10}], + {"mode": "budget_tokens", "keywords": ["minimum"]}, + ), + ("enable_thinking", None, {"mode": "toggle", "keywords": ["enum"]}), + ], +) +def test_missing_or_wrong_control_domain_is_not_inferred(key, controls, reference): + with pytest.raises(RuleStorageError): + restore_parameter_rules( + [declaration(key, value_domain_ref=reference)], + reasoning_controls=controls, + saved_values={}, + ) + + +@pytest.mark.parametrize( + "rule", + [ + declaration(fixed_to_saved_value=False), + declaration(fixed_to_saved_value=1), + declaration(fixed_to_saved_value=True, fixed_value=False), + ], +) +def test_fixed_reference_marker_is_strict_and_cannot_duplicate_values(rule): + with pytest.raises(RuleStorageError): + restore_parameter_rules( + [rule], reasoning_controls=None, saved_values={"enable_thinking": False} + ) + + +@pytest.mark.parametrize( + "controls", + [ + [{"mode": "toggle", "values": [True]}] * 2, + [{"mode": "toggle", "values": [1]}], + {}, + ], +) +def test_invalid_control_domains_fail_on_both_boundaries(controls): + compiled = compile_parameter_rules([declaration()]) + with pytest.raises(RuleStorageError): + store_parameter_rules(compiled, reasoning_controls=controls, saved_values={}) + with pytest.raises(RuleStorageError): + restore_parameter_rules( + compiled.declarations, reasoning_controls=controls, saved_values={} + ) + + +def test_failed_restore_does_not_mutate_declarations_or_include_private_values(): + rules = [declaration("private", schema={"type": "integer"})] + original = deepcopy(rules) + with pytest.raises(RuleStorageError) as captured: + restore_parameter_rules( + rules, + reasoning_controls=None, + saved_values={"private": "credential-secret"}, + ) + assert rules == original + assert "credential-secret" not in str(captured.value) + + +@pytest.mark.parametrize( + "rules", + [None, {}, [None], [{"key": "x", "schema": None}], [declaration(unknown=True)]], +) +def test_invalid_stored_rule_structure_fails(rules): + with pytest.raises(RuleStorageError): + restore_parameter_rules(rules, reasoning_controls=None, saved_values={}) + + +def test_oversized_rules_fail_with_sanitized_storage_error(): + compiled = compile_parameter_rules( + [declaration(source={"note": "x" * (1024 * 1024)})] + ) + with pytest.raises(RuleStorageError): + store_parameter_rules(compiled, reasoning_controls=None, saved_values={}) + with pytest.raises(RuleStorageError): + restore_parameter_rules( + compiled.declarations, reasoning_controls=None, saved_values={} + ) diff --git a/test/sdk/core/model_service/test_outlet_storage.py b/test/sdk/core/model_service/test_outlet_storage.py new file mode 100644 index 0000000000..8344374da1 --- /dev/null +++ b/test/sdk/core/model_service/test_outlet_storage.py @@ -0,0 +1,433 @@ +"""Whole-row outlet codec validates ownership, versions and saved configuration.""" + +from copy import deepcopy + +import pytest +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.profiles.instruction_mapping import ( + resolve_instruction_mapping, +) +from nexent.core.model_service.profiles.outlet_storage import ( + ProfileStorageError, + decode_resolved_model_profile, + encode_resolved_model_profile, +) +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile +from pydantic import ValidationError + +REFERENCE = VersionedReference(ref="fixture_v1", version=1) +ROLE = VersionedReference(ref="system_only_v1", version=1) +REGISTRY = { + key: [REFERENCE] + for key in ( + "protocol", + "auth", + "adapter", + "code_converter", + "native_converter", + "parameter_transform", + ) +} + + +def rule(key, **changes): + value = { + "key": key, + "schema": {}, + "visible": True, + "editable": True, + "required": False, + "allowed_scopes": ["outlet", "conversation"], + "ui_group": "common", + "owner": "provider", + "source": {"rule_id": "fixture"}, + "api_binding": { + "path": "/" + key, + "transform_ref": "fixture_v1", + "transform_version": 1, + }, + } + value.update(changes) + return value + + +@pytest.fixture +def document(): + return { + "schema_version": 1, + "outlet_id": 12, + "config_revision": 1, + "identity": { + "tenant_id": "tenant", + "display_name": "model display", + "model_type": "llm", + "provider_id": "custom-provider", + "request_model_id": "Exact/MODEL", + "provider_key": None, + "model_profile_key": None, + }, + "connection": { + "base_url": "http://localhost:8000/v1", + "ssl_verify": False, + "timeout_seconds": 120, + "concurrency_limit": None, + "credential_ref": {"kind": "model_record", "model_id": 12}, + }, + "connection_rules": { + "credential_kind": "model_record", + "relative_path": "/chat/completions", + "method": "POST", + "headers": {"Accept": "application/json"}, + }, + "context_configuration": { + "output_reserve_tokens": None, + "tokenizer_family": None, + "capacity_source": "operator", + "capability_profile_version": None, + }, + "capabilities": {}, + "instruction_mapping": resolve_instruction_mapping(ROLE).model_dump( + mode="json" + ), + "parameter_rules": [ + rule("temperature", schema={"type": "number"}, fixed_value=0), + rule("custom", default_value="default"), + ], + "parameter_values": {"temperature": 0, "custom": None}, + "extra_api_parameters": [{"path": "/vendor/option", "value": False}], + "bindings": { + **{ + key: REFERENCE.model_dump() + for key in REGISTRY + if key != "parameter_transform" + }, + "role_mapping": ROLE.model_dump(), + }, + "provenance": { + "atomic_refs": {"model": None, "provider": None, "provider_model": None}, + "interpretation_versions": { + "source_parser": None, + "source_mapping": None, + "supplement_schema": None, + "composer": None, + }, + "overrides": [], + "field_sources": [], + "provider_metadata": {}, + }, + } + + +def encode(document): + return encode_resolved_model_profile( + ResolvedModelProfile.model_validate(document), registered_references=REGISTRY + ) + + +def decode(row): + return decode_resolved_model_profile(row, registered_references=REGISTRY) + + +def test_complete_snapshot_round_trip_without_duplicate_values_or_credentials(document): + profile = ResolvedModelProfile.model_validate(document) + row = encode(document) + assert "api_key" not in row + assert "max_tokens" not in row + assert row["temperature"] == 0 + assert row["extra_params"]["parameter_values"] == {"custom": None} + assert row["resolved_profile"]["parameter_rules"][0]["fixed_to_saved_value"] is True + assert "fixed_value" not in row["resolved_profile"]["parameter_rules"][0] + assert "protocol" not in row["resolved_profile"]["bindings"] + assert row["protocol_id"] == "fixture_v1" + assert row["resolved_profile"]["bindings"]["protocol_version"] == 1 + assert decode(row).model_dump() == profile.model_dump() + row["api_key"] = "credential-private" + assert decode(row).model_dump() == profile.model_dump() + assert "credential-private" not in repr(decode(row)) + + +def test_creation_before_database_id_assignment_and_no_default_application(document): + document["outlet_id"] = None + document["connection"]["credential_ref"]["model_id"] = None + document["parameter_values"].pop("custom") + row = encode(document) + assert "model_id" not in row + row["model_id"] = 27 + profile = decode(row) + assert profile.outlet_id == profile.connection.credential_ref.model_id == 27 + assert "custom" not in profile.parameter_values + + +def test_snapshot_load_is_detached_and_ignores_unrelated_row_fields(document): + row = encode(document) + row["create_time"] = object() + original = deepcopy(row["resolved_profile"]) + restored = decode(row) + restored.parameter_rules[0]["schema"]["type"] = "string" + restored.connection_rules.headers["Accept"] = "changed" + assert row["resolved_profile"] == original + + +@pytest.mark.parametrize( + "key", + [ + "model_id", + "resolved_profile", + "protocol_id", + "extra_params", + "timeout_seconds", + "config_revision", + ], +) +def test_incomplete_rows_fail_without_catalog_fallback(document, key): + row = encode(document) + row.pop(key) + with pytest.raises(ProfileStorageError, match="^PROFILE_STORAGE_INVALID$"): + decode(row) + + +@pytest.mark.parametrize( + "key,value", + [ + ("profile_schema_version", None), + ("profile_schema_version", True), + ("profile_schema_version", 2), + ("model_id", True), + ("model_id", 0), + ("config_revision", 0), + ("temperature", True), + ("protocol_id", "x" * 31), + ("timeout_seconds", -1), + ], +) +def test_invalid_row_types_and_versions_are_rejected(document, key, value): + row = encode(document) + row[key] = value + with pytest.raises(ProfileStorageError): + decode(row) + + +@pytest.mark.parametrize( + "group", + [ + "protocol", + "auth", + "adapter", + "code_converter", + "native_converter", + "parameter_transform", + ], +) +def test_unavailable_implementation_versions_fail_both_save_and_load(document, group): + row = encode(document) + registry = {key: refs for key, refs in REGISTRY.items() if key != group} + with pytest.raises(ProfileStorageError): + encode_resolved_model_profile( + ResolvedModelProfile.model_validate(document), + registered_references=registry, + ) + with pytest.raises(ProfileStorageError): + decode_resolved_model_profile(row, registered_references=registry) + + +@pytest.mark.parametrize( + "group,value", + [ + ("extra_params", {"temperature": 0}), + ("extra_params", None), + ("resolved_profile", None), + ("resolved_profile", {"api_key": "private"}), + ], +) +def test_legacy_or_bare_configuration_is_not_treated_as_resolved( + document, group, value +): + row = encode(document) + row[group] = value + with pytest.raises(ProfileStorageError) as captured: + decode(row) + assert "private" not in str(captured.value) + + +def test_duplicate_storage_and_conflicting_null_marker_fail(document): + row = encode(document) + row["extra_params"]["parameter_values"]["temperature"] = 0 + with pytest.raises(ProfileStorageError): + decode(row) + row = encode(document) + row["resolved_profile"]["explicit_null_parameters"] = ["temperature"] + with pytest.raises(ProfileStorageError): + decode(row) + + +def test_protected_adapter_paths_are_enforced_at_both_storage_boundaries(document): + row = encode(document) + with pytest.raises(ProfileStorageError): + decode_resolved_model_profile( + row, registered_references=REGISTRY, protected_paths=["/vendor"] + ) + with pytest.raises(ProfileStorageError): + encode_resolved_model_profile( + ResolvedModelProfile.model_validate(document), + registered_references=REGISTRY, + protected_paths=["/temperature"], + ) + + +@pytest.mark.parametrize( + "change", + [ + lambda d: d["connection"]["credential_ref"].update(model_id=99), + lambda d: d["connection"].update(api_key="private"), + lambda d: d["provenance"].update(unknown=True), + lambda d: d["bindings"].update(role_mapping={"ref": "other", "version": 1}), + lambda d: d.update(schema_version=True), + lambda d: d["identity"].update(request_model_id="x" * 101), + lambda d: d["extra_api_parameters"].append( + {"path": "/model", "value": "other"} + ), + ], +) +def test_complete_contract_rejects_unowned_or_conflicting_fields(document, change): + change(document) + with pytest.raises((ValueError, ValidationError)): + ResolvedModelProfile.model_validate(document) + + +def test_serialization_revalidates_mutated_nested_values(document): + profile = ResolvedModelProfile.model_validate(document) + profile.parameter_values["temperature"] = 1 + with pytest.raises(ProfileStorageError): + encode_resolved_model_profile(profile, registered_references=REGISTRY) + + +def test_atomic_identity_restores_from_provenance_without_source_reads( + document, monkeypatch +): + from pathlib import Path + + reference = { + "key": "provider/family", + "catalog_revision": "catalog-one", + "file_path": "providers/family/provider.toml", + "content_sha256": "a" * 64, + } + document["provenance"]["atomic_refs"]["provider"] = reference + document["identity"]["provider_key"] = "provider/family" + row = encode(document) + + def unexpected_read(*args, **kwargs): + pytest.fail("Saved outlet loading must not read source files") + + monkeypatch.setattr(Path, "read_text", unexpected_read) + restored = decode(row) + assert restored.identity.provider_key == restored.provider_key == "provider/family" + assert "identity" not in row["resolved_profile"] + assert restored.provenance.atomic_refs.provider.content_sha256 == "a" * 64 + + +@pytest.mark.parametrize( + "key,control,schema,value", + [ + ( + "enable_thinking", + {"mode": "toggle", "values": [False, True]}, + {"type": "boolean", "enum": [False, True]}, + False, + ), + ( + "reasoning_effort", + {"mode": "effort", "values": [None, "high"]}, + {"type": ["string", "null"], "enum": [None, "high"]}, + None, + ), + ( + "reasoning_budget_tokens", + {"mode": "budget_tokens", "min": 0, "max": 10, "special_values": [-1]}, + {"type": "integer"}, + -1, + ), + ], +) +def test_full_row_round_trip_includes_reasoning_domains_and_null_presence( + document, key, control, schema, value +): + document["capabilities"] = { + "reasoning": {"status": "supported", "controls": [control]} + } + document["parameter_rules"].append(rule(key, schema=schema)) + document["parameter_values"][key] = value + row = encode(document) + assert key not in row["extra_params"]["parameter_values"] + assert decode(row).parameter_values[key] == value + assert row["resolved_profile"]["explicit_null_parameters"] == ( + [key] if value is None else [] + ) + + +@pytest.mark.parametrize( + "control,value", + [ + ({"mode": "toggle", "values": [False]}, True), + ({"mode": "budget_tokens", "min": 0, "max": 10}, -1), + ({"mode": "budget_tokens", "min": 0, "max": 10}, 11), + ], +) +def test_saved_values_cannot_exceed_declared_reasoning_domain(document, control, value): + key = ( + "enable_thinking" if control["mode"] == "toggle" else "reasoning_budget_tokens" + ) + document["capabilities"] = { + "reasoning": {"status": "supported", "controls": [control]} + } + document["parameter_rules"].append(rule(key)) + document["parameter_values"][key] = value + with pytest.raises(ValueError): + ResolvedModelProfile.model_validate(document) + + +def test_control_declaration_requires_a_rule_even_when_value_is_not_sent(document): + document["capabilities"] = { + "reasoning": {"controls": [{"mode": "toggle", "values": [False, True]}]} + } + with pytest.raises(ValueError): + ResolvedModelProfile.model_validate(document) + document["parameter_rules"].append(rule("enable_thinking")) + assert "enable_thinking" not in decode(encode(document)).parameter_values + + +def test_schema_with_missing_parameter_binding_is_not_persistable(document): + document["parameter_rules"][0]["api_binding"].pop("path") + with pytest.raises(ValueError): + ResolvedModelProfile.model_validate(document) + + +def test_duplicate_protocol_reference_in_remaining_json_fails(document): + row = encode(document) + row["resolved_profile"]["bindings"]["protocol"] = REFERENCE.model_dump() + with pytest.raises(ProfileStorageError): + decode(row) + + +def test_oversized_complete_configuration_is_rejected(document): + profile = ResolvedModelProfile.model_validate(document) + profile.provenance.provider_metadata["filler"] = "x" * (1024 * 1024) + with pytest.raises(ProfileStorageError): + encode_resolved_model_profile(profile, registered_references=REGISTRY) + + +def test_atomic_key_cannot_contradict_provenance(document): + document["identity"]["model_profile_key"] = "unrelated-model" + with pytest.raises(ValueError): + ResolvedModelProfile.model_validate(document) + + +def test_integer_sampling_schema_survives_postgresql_float_column(document): + document["parameter_rules"][0]["schema"] = {"type": "integer"} + row = encode(document) + row["temperature"] = ( + 0.0 # PostgreSQL FLOAT returns a Python float for an integer insert. + ) + profile = decode(row) + assert type(profile.parameter_values["temperature"]) is int + assert profile.parameter_values["temperature"] == 0 diff --git a/test/sdk/core/model_service/test_profile_capabilities.py b/test/sdk/core/model_service/test_profile_capabilities.py index 6a4b2eabbb..f0a9f25493 100644 --- a/test/sdk/core/model_service/test_profile_capabilities.py +++ b/test/sdk/core/model_service/test_profile_capabilities.py @@ -15,6 +15,31 @@ def test_missing_capabilities_are_unknown_not_supported_or_false(): assert capabilities.response.supported_finish_reasons is None assert capabilities.usage.input_includes_cached == "unknown" assert capabilities.usage.output_includes_reasoning == "unknown" + assert capabilities.sampling.top_p.status == "unknown" + + +@pytest.mark.parametrize("status", ["supported", "unsupported", "unknown"]) +def test_top_p_support_is_independent_of_temperature(status): + capabilities = ProfileCapabilities.model_validate( + {"sampling": {"top_p": {"status": status}}} + ) + assert capabilities.sampling.top_p.status == status + assert capabilities.sampling.temperature.status == "unknown" + + +@pytest.mark.parametrize( + "value", + [ + True, + 0.5, + {"status": False}, + {"status": "maybe"}, + {"status": "supported", "value": 0.5}, + ], +) +def test_top_p_rejects_parameter_values_and_undeclared_support(value): + with pytest.raises(ValidationError): + ProfileCapabilities.model_validate({"sampling": {"top_p": value}}) @pytest.mark.parametrize(