diff --git a/frontend/package.json b/frontend/package.json index b814fdb473..caf9efbea8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,7 @@ "dependencies": { "@ant-design/icons": "6.2.5", "@assistant-ui/react": "^0.15.0", - "@assistant-ui/react-ai-sdk": "^1.3.33", + "@assistant-ui/react-ai-sdk": "1.3.33", "@assistant-ui/react-lexical": "0.2.7", "@assistant-ui/react-markdown": "^0.14.2", "@dicebear/core": "^9.2.2", diff --git a/sdk/nexent/core/agent_runtime_support/__init__.py b/sdk/nexent/core/agent_runtime_support/__init__.py new file mode 100644 index 0000000000..d99965a203 --- /dev/null +++ b/sdk/nexent/core/agent_runtime_support/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Shared Agent runtime contracts and scoped support; no engine selection.""" diff --git a/sdk/nexent/core/agent_runtime_support/contracts.py b/sdk/nexent/core/agent_runtime_support/contracts.py new file mode 100644 index 0000000000..a21b22d835 --- /dev/null +++ b/sdk/nexent/core/agent_runtime_support/contracts.py @@ -0,0 +1,58 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Selected model-visible resources shared by Agent runtime implementations.""" + +from typing import Annotated, Self + +from pydantic import Field, model_validator + +from nexent.core.model_service.contracts import ModelMessage, ToolDefinition +from nexent.core.model_service.profiles.contracts import NonBlankString, ProfileContract + + +class ResourceItem(ProfileContract): + message: ModelMessage + source: NonBlankString + order: Annotated[int, Field(ge=0)] + + +class RuntimeResourceView(ProfileContract): + """Only selected model-visible data; no executable bindings or arbitrary metadata.""" + + instructions: list[ResourceItem] = Field(default_factory=list) + references: list[ResourceItem] = Field(default_factory=list) + history: list[ResourceItem] = Field(default_factory=list) + current_user_input: ResourceItem + tool_observations: list[ResourceItem] = Field(default_factory=list) + tool_definitions: list[ToolDefinition] = Field(default_factory=list) + authorized_metadata: list[ResourceItem] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_resources(self) -> Self: + items = self.items() + if len({item.order for item in items}) != len(items): + raise ValueError("Resource ordering must be unique") + if len({item.message.message_id for item in items}) != len(items): + raise ValueError("Resource message identities must be unique") + if len({tool.name for tool in self.tool_definitions}) != len(self.tool_definitions): + raise ValueError("Authorized tool names must be unique") + for item in items: + message = item.message + if message.tool_calls and message.semantic_role not in {"assistant_history", "assistant"}: + raise ValueError("Tool calls require an assistant history item") + if message.tool_call_id is not None and message.semantic_role not in {"tool_result", "tool"}: + raise ValueError("Tool result association requires an observation") + for group, roles in ( + (self.instructions, {"platform_instruction", "application_instruction"}), + (self.references + self.authorized_metadata, {"reference_data"}), + (self.history, {"user_input", "assistant_history", "tool_result", "user", "assistant", "tool"}), + (self.tool_observations, {"tool_result", "tool"}), + ([self.current_user_input], {"user_input"}), + ): + if any(item.message.semantic_role not in roles for item in group): + raise ValueError("Resource role does not match its authorized group") + return self + + def items(self): + return (self.instructions + self.references + self.history + self.tool_observations + + self.authorized_metadata + [self.current_user_input]) diff --git a/sdk/nexent/core/model_service/__init__.py b/sdk/nexent/core/model_service/__init__.py new file mode 100644 index 0000000000..eb4349d42f --- /dev/null +++ b/sdk/nexent/core/model_service/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Model service contracts and provider adapters.""" diff --git a/sdk/nexent/core/model_service/adapters/__init__.py b/sdk/nexent/core/model_service/adapters/__init__.py new file mode 100644 index 0000000000..3b5988bbd4 --- /dev/null +++ b/sdk/nexent/core/model_service/adapters/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Registered provider request and response implementations.""" diff --git a/sdk/nexent/core/model_service/compatibility/__init__.py b/sdk/nexent/core/model_service/compatibility/__init__.py new file mode 100644 index 0000000000..22fc83f322 --- /dev/null +++ b/sdk/nexent/core/model_service/compatibility/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Compatibility with existing model consumers; internal imports use concrete modules.""" diff --git a/sdk/nexent/core/model_service/contracts.py b/sdk/nexent/core/model_service/contracts.py new file mode 100644 index 0000000000..d5394135b9 --- /dev/null +++ b/sdk/nexent/core/model_service/contracts.py @@ -0,0 +1,160 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Provider-independent model messages, requests and complete responses.""" + +from typing import Annotated, Literal, Self +from uuid import UUID, uuid4 + +from pydantic import Field, JsonValue, model_validator + +from nexent.core.model_service.profiles.contracts import NonBlankString, ProfileContract + + +NonnegativeInt = Annotated[int, Field(ge=0)] + + +class ToolCall(ProfileContract): + """Complete protocol call; parsing never constitutes execution permission.""" + + local_call_id: UUID = Field(default_factory=uuid4) + provider_call_id: NonBlankString + index: NonnegativeInt + name: NonBlankString + raw_arguments: str = Field(repr=False) + parsed_arguments: dict[str, JsonValue] | None = Field(default=None, repr=False) + argument_error: Literal["INVALID_TOOL_ARGUMENTS"] | None = None + + +class ToolDefinition(ProfileContract): + name: NonBlankString + description: str + input_schema: dict[str, JsonValue] = Field(repr=False) + + +class ModelMessage(ProfileContract): + message_id: UUID = Field(default_factory=uuid4) + semantic_role: NonBlankString + content_parts: list[dict[str, JsonValue]] = Field(default_factory=list, repr=False) + reasoning_parts: list[dict[str, JsonValue]] = Field(default_factory=list, repr=False) + tool_calls: list[ToolCall] = Field(default_factory=list, repr=False) + tool_call_id: NonBlankString | None = None + source: NonBlankString | None = None + + +class ModelRequest(ProfileContract): + request_id: NonBlankString + outlet_id: Annotated[int, Field(gt=0)] | None + config_revision: Annotated[int, Field(gt=0)] + messages: list[ModelMessage] = Field(min_length=1, repr=False) + tools: list[ToolDefinition] = Field(default_factory=list, repr=False) + stream: bool = False + purpose: NonBlankString = "agent" + run_id: NonBlankString | None = None + + @model_validator(mode="after") + def validate_identities(self) -> Self: + if len({message.message_id for message in self.messages}) != len(self.messages): + raise ValueError("Model message identities must be unique") + if len({tool.name for tool in self.tools}) != len(self.tools): + raise ValueError("Model tool names must be unique") + return self + + +class TokenUsage(ProfileContract): + input_tokens: NonnegativeInt | None = None + output_tokens: NonnegativeInt | None = None + total_tokens: NonnegativeInt | None = None + cached_input_tokens: NonnegativeInt | None = None + reasoning_tokens: NonnegativeInt | None = None + origin: Literal["provider", "unknown"] = "unknown" + semantics_id: NonBlankString | None = None + + +class AssistantMessage(ProfileContract): + message_id: UUID = Field(default_factory=uuid4) + content_parts: list[dict[str, JsonValue]] = Field(default_factory=list, repr=False) + reasoning_parts: list[dict[str, JsonValue]] = Field(default_factory=list, repr=False) + refusal: str | None = Field(default=None, repr=False) + tool_calls: list[ToolCall] = Field(default_factory=list, repr=False) + finish_reason: NonBlankString + usage: TokenUsage = Field(default_factory=TokenUsage) + provider_metadata: dict[str, JsonValue] = Field(default_factory=dict, repr=False) + + @model_validator(mode="after") + def validate_call_identities(self) -> Self: + for values in ([call.provider_call_id for call in self.tool_calls], [call.index for call in self.tool_calls]): + if len(set(values)) != len(values): + raise ValueError("Complete tool call identities must be unique") + return self + + +class ToolCallDelta(ProfileContract): + """Display/accumulation only: never contains parsed execution arguments.""" + + local_call_id: UUID + index: NonnegativeInt + provider_call_id: NonBlankString | None = None + name_delta: str | None = Field(default=None, repr=False) + arguments_delta: str | None = Field(default=None, repr=False) + + +class ModelError(ProfileContract): + category: NonBlankString + scope: Literal["transport", "provider", "protocol"] + retryable: bool + will_retry: bool = False + attempt: Annotated[int, Field(gt=0)] = 1 + max_attempts: Annotated[int, Field(gt=0)] = 1 + provider_code: str | None = None + safe_message: NonBlankString + request_id: NonBlankString + finish_reason: NonBlankString | None = None + + @model_validator(mode="after") + def validate_attempt(self) -> Self: + if self.attempt > self.max_attempts: + raise ValueError("Model attempt exceeds its configured maximum") + if self.will_retry and (not self.retryable or self.attempt >= self.max_attempts): + raise ValueError("Model retry requires remaining attempts and a retryable error") + return self + + +class ModelEvent(ProfileContract): + request_id: NonBlankString + message_id: UUID + sequence: Annotated[int, Field(gt=0)] + attempt: Annotated[int, Field(gt=0)] = 1 + type: Literal["text_delta", "reasoning_delta", "tool_call_delta", "message_completed", "usage", "error"] + delta: str | None = Field(default=None, repr=False) + content_kind: Literal["text", "refusal"] | None = None + tool_call_delta: ToolCallDelta | None = Field(default=None, repr=False) + message: AssistantMessage | None = Field(default=None, repr=False) + usage: TokenUsage | None = None + finish_reason: NonBlankString | None = None + error: ModelError | None = Field(default=None, repr=False) + + @model_validator(mode="after") + def validate_payload(self) -> Self: + required = { + "text_delta": "delta", + "reasoning_delta": "delta", + "tool_call_delta": "tool_call_delta", + "message_completed": "message", + "usage": "usage", + "error": "error", + }[self.type] + payloads = { + key for key in ("delta", "tool_call_delta", "message", "usage", "error") if getattr(self, key) is not None + } + if payloads != {required}: + raise ValueError("Model event requires exactly its declared payload") + if self.content_kind is not None and self.type != "text_delta": + raise ValueError("Content kind applies only to text events") + if self.type == "message_completed": + if self.message.message_id != self.message_id or self.finish_reason != self.message.finish_reason: + raise ValueError("Completion identity and finish reason must agree") + elif self.finish_reason is not None: + raise ValueError("Only completed messages carry finish reasons") + if self.error is not None and self.error.request_id != self.request_id: + raise ValueError("Model error request identity must agree") + return self diff --git a/sdk/nexent/core/model_service/http_contracts.py b/sdk/nexent/core/model_service/http_contracts.py new file mode 100644 index 0000000000..54aec12bc8 --- /dev/null +++ b/sdk/nexent/core/model_service/http_contracts.py @@ -0,0 +1,127 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""HTTP boundary contracts; credentials are resolved only by the sender.""" + +import json +import re +from dataclasses import dataclass, field +from types import MappingProxyType +from urllib.parse import unquote, urlsplit, urlunsplit + +from nexent.core.model_service.profiles.resolved_profile import CredentialReference + + +CONTROL_HEADERS = frozenset({"host", "content-length", "transfer-encoding", "connection"}) +AUTH_HEADERS = frozenset({"authorization", "proxy-authorization", "cookie", "x-api-key", "api-key"}) +HEADER_NAME = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") + + +def validate_headers(headers: dict[str, str], *, credentials: bool = False) -> dict[str, str]: + """Reject invalid/control headers without exposing their values.""" + if not isinstance(headers, dict): + raise ValueError("Invalid model HTTP headers") + result = {} + for name, value in headers.items(): + if ( + not isinstance(name, str) + or not HEADER_NAME.fullmatch(name) + or not isinstance(value, str) + or any(ord(character) < 32 or ord(character) >= 127 for character in value) + or name.lower() in result + or name.lower() in CONTROL_HEADERS + or (not credentials and name.lower() in AUTH_HEADERS) + ): + raise ValueError("Invalid model HTTP headers") + result[name.lower()] = value + return result + + +def model_request_url(base_url: str, relative_path: str) -> str: + """Preserve the API root and query without guessing a provider prefix.""" + if not isinstance(base_url, str) or not isinstance(relative_path, str): + raise ValueError("Invalid model HTTP URL") + if any(character.isspace() or ord(character) < 32 for character in base_url + relative_path): + raise ValueError("Invalid model HTTP URL") + try: + base, relative = urlsplit(base_url), urlsplit(relative_path) + invalid_port = base.port is not None and not 0 < base.port < 65536 + except ValueError: + raise ValueError("Invalid model HTTP URL") from None + if ( + base.scheme not in {"http", "https"} + or not base.hostname + or base.username is not None + or base.password is not None + or base.fragment + or invalid_port + or relative.scheme + or relative.netloc + or relative.query + or relative.fragment + or not relative.path + or relative.path.startswith("//") + or "\\" in unquote(relative.path) + or any(part in {"", ".", ".."} for part in unquote(relative.path).lstrip("/").split("/")) + ): + raise ValueError("Invalid model HTTP URL") + return urlunsplit( + (base.scheme, base.netloc, base.path.rstrip("/") + "/" + relative.path.lstrip("/"), base.query, "") + ) + + +@dataclass(frozen=True) +class PreparedHttpRequest: + """Store a detached serialized body, never a mutable caller-owned payload.""" + + request_id: str + method: str + base_url: str = field(repr=False) + relative_path: str + headers: dict[str, str] = field(repr=False) + credential_ref: CredentialReference + json_body: dict = field(repr=False) + timeout_seconds: float + ssl_verify: bool + stream_protocol: str | None = None + url: str = field(init=False, repr=False) + body_bytes: bytes = field(init=False, repr=False) + + def __post_init__(self): + if ( + not isinstance(self.request_id, str) + or not self.request_id.strip() + or self.method not in {"POST", "GET"} + or type(self.timeout_seconds) not in {int, float} + or not 0 < self.timeout_seconds < float("inf") + or type(self.ssl_verify) is not bool + or self.stream_protocol not in {None, "sse"} + or not isinstance(self.credential_ref, CredentialReference) + or not isinstance(self.json_body, dict) + ): + raise ValueError("Invalid prepared model HTTP request") + try: + body = json.dumps(self.json_body, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode( + "utf-8" + ) + except (TypeError, ValueError, UnicodeError, RecursionError): + raise ValueError("Invalid model HTTP body") from None + object.__setattr__(self, "body_bytes", body) + object.__setattr__(self, "json_body", MappingProxyType(json.loads(body))) + object.__setattr__(self, "headers", MappingProxyType(validate_headers(self.headers))) + object.__setattr__(self, "url", model_request_url(self.base_url, self.relative_path)) + + +@dataclass(frozen=True) +class RawHttpResponse: + status_code: int + headers: dict[str, str] = field(repr=False) + body: bytes = field(repr=False) + + +class ModelTransportError(Exception): + """Safe common error with private response bytes available to adapters.""" + + def __init__(self, category: str, *, response: RawHttpResponse | None = None): + self.category = category + self.response = response + super().__init__(category) diff --git a/sdk/nexent/core/model_service/parameter_transforms.py b/sdk/nexent/core/model_service/parameter_transforms.py new file mode 100644 index 0000000000..8a85b9af32 --- /dev/null +++ b/sdk/nexent/core/model_service/parameter_transforms.py @@ -0,0 +1,34 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Executable versioned parameter conversions shared by validation and adapters.""" + +from types import MappingProxyType + +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError, _json_copy + + +def _thinking_toggle(value): + if type(value) is not bool: + raise ParameterValidationError("PARAMETER_TRANSFORM_VALUE_INVALID") + return "enabled" if value else "disabled" + + +_TRANSFORMS = MappingProxyType({ + ("identity_v1", 1): _json_copy, + ("thinking_toggle_v1", 1): _thinking_toggle, +}) + + +def parameter_transform_references(): + """Advertise only implementations available in this process.""" + return tuple(VersionedReference(ref=ref, version=version) for ref, version in _TRANSFORMS) + + +def transform_parameter(binding, value): + """Transform a detached value; never infer a rule from provider or model names.""" + key = (binding["transform_ref"], binding["transform_version"]) + transform = _TRANSFORMS.get(key) + if transform is None: + raise ParameterValidationError("PARAMETER_TRANSFORM_UNREGISTERED") + return transform(_json_copy(value)) diff --git a/sdk/nexent/core/model_service/profiles/__init__.py b/sdk/nexent/core/model_service/profiles/__init__.py new file mode 100644 index 0000000000..5ee07e3711 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Local model profile definitions, loading and resolution.""" diff --git a/sdk/nexent/core/model_service/profiles/_models_dev/__init__.py b/sdk/nexent/core/model_service/profiles/_models_dev/__init__.py new file mode 100644 index 0000000000..b14e3e75e2 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/_models_dev/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Private parsing of the pinned models.dev authored source format.""" diff --git a/sdk/nexent/core/model_service/profiles/_models_dev/reader.py b/sdk/nexent/core/model_service/profiles/_models_dev/reader.py new file mode 100644 index 0000000000..fed0754fa3 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/_models_dev/reader.py @@ -0,0 +1,173 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Read the complete declared source inventory before semantic normalization.""" + +import hashlib +import tomllib +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Literal, Mapping + +from pydantic import Field, HttpUrl, ValidationError, model_validator + +from nexent.core.model_service.profiles.contracts import AtomicProfileReference, NonBlankString, ProfileContract + + +SourceKind = Literal["model", "provider", "provider_model"] + + +class SourceEntry(ProfileContract): + """One source file whose bytes are pinned by the catalog manifest.""" + + kind: SourceKind + path: NonBlankString + content_sha256: NonBlankString + origin: Literal["models_dev", "nexent"] = "models_dev" + source_revision: NonBlankString | None = None + source_url: HttpUrl | None = None + + @model_validator(mode="after") + def validate_origin(self): + if self.origin == "nexent": + if self.source_revision is None or self.source_url is None: + raise ValueError("Local sources require a revision and documentation URL") + elif self.source_revision is not None or self.source_url is not None: + raise ValueError("Upstream sources use the pinned upstream provenance") + return self + + +class SourceManifest(ProfileContract): + """Versioned source inventory; mapping and runtime API bindings are separate.""" + + schema_version: int = Field(ge=1, le=1) + source_schema_ref: Literal["models_dev_authored_v1"] + upstream_url: Literal["https://github.com/anomalyco/models.dev"] + upstream_revision: NonBlankString + sources: list[SourceEntry] = Field(min_length=1) + + +class SourceReadError(ValueError): + """Sanitized source diagnostic without embedding file contents or OS errors.""" + + def __init__(self, category: str, file_path: str, source_kind: str = "manifest"): + self.category = category + self.file_path = file_path + self.source_kind = source_kind + self.source_path = "" + self.rule_id = "source_inventory_v1" + super().__init__(f"{category}: {source_kind}: {file_path}") + + +@dataclass(frozen=True) +class SourceDocument: + """Deeply read-only authored document, not a resolved runtime profile.""" + + kind: SourceKind + key: str + file_path: str + content_sha256: str + data: Mapping[str, object] + origin: str = "models_dev" + source_revision: str | None = None + source_url: str | None = None + + +def _freeze(value: object) -> object: + if isinstance(value, dict): + return MappingProxyType({key: _freeze(item) for key, item in value.items()}) + if isinstance(value, list): + return tuple(_freeze(item) for item in value) + return value + + +def _read(root: Path, path: str, kind: str) -> bytes: + try: + candidate = (root / path).resolve() + if not candidate.is_relative_to(root): + raise SourceReadError("SOURCE_PATH_OUTSIDE_ROOT", path, kind) + return candidate.read_bytes() + except (OSError, RuntimeError): + raise SourceReadError("SOURCE_FILE_UNAVAILABLE", path, kind) from None + + +def _parse(data: bytes, path: str, kind: str) -> dict: + try: + return tomllib.loads(data.decode("utf-8")) + except (UnicodeDecodeError, tomllib.TOMLDecodeError): + raise SourceReadError("SOURCE_TOML_INVALID", path, kind) from None + + +def _key(entry: SourceEntry) -> str: + parts = entry.path.split("/") + if entry.kind == "model" and len(parts) >= 3 and parts[0] == "models": + return entry.path[len("models/") : -len(".toml")] + if entry.kind == "provider" and len(parts) == 3 and parts[0] == "providers" and parts[2] == "provider.toml": + return parts[1] + if entry.kind == "provider_model" and len(parts) >= 4 and parts[0] == "providers" and parts[2] == "models": + return parts[1] + "/" + "/".join(parts[3:])[: -len(".toml")] + raise SourceReadError("SOURCE_KIND_PATH_MISMATCH", entry.path, entry.kind) + + +@dataclass(frozen=True) +class SourceInventory: + """Metadata from the same manifest bytes used to validate source documents.""" + + upstream_revision: str + source_schema_ref: str + manifest_sha256: str + documents: tuple[SourceDocument, ...] + + +def read_inventory(root: Path) -> SourceInventory: + """Fail the whole inventory on errors; do not expose a partial catalog.""" + root = root.resolve() + manifest_bytes = _read(root, "manifest.toml", "manifest") + raw_manifest = _parse(manifest_bytes, "manifest.toml", "manifest") + try: + manifest = SourceManifest.model_validate(raw_manifest) + if len(manifest.upstream_revision) != 40 or any( + char not in "0123456789abcdef" for char in manifest.upstream_revision + ): + raise ValueError("Invalid upstream commit") + except (ValidationError, ValueError): + raise SourceReadError("SOURCE_MANIFEST_INVALID", "manifest.toml") from None + + documents = [] + identities = set() + paths = set() + for entry in manifest.sources: + try: + AtomicProfileReference( + key=entry.path, + catalog_revision=manifest.upstream_revision, + file_path=entry.path, + content_sha256=entry.content_sha256, + ) + except ValidationError: + raise SourceReadError("SOURCE_REFERENCE_INVALID", entry.path, entry.kind) from None + if not entry.path.endswith(".toml") or entry.path.split("/")[-1] == ".toml": + raise SourceReadError("SOURCE_KIND_PATH_MISMATCH", entry.path, entry.kind) + key = _key(entry) + identity = (entry.kind, key) + if entry.path in paths or identity in identities: + raise SourceReadError("SOURCE_DUPLICATE", entry.path, entry.kind) + paths.add(entry.path) + identities.add(identity) + data = _read(root, entry.path, entry.kind) + if hashlib.sha256(data).hexdigest() != entry.content_sha256: + raise SourceReadError("SOURCE_DIGEST_MISMATCH", entry.path, entry.kind) + parsed = _parse(data, entry.path, entry.kind) + documents.append(SourceDocument(entry.kind, key, entry.path, entry.content_sha256, _freeze(parsed), + entry.origin, entry.source_revision, str(entry.source_url) if entry.source_url is not None else None)) + return SourceInventory( + manifest.upstream_revision, + manifest.source_schema_ref, + hashlib.sha256(manifest_bytes).hexdigest(), + tuple(documents), + ) + + +def read_sources(root: Path) -> tuple[SourceDocument, ...]: + """Return complete authored documents without changing the existing interface.""" + return read_inventory(root).documents diff --git a/sdk/nexent/core/model_service/profiles/_models_dev/validation.py b/sdk/nexent/core/model_service/profiles/_models_dev/validation.py new file mode 100644 index 0000000000..13f97ab9f2 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/_models_dev/validation.py @@ -0,0 +1,174 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Strict selected authored schemas pinned to models.dev e69e184. + +Submission-only checks of consumed facts; reference metadata is not validated. +""" + +import math +from collections.abc import Mapping +from dataclasses import dataclass + +from nexent.core.model_service.profiles._models_dev.reader import SourceDocument, SourceReadError + + +_REFERENCE_FIELDS = frozenset({"description", "family", "knowledge", "release_date", "last_updated", + "license", "links", "weights", "benchmarks", "cost", "status", "env", "npm", "doc"}) + + +@dataclass(frozen=True) +class ObjectSchema: + fields: Mapping[str, object] + required: tuple[str, ...] = () + + +@dataclass(frozen=True) +class ArraySchema: + item: object + + +def _error(document: SourceDocument, path: str, category: str = "SOURCE_VALUE_INVALID") -> SourceReadError: + error = SourceReadError(category, document.file_path, document.kind) + error.source_path = path + error.rule_id = "models_dev_authored_v1" + return error + + +def _validate(value: object, schema: object, document: SourceDocument, path: str, partial: bool = False) -> None: + if isinstance(schema, ObjectSchema): + if not isinstance(value, Mapping): + raise _error(document, path) + for key, item in value.items(): + field_path = f"{path}.{key}" if path else key + if not path and key in _REFERENCE_FIELDS: + continue + if key not in schema.fields: + raise _error(document, field_path, "SOURCE_FIELD_UNHANDLED") + _validate(item, schema.fields[key], document, field_path, partial) + if not partial: + for key in schema.required: + if key not in value: + raise _error(document, f"{path}.{key}" if path else key, "SOURCE_FIELD_REQUIRED") + return + if isinstance(schema, ArraySchema): + if not isinstance(value, (list, tuple)): + raise _error(document, path) + for index, item in enumerate(value): + _validate(item, schema.item, document, f"{path}[{index}]", partial) + return + valid = False + if isinstance(schema, frozenset): + valid = isinstance(value, str) and value in schema + elif schema == "boolean": + valid = type(value) is bool + elif schema == "string": + valid = isinstance(value, str) + elif schema == "nonempty": + valid = isinstance(value, str) and len(value) > 0 + elif schema in ("number", "nonnegative", "budget_min", "tokens"): + valid = type(value) is int or (type(value) is float and math.isfinite(value)) + if valid and schema != "number": + valid = value >= (-1 if schema == "budget_min" else 0) + if valid and schema in ("tokens", "budget_min"): + valid = type(value) is int + elif schema == "score": + valid = (type(value) is int or (type(value) is float and math.isfinite(value))) or ( + isinstance(value, str) and bool(value) + ) + elif schema == "interleaved": + if value is True: + return + _validate( + value, + ObjectSchema({"field": frozenset({"reasoning_content", "reasoning_details"})}, ("field",)), + document, + path, + partial, + ) + return + elif schema == "reasoning_option": + if not isinstance(value, Mapping) or value.get("type") not in ("toggle", "effort", "budget_tokens"): + raise _error(document, f"{path}.type") + mode = value["type"] + fields = {"type": frozenset({mode})} + required = ("type",) + if mode == "effort": + fields["values"] = ArraySchema( + frozenset({"none", "minimal", "low", "medium", "high", "xhigh", "max", "default", "null"}) + ) + required += ("values",) + if mode == "budget_tokens": + fields.update(min="budget_min", max="tokens") + _validate(value, ObjectSchema(fields, required), document, path, partial) + if mode == "budget_tokens" and "min" in value and "max" in value and value["min"] > value["max"]: + raise _error(document, f"{path}.min", "SOURCE_CONSTRAINT_INVALID") + return + if not valid: + raise _error(document, path) + + +_MODALITIES = ObjectSchema( + { + "input": ArraySchema(frozenset({"text", "audio", "image", "video", "pdf"})), + "output": ArraySchema(frozenset({"text", "audio", "image", "video", "pdf"})), + }, + ("input", "output"), +) +_LIMIT = ObjectSchema({"context": "tokens", "input": "tokens", "output": "tokens"}, ("context",)) +_COMMON = { + "name": "nonempty", + **dict.fromkeys( + ("attachment", "reasoning", "tool_call", "structured_output", "temperature", "open_weights"), "boolean" + ), + "modalities": _MODALITIES, + "limit": _LIMIT, +} +_MODEL = ObjectSchema( + { + **_COMMON, + }, + ("name",), +) +_PROVIDER_MODEL = ObjectSchema( + { + **_COMMON, + "limit": ObjectSchema(_LIMIT.fields, ("context", "output")), + "reasoning_options": ArraySchema("reasoning_option"), + "interleaved": "interleaved", + }, + ( + "name", + "attachment", + "reasoning", + "tool_call", + "modalities", + "open_weights", + "limit", + ), +) +_PROVIDER = ObjectSchema( + {"name": "nonempty", "env": ArraySchema("string"), "npm": "nonempty", "api": "string", "doc": "nonempty"}, + ("name",), +) + + +def validate_source_document(document: SourceDocument, *, effective: bool = False) -> None: + """Validate authored overrides separately from complete inherited results. + + Valid upstream fields outside the selected subset fail explicitly until their + source dispositions are registered. They are never silently ignored. + """ + schema = {"model": _MODEL, "provider": _PROVIDER, "provider_model": _PROVIDER_MODEL}.get(document.kind) + if schema is None: + raise _error(document, "", "SOURCE_MODEL_KIND_INVALID") + partial = document.kind == "provider_model" and "base_model" in document.data and not effective + if partial: + schema = ObjectSchema( + {**schema.fields, "base_model": "nonempty", "base_model_omit": ArraySchema("nonempty")}, ("base_model",) + ) + _validate(document.data, schema, document, "", partial) + if document.kind == "provider_model" and not partial: + reasoning = document.data["reasoning"] + options = "reasoning_options" in document.data + if (reasoning and not options) or (not reasoning and options): + raise _error(document, "reasoning_options", "SOURCE_CONSTRAINT_INVALID") diff --git a/sdk/nexent/core/model_service/profiles/contracts.py b/sdk/nexent/core/model_service/profiles/contracts.py new file mode 100644 index 0000000000..964bb47f88 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/contracts.py @@ -0,0 +1,238 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Strict profile primitives, independent of provider clients and persistence.""" + +import json +from typing import Annotated, Literal, Self + +from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator + + +SupportStatus = Literal["supported", "unsupported", "unknown"] +NonBlankString = Annotated[str, StringConstraints(pattern=r"\S")] +PositiveTokens = Annotated[int, Field(gt=0)] + + +class ProfileContract(BaseModel): + """Reject undeclared fields and coercion at the profile boundary.""" + + model_config = ConfigDict(strict=True, extra="forbid", frozen=True) + + +class ContextCapabilities(ProfileContract): + """Effective service limits; missing limits remain unknown.""" + + combined_window: PositiveTokens | None = None + input_limit: PositiveTokens | None = None + output_limit: PositiveTokens | None = None + + @model_validator(mode="after") + def validate_limits(self) -> Self: + if self.combined_window is not None: + for limit in (self.input_limit, self.output_limit): + if limit is not None and limit > self.combined_window: + raise ValueError("Service limits cannot exceed the combined context window") + return self + + +class ToggleControl(ProfileContract): + """Explicitly supported thinking switch values, not the current setting.""" + + mode: Literal["toggle"] = "toggle" + parameter_key: Literal["enable_thinking"] = "enable_thinking" + values: list[bool] = Field(min_length=1) + + @model_validator(mode="after") + def validate_values(self) -> Self: + if len(set(self.values)) != len(self.values): + raise ValueError("Toggle values must be unique") + return self + + +class EffortControl(ProfileContract): + """Provider-declared effort values; no universal effort enum is inferred.""" + + mode: Literal["effort"] = "effort" + parameter_key: Literal["reasoning_effort"] = "reasoning_effort" + values: list[NonBlankString | None] = Field(min_length=1) + + @model_validator(mode="after") + def validate_values(self) -> Self: + if len(set(self.values)) != len(self.values): + raise ValueError("Effort values must be unique") + return self + + +class BudgetTokensControl(ProfileContract): + """Known or unknown token bounds and explicitly declared sentinel values.""" + + mode: Literal["budget_tokens"] = "budget_tokens" + parameter_key: Literal["reasoning_budget_tokens"] = "reasoning_budget_tokens" + min: Annotated[int, Field(ge=0)] | None = None + max: Annotated[int, Field(ge=0)] | None = None + special_values: list[int] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_range(self) -> Self: + if self.min is not None and self.max is not None and self.min > self.max: + raise ValueError("Budget minimum cannot exceed maximum") + if len(set(self.special_values)) != len(self.special_values): + raise ValueError("Special budget values must be unique") + return self + + +ReasoningControl = Annotated[ToggleControl | EffortControl | BudgetTokensControl, Field(discriminator="mode")] + + +class ReasoningCapabilities(ProfileContract): + """Capability declarations with unknown distinct from unsupported.""" + + status: SupportStatus = "unknown" + controls: list[ReasoningControl] | None = None + response_paths: list[NonBlankString] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_controls(self) -> Self: + if self.status == "unsupported" and (self.controls or self.response_paths): + raise ValueError("Unsupported reasoning cannot declare controls or response paths") + if len(set(self.response_paths)) != len(self.response_paths): + raise ValueError("Reasoning response paths must be unique") + if self.controls is not None: + modes = [control.mode for control in self.controls] + keys = [control.parameter_key for control in self.controls] + if len(set(modes)) != len(modes) or len(set(keys)) != len(keys): + raise ValueError("Reasoning control modes and parameter keys must be unique") + encoded = json.dumps( + [control.model_dump(mode="json") for control in self.controls], + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + if len(encoded) > 64 * 1024: + raise ValueError("Reasoning controls exceed the 64 KiB UTF-8 limit") + return self + + +class AtomicProfileReference(ProfileContract): + """Provenance only; runtime loading never recomposes from this reference.""" + + key: NonBlankString + catalog_revision: NonBlankString + file_path: NonBlankString + content_sha256: Annotated[str, StringConstraints(pattern=r"^[a-f0-9]{64}$")] + + @model_validator(mode="after") + def validate_source_path(self) -> Self: + parts = self.file_path.split("/") + if ( + "\\" in self.file_path + or ":" in self.file_path + or any(ord(character) < 32 for character in self.file_path) + or any(part in ("", ".", "..") for part in parts) + ): + raise ValueError("Source paths must be canonical relative POSIX paths") + return self + + +class VersionedReference(ProfileContract): + """An explicit registry identity; never a dynamic import path.""" + + ref: NonBlankString + version: Annotated[int, Field(gt=0)] + + +class InstructionRoleMappings(ProfileContract): + """All Nexent semantic instruction/resource categories need explicit targets.""" + + platform_instruction: NonBlankString + application_instruction: NonBlankString + user_input: NonBlankString + assistant_history: NonBlankString + tool_result: NonBlankString + reference_data: NonBlankString + + +class InstructionMappingDefinition(ProfileContract): + """Extensible declaration; execution requires a matching registered strategy.""" + + strategy_id: NonBlankString + strategy_version: Annotated[int, Field(gt=0)] + instruction_hierarchy: list[NonBlankString] + supported_roles: list[NonBlankString] + mappings: InstructionRoleMappings + ordering: list[NonBlankString] + reference_placement: NonBlankString + + @model_validator(mode="after") + def validate_declared_roles(self) -> Self: + for values in (self.instruction_hierarchy, self.supported_roles, self.ordering): + if not values or len(set(values)) != len(values): + raise ValueError("Instruction lists must be nonempty and unique") + if not set(self.instruction_hierarchy) <= set(self.supported_roles): + raise ValueError("Instruction hierarchy must use supported roles") + if not set(self.mappings.model_dump().values()) <= set(self.supported_roles): + raise ValueError("All semantic roles require supported targets") + return self + + +class CapabilityFlag(ProfileContract): + """Missing capability evidence stays unknown.""" + + status: SupportStatus = "unknown" + + +class ToolCapabilities(ProfileContract): + """Native tool support and explicit registered format/constraint contracts.""" + + status: SupportStatus = "unknown" + schema_constraints: VersionedReference | None = None + result_format: NonBlankString | None = None + + +class SamplingCapabilities(ProfileContract): + """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): + input: list[NonBlankString] | None = None + output: list[NonBlankString] | None = None + + @model_validator(mode="after") + def validate_unique_modalities(self) -> Self: + for values in (self.input, self.output): + if values is not None and len(set(values)) != len(values): + raise ValueError("Modalities must be unique") + return self + + +class ResponseCapabilities(ProfileContract): + supported_finish_reasons: list[NonBlankString] | None = None + stream_format: NonBlankString | None = None + + @model_validator(mode="after") + def validate_finish_reasons(self) -> Self: + if self.supported_finish_reasons is not None: + if len(set(self.supported_finish_reasons)) != len(self.supported_finish_reasons): + raise ValueError("Finish reasons must be unique") + return self + + +class UsageCapabilities(ProfileContract): + semantics_id: NonBlankString | None = None + input_includes_cached: SupportStatus = "unknown" + output_includes_reasoning: SupportStatus = "unknown" + + +class ProfileCapabilities(ProfileContract): + """Strict complete groups; unknown controls never become invented defaults.""" + + context: ContextCapabilities = Field(default_factory=ContextCapabilities) + reasoning: ReasoningCapabilities = Field(default_factory=ReasoningCapabilities) + tools: ToolCapabilities = Field(default_factory=ToolCapabilities) + sampling: SamplingCapabilities = Field(default_factory=SamplingCapabilities) + structured_output: CapabilityFlag = Field(default_factory=CapabilityFlag) + modalities: ModalityCapabilities = Field(default_factory=ModalityCapabilities) + response: ResponseCapabilities = Field(default_factory=ResponseCapabilities) + usage: UsageCapabilities = Field(default_factory=UsageCapabilities) diff --git a/sdk/nexent/core/model_service/profiles/instruction_mapping.py b/sdk/nexent/core/model_service/profiles/instruction_mapping.py new file mode 100644 index 0000000000..6a0d80fcb7 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/instruction_mapping.py @@ -0,0 +1,46 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Registered traditional instruction metadata, independent of prompt rendering.""" + +from nexent.core.model_service.profiles.contracts import InstructionMappingDefinition, VersionedReference + + +class InstructionMappingError(ValueError): + """A sanitized registry error without caller-supplied prompt content.""" + + def __init__(self, category: str): + self.category = category + super().__init__(category) + + +def resolve_instruction_mapping(reference: VersionedReference) -> InstructionMappingDefinition: + """Return a detached declaration for the initial traditional role strategy.""" + if reference.ref != "system_only_v1" or reference.version != 1: + raise InstructionMappingError("UNSUPPORTED_INSTRUCTION_MAPPING") + return InstructionMappingDefinition( + strategy_id="system_only", + strategy_version=1, + instruction_hierarchy=["system", "user"], + supported_roles=["system", "user", "assistant", "tool"], + mappings={ + "platform_instruction": "system", + "application_instruction": "system", + "user_input": "user", + "assistant_history": "assistant", + "tool_result": "tool", + "reference_data": "user", + }, + ordering=["platform_instruction", "agent_instruction", "activated_skill_instruction"], + reference_placement="user_data_block", + ) + + +def validate_instruction_mapping(definition: InstructionMappingDefinition) -> VersionedReference: + """Reject declarations that claim unimplemented authority or mapping behavior.""" + reference = VersionedReference( + ref=f"{definition.strategy_id}_v{definition.strategy_version}", version=definition.strategy_version + ) + registered = resolve_instruction_mapping(reference) + if definition.model_dump() != registered.model_dump(): + raise InstructionMappingError("UNSUPPORTED_INSTRUCTION_MAPPING") + return reference diff --git a/sdk/nexent/core/model_service/profiles/parameter_bindings.py b/sdk/nexent/core/model_service/profiles/parameter_bindings.py new file mode 100644 index 0000000000..650421a492 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/parameter_bindings.py @@ -0,0 +1,55 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Validate explicit body bindings against registered parameter transforms.""" + +from typing import Mapping, Sequence + +from pydantic import ValidationError + +from nexent.core.model_service.profiles._models_dev.reader import _freeze +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.profiles.parameter_rules import CompiledParameterRules +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError +from nexent.core.model_service.profiles.request_parameters import _CONTROLLED_PATHS, _overlap, _tokens + + +def validate_parameter_bindings( + rules: CompiledParameterRules, + registered_transforms: Sequence[VersionedReference], + *, + protected_paths: Sequence[str] = (), +) -> Mapping[str, Mapping[str, object]]: + """Return immutable declarations; this does not render or send model requests.""" + protected = [_tokens(path) for path in (*_CONTROLLED_PATHS, *protected_paths)] + paths, bindings = [], {} + for rule in rules.declarations: + location = "/parameter_rules/" + rule["key"].replace("~", "~0").replace("/", "~1") + "/api_binding" + binding = rule.get("api_binding") + if binding is None: + if rule["visible"] or rule["editable"]: + raise ParameterValidationError("PARAMETER_BINDING_MISSING", location) + continue + if not isinstance(binding, dict) or set(binding) != {"path", "transform_ref", "transform_version"}: + raise ParameterValidationError("PARAMETER_BINDING_INVALID", location) + try: + reference = VersionedReference.model_validate( + { + "ref": binding["transform_ref"], + "version": binding["transform_version"], + } + ) + except ValidationError: + raise ParameterValidationError("PARAMETER_BINDING_INVALID", location) from None + if reference not in registered_transforms: + raise ParameterValidationError("PARAMETER_TRANSFORM_UNREGISTERED", location) + try: + tokens = _tokens(binding["path"]) + except ParameterValidationError: + raise ParameterValidationError("PARAMETER_BINDING_INVALID", location) from None + if any(_overlap(tokens, path) for path in protected): + raise ParameterValidationError("RESERVED_API_PATH", location) + if any(_overlap(tokens, path) for path in paths): + raise ParameterValidationError("PARAMETER_BINDING_CONFLICT", location) + paths.append(tokens) + bindings[rule["key"]] = binding + return _freeze(bindings) diff --git a/sdk/nexent/core/model_service/profiles/parameter_rules.py b/sdk/nexent/core/model_service/profiles/parameter_rules.py new file mode 100644 index 0000000000..c496ba08c5 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/parameter_rules.py @@ -0,0 +1,265 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Explicit parameter policy shared by outlet and conversation configuration.""" + +import json +from dataclasses import dataclass, field +from typing import Literal, Mapping + +from pydantic import Field, JsonValue, ValidationError + +from nexent.core.model_service.profiles.contracts import NonBlankString, ProfileContract +from nexent.core.model_service.profiles.parameter_schema import ( + CompiledParameterSchema, + ParameterValidationError, + compile_parameter_schema, + json_values_equal, + validate_parameter_value, +) + + +class ParameterRule(ProfileContract): + """Declaration only; field presence distinguishes missing from explicit null.""" + + key: NonBlankString + value_schema: dict = Field(alias="schema") + visible: bool + editable: bool + required: bool + allowed_scopes: list[Literal["outlet", "conversation"]] + ui_group: NonBlankString + owner: Literal["model", "provider", "combination"] + source: dict[str, JsonValue] + default_value: JsonValue = Field(default=None, repr=False) + fixed_value: JsonValue = Field(default=None, repr=False) + condition: dict | None = None + api_binding: dict[str, JsonValue] | None = None + + +@dataclass(frozen=True) +class CompiledParameterRules: + """Validated immutable declarations, schemas and dependency ordering.""" + + _declarations: bytes = field(repr=False) + _schemas: tuple[tuple[str, CompiledParameterSchema], ...] = field(repr=False) + _order: tuple[str, ...] + + @property + def declarations(self) -> list[dict]: + return json.loads(self._declarations) + + +def _pointer(key: str) -> str: + return "/" + key.replace("~", "~0").replace("/", "~1") + + +def _check_condition(condition: object, keys: set[str], path: str) -> set[str]: + if not isinstance(condition, dict): + raise ParameterValidationError("PARAMETER_CONDITION_INVALID", path) + for operator in ("all", "any"): + if operator in condition: + children = condition[operator] + if set(condition) != {operator} or not isinstance(children, list) or not children: + raise ParameterValidationError("PARAMETER_CONDITION_INVALID", path) + dependencies = set() + for index, child in enumerate(children): + dependencies.update(_check_condition(child, keys, f"{path}/{operator}/{index}")) + return dependencies + if ( + set(condition) != {"key", "op", "value"} + or not isinstance(condition.get("key"), str) + or condition["key"] not in keys + ): + raise ParameterValidationError("PARAMETER_CONDITION_INVALID", path) + operator, value = condition["op"], condition["value"] + if operator not in ("eq", "in", "exists"): + raise ParameterValidationError("PARAMETER_CONDITION_INVALID", path) + if operator == "exists" and type(value) is not bool: + raise ParameterValidationError("PARAMETER_CONDITION_INVALID", path) + if operator == "in" and (not isinstance(value, list) or not value): + raise ParameterValidationError("PARAMETER_CONDITION_INVALID", path) + validate_parameter_value(compile_parameter_schema({}), value, path) + return {condition["key"]} + + +def _condition_matches(condition: dict | None, values: dict) -> bool: + if condition is None: + return True + if "all" in condition: + return all(_condition_matches(child, values) for child in condition["all"]) + if "any" in condition: + return any(_condition_matches(child, values) for child in condition["any"]) + key, operator, expected = condition["key"], condition["op"], condition["value"] + if operator == "exists": + return (key in values) is expected + if key not in values: + return False + if operator == "eq": + return json_values_equal(values[key], expected) + return any(json_values_equal(values[key], option) for option in expected) + + +def compile_parameter_rules(declarations: list[dict]) -> CompiledParameterRules: + """Reject ambiguous rules before they can accept user configuration.""" + validate_parameter_value(compile_parameter_schema({"type": "array", "items": {"type": "object"}}), declarations) + encoded = json.dumps(declarations, ensure_ascii=False, allow_nan=False, sort_keys=True).encode("utf-8") + # Work from a detached JSON snapshot, never retain caller-owned containers. + copied = json.loads(encoded) + rules, schemas = {}, {} + for declaration in copied: + try: + rule = ParameterRule.model_validate(declaration) + except ValidationError: + raise ParameterValidationError("PARAMETER_RULE_INVALID") from None + if rule.key in rules or len(set(rule.allowed_scopes)) != len(rule.allowed_scopes): + raise ParameterValidationError("PARAMETER_RULE_INVALID", _pointer(rule.key)) + schema = compile_parameter_schema(rule.value_schema) + for name in ("default_value", "fixed_value"): + if name in rule.model_fields_set: + validate_parameter_value(schema, getattr(rule, name), _pointer(rule.key)) + if {"default_value", "fixed_value"} <= rule.model_fields_set and not json_values_equal( + rule.default_value, rule.fixed_value + ): + raise ParameterValidationError("PARAMETER_RULE_INVALID", _pointer(rule.key)) + rules[rule.key], schemas[rule.key] = rule, schema + dependencies = { + key: _check_condition(rule.condition, set(rules), _pointer(key) + "/condition") + if rule.condition is not None + else set() + for key, rule in rules.items() + } + order = [] + while dependencies: + ready = sorted(key for key, parents in dependencies.items() if not parents) + if not ready: + raise ParameterValidationError("PARAMETER_CONDITION_CYCLE", _pointer(min(dependencies))) + order.extend(ready) + for key in ready: + del dependencies[key] + for parents in dependencies.values(): + parents.difference_update(ready) + return CompiledParameterRules(encoded, tuple(schemas.items()), tuple(order)) + + +def apply_parameter_values( + compiled: CompiledParameterRules, + submitted: Mapping[str, object], + *, + scope: Literal["outlet", "conversation"], + saved_values: Mapping[str, object], +) -> dict: + """Build a detached effective view; never mutate saved values or declarations.""" + if scope not in ("outlet", "conversation"): + raise ParameterValidationError("PARAMETER_SCOPE_INVALID") + object_schema = compile_parameter_schema({"type": "object"}) + validate_parameter_value(object_schema, submitted) + validate_parameter_value(object_schema, saved_values) + submitted = json.loads(json.dumps(dict(submitted))) + saved_values = json.loads(json.dumps(dict(saved_values))) + rules = {rule["key"]: rule for rule in compiled.declarations} + schemas = dict(compiled._schemas) + for key in submitted.keys() | saved_values.keys(): + if key not in rules: + raise ParameterValidationError("PARAMETER_UNKNOWN", _pointer(key)) + values = {} + for key, rule in rules.items(): + if "fixed_value" in rule: + values[key] = rule["fixed_value"] + if key in saved_values and not json_values_equal(saved_values[key], values[key]): + raise ParameterValidationError("FIXED_PARAMETER", _pointer(key)) + elif key in saved_values: + values[key] = saved_values[key] + elif "default_value" in rule: + values[key] = rule["default_value"] + if key in submitted: + if "fixed_value" in rule: + if not json_values_equal(submitted[key], values[key]): + raise ParameterValidationError("FIXED_PARAMETER", _pointer(key)) + elif not rule["editable"] or scope not in rule["allowed_scopes"]: + raise ParameterValidationError("PARAMETER_NOT_EDITABLE", _pointer(key)) + values[key] = submitted[key] + if key in values: + validate_parameter_value(schemas[key], values[key], _pointer(key)) + for key in compiled._order: + rule = rules[key] + if not _condition_matches(rule.get("condition"), values): + if key in submitted: + raise ParameterValidationError("PARAMETER_DEPENDENCY", _pointer(key)) + values.pop(key, None) + elif rule["required"] and key not in values: + raise ParameterValidationError("PARAMETER_VALUE_REQUIRED", _pointer(key)) + return values + + +def overlay_saved_parameter_values(compiled, submitted, *, scope, saved_values): + """Apply explicit run choices without creation defaults or silent dependency removal.""" + if scope not in {"outlet", "conversation"}: + raise ParameterValidationError("PARAMETER_SCOPE_INVALID") + validate_parameter_value(compile_parameter_schema({"type": "object"}), submitted) + rules = {rule["key"]: rule for rule in compiled.declarations} + values = dict(saved_values) + for key, value in submitted.items(): + if key not in rules: + raise ParameterValidationError("PARAMETER_UNKNOWN", _pointer(key)) + rule = rules[key] + if scope == "conversation" and (not rule["editable"] or scope not in rule["allowed_scopes"]): + raise ParameterValidationError("PARAMETER_NOT_EDITABLE", _pointer(key)) + if "fixed_value" in rule: + if not json_values_equal(value, rule["fixed_value"]): + raise ParameterValidationError("FIXED_PARAMETER", _pointer(key)) + elif (not rule["editable"] or scope not in rule["allowed_scopes"]) and not ( + key in saved_values and json_values_equal(value, saved_values[key])): + raise ParameterValidationError("PARAMETER_NOT_EDITABLE", _pointer(key)) + values[key] = value + return validate_saved_parameter_values(compiled, 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 + + +def overlay_profile_parameters(profile, submitted, *, scope): + """Exclude inactive inherited thinking values, never contradictory explicit input.""" + inherited = dict(profile.parameter_values) + if submitted.get("enable_thinking") is False: + for rule in profile.parameter_rules: + if (rule.get("condition") == {"key": "enable_thinking", "op": "eq", "value": True} + and rule["key"] not in submitted): + inherited.pop(rule["key"], None) + values = overlay_saved_parameter_values( + compile_parameter_rules(profile.parameter_rules), submitted, scope=scope, saved_values=inherited, + ) + data = profile.model_dump(mode="json") + data["parameter_values"] = values + return profile.__class__.model_validate(data) + + +def patch_parameter_values(saved, submitted, deleted): + """Apply explicit deletions without copying defaults into persistent overrides.""" + if len(set(deleted)) != len(deleted) or set(deleted) & submitted.keys(): + raise ParameterValidationError("PARAMETER_UPDATE_CONFLICT") + return {**{key: value for key, value in saved.items() if key not in deleted}, **submitted} diff --git a/sdk/nexent/core/model_service/profiles/parameter_schema.py b/sdk/nexent/core/model_service/profiles/parameter_schema.py new file mode 100644 index 0000000000..88eca4c5dc --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/parameter_schema.py @@ -0,0 +1,214 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Finite parameter schema validation without evaluation or remote resolution.""" + +import json +import math +from dataclasses import dataclass, field +from typing import Mapping + + +_KEYWORDS = frozenset( + { + "type", + "enum", + "const", + "minimum", + "maximum", + "minLength", + "maxLength", + "items", + "properties", + "required", + "additionalProperties", + } +) +_TYPES = frozenset({"null", "boolean", "integer", "number", "string", "array", "object"}) + + +class ParameterValidationError(ValueError): + """A value-free diagnostic suitable for configuration error projection.""" + + def __init__(self, category: str, path: str = "", keyword: str = ""): + self.category = category + self.path = path + self.keyword = keyword + super().__init__(f"{category}: {path}: {keyword}") + + +@dataclass(frozen=True) +class CompiledParameterSchema: + """Canonical immutable schema bytes; callers receive independent copies.""" + + _json: bytes = field(repr=False) + + @property + def schema(self) -> dict: + return json.loads(self._json) + + +def _path(parent: str, key: object) -> str: + return parent + "/" + str(key).replace("~", "~0").replace("/", "~1") + + +def _json_copy(value: object, path: str = "", ancestors: frozenset[int] = frozenset()): + if value is None or type(value) in (bool, int, str): + return value + if type(value) is float: + if math.isfinite(value): + return value + elif isinstance(value, (Mapping, list, tuple)): + if id(value) in ancestors: + raise ParameterValidationError("PARAMETER_JSON_INVALID", path) + ancestors = ancestors | {id(value)} + if isinstance(value, Mapping): + copied = {} + for key, item in value.items(): + if not isinstance(key, str): + raise ParameterValidationError("PARAMETER_JSON_INVALID", path) + copied[key] = _json_copy(item, _path(path, key), ancestors) + return copied + return [_json_copy(item, _path(path, index), ancestors) for index, item in enumerate(value)] + raise ParameterValidationError("PARAMETER_JSON_INVALID", path) + + +def json_values_equal(left: object, right: object) -> bool: + """JSON numeric equality never conflates booleans with numbers.""" + if type(left) in (int, float) and type(right) in (int, float): + return left == right + if isinstance(left, Mapping) and isinstance(right, Mapping): + return left.keys() == right.keys() and all(json_values_equal(value, right[key]) for key, value in left.items()) + if isinstance(left, (list, tuple)) and isinstance(right, (list, tuple)): + return len(left) == len(right) and all(json_values_equal(a, b) for a, b in zip(left, right)) + return type(left) is type(right) and left == right + + +def _check_schema(schema: object, path: str = "") -> None: + if not isinstance(schema, dict): + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", path) + for key in schema: + if key not in _KEYWORDS: + raise ParameterValidationError("PARAMETER_SCHEMA_KEYWORD_UNSUPPORTED", _path(path, key), key) + if "type" in schema: + types = schema["type"] if isinstance(schema["type"], list) else [schema["type"]] + if ( + not types + or any(not isinstance(kind, str) or kind not in _TYPES for kind in types) + or len(set(types)) != len(types) + ): + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", _path(path, "type"), "type") + if "enum" in schema: + values = schema["enum"] + if ( + not isinstance(values, list) + or not values + or any( + json_values_equal(value, previous) for index, value in enumerate(values) for previous in values[:index] + ) + ): + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", _path(path, "enum"), "enum") + for key in ("minimum", "maximum"): + if key in schema and type(schema[key]) not in (int, float): + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", _path(path, key), key) + for key in ("minLength", "maxLength"): + if key in schema and (type(schema[key]) is not int or schema[key] < 0): + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", _path(path, key), key) + for lower, upper in (("minimum", "maximum"), ("minLength", "maxLength")): + if lower in schema and upper in schema and schema[lower] > schema[upper]: + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", path, lower) + if "required" in schema: + keys = schema["required"] + if not isinstance(keys, list) or any(not isinstance(key, str) for key in keys) or len(set(keys)) != len(keys): + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", _path(path, "required"), "required") + if "items" in schema: + _check_schema(schema["items"], _path(path, "items")) + if "properties" in schema: + if not isinstance(schema["properties"], dict): + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", _path(path, "properties"), "properties") + for key, child in schema["properties"].items(): + _check_schema(child, _path(_path(path, "properties"), key)) + if "additionalProperties" in schema and type(schema["additionalProperties"]) is not bool: + _check_schema(schema["additionalProperties"], _path(path, "additionalProperties")) + + +def compile_parameter_schema(schema: Mapping[str, object]) -> CompiledParameterSchema: + """Reject unsupported syntax before parameter values can use this schema.""" + try: + copied = _json_copy(schema) + _check_schema(copied) + encoded = json.dumps( + copied, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + except (RecursionError, UnicodeError, TypeError, ValueError) as error: + if isinstance(error, ParameterValidationError): + raise + raise ParameterValidationError("PARAMETER_JSON_INVALID") from None + return CompiledParameterSchema(encoded) + + +def _matches(value: object, kind: str) -> bool: + return { + "null": value is None, + "boolean": type(value) is bool, + "integer": type(value) is int, + "number": type(value) in (int, float), + "string": isinstance(value, str), + "array": isinstance(value, list), + "object": isinstance(value, dict), + }[kind] + + +def _validate(schema: dict, value: object, path: str) -> None: + if "type" in schema: + kinds = schema["type"] if isinstance(schema["type"], list) else [schema["type"]] + if not any(_matches(value, kind) for kind in kinds): + raise ParameterValidationError("PARAMETER_VALUE_INVALID", path, "type") + if "enum" in schema and not any(json_values_equal(value, option) for option in schema["enum"]): + raise ParameterValidationError("PARAMETER_VALUE_INVALID", path, "enum") + if "const" in schema and not json_values_equal(value, schema["const"]): + raise ParameterValidationError("PARAMETER_VALUE_INVALID", path, "const") + if type(value) in (int, float): + for keyword in ("minimum", "maximum"): + if keyword in schema and ( + (keyword == "minimum" and value < schema[keyword]) + or (keyword == "maximum" and value > schema[keyword]) + ): + raise ParameterValidationError("PARAMETER_VALUE_INVALID", path, keyword) + if isinstance(value, str): + for keyword in ("minLength", "maxLength"): + if keyword in schema and ( + (keyword == "minLength" and len(value) < schema[keyword]) + or (keyword == "maxLength" and len(value) > schema[keyword]) + ): + raise ParameterValidationError("PARAMETER_VALUE_INVALID", path, keyword) + if isinstance(value, list) and "items" in schema: + for index, item in enumerate(value): + _validate(schema["items"], item, _path(path, index)) + if isinstance(value, dict): + for key in schema.get("required", ()): + if key not in value: + raise ParameterValidationError("PARAMETER_VALUE_REQUIRED", _path(path, key), "required") + properties = schema.get("properties", {}) + additional = schema.get("additionalProperties", True) + for key, item in value.items(): + if key in properties: + _validate(properties[key], item, _path(path, key)) + elif additional is False: + raise ParameterValidationError( + "PARAMETER_ADDITIONAL_PROPERTY", _path(path, key), "additionalProperties" + ) + elif isinstance(additional, dict): + _validate(additional, item, _path(path, key)) + + +def validate_parameter_value(schema: CompiledParameterSchema, value: object, path: str = "") -> None: + """Validate the submitted JSON value without defaulting or coercion.""" + try: + copied = _json_copy(value, path) + # Validate UTF-8 serializability as well as the Python structure. + json.dumps(copied, ensure_ascii=False, allow_nan=False).encode("utf-8") + _validate(schema.schema, copied, path) + except (RecursionError, UnicodeError, TypeError, ValueError) as error: + if isinstance(error, ParameterValidationError): + raise + raise ParameterValidationError("PARAMETER_JSON_INVALID", path) from None diff --git a/sdk/nexent/core/model_service/profiles/request_parameters.py b/sdk/nexent/core/model_service/profiles/request_parameters.py new file mode 100644 index 0000000000..42e087ca68 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/request_parameters.py @@ -0,0 +1,130 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Controlled body-only extra parameter validation and JSON Pointer application.""" + +import json +import re +from typing import Mapping, Sequence + +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError, _json_copy + + +PROTECTED_CONNECTION_PATHS = ("/api_key", "/authorization", "/headers", "/credentials", "/base_url") + +_CONTROLLED_PATHS = ("/model", "/messages", "/tools", "/tool_choice", "/stream", "/stream_options") +_MAX_BYTES, _MAX_DEPTH, _MAX_LEAVES = 64 * 1024, 16, 128 + + +def _clone(value: object): + try: + copied = _json_copy(value) + encoded = json.dumps(copied, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8") + return copied, encoded + except (ValueError, TypeError, RecursionError, UnicodeError) as error: + if isinstance(error, ParameterValidationError): + raise + raise ParameterValidationError("PARAMETER_JSON_INVALID") from None + + +def _tokens(path: object) -> tuple[str, ...]: + if not isinstance(path, str) or not path.startswith("/"): + raise ParameterValidationError("API_PATH_INVALID") + parts = path[1:].split("/") + if any(re.search(r"~(?![01])", part) for part in parts): + raise ParameterValidationError("API_PATH_INVALID", path) + return tuple(part.replace("~1", "/").replace("~0", "~") for part in parts) + + +def _overlap(left: tuple[str, ...], right: tuple[str, ...]) -> bool: + length = min(len(left), len(right)) + return left[:length] == right[:length] + + +def _leaves(value: object, depth: int = 0) -> int: + if depth > _MAX_DEPTH: + raise ParameterValidationError("EXTRA_API_PARAMETERS_LIMIT", keyword="depth") + if isinstance(value, dict): + return sum(_leaves(item, depth + 1) for item in value.values()) if value else 1 + if isinstance(value, list): + return sum(_leaves(item, depth + 1) for item in value) if value else 1 + return 1 + + +def _array_index(token: str, size: int, path: str) -> int: + if not re.fullmatch(r"0|[1-9][0-9]*", token): + raise ParameterValidationError("API_PATH_INVALID", path) + # Bound before integer conversion to reject oversized index strings safely. + if len(token) > len(str(size)) or int(token) >= size: + raise ParameterValidationError("API_PATH_INVALID", path) + return int(token) + + +def _set(body: dict, tokens: tuple[str, ...], value: object, path: str) -> None: + current = body + for token in tokens[:-1]: + if isinstance(current, dict): + if token not in current: + current[token] = {} + current = current[token] + elif isinstance(current, list): + current = current[_array_index(token, len(current), path)] + else: + raise ParameterValidationError("API_PATH_CONFLICT", path) + last = tokens[-1] + if isinstance(current, dict): + current[last] = value + elif isinstance(current, list): + current[_array_index(last, len(current), path)] = value + else: + raise ParameterValidationError("API_PATH_CONFLICT", path) + + +def apply_extra_api_parameters( + body: Mapping[str, object], + extras: Sequence[Mapping[str, object]], + *, + parameter_paths: Sequence[str] = (), + protected_paths: Sequence[str] = (), +) -> dict: + """Apply validated extras to a detached body, never to headers, URL or auth.""" + copied_body, _ = _clone(body) + copied_extras, encoded = _clone(extras) + if not isinstance(copied_body, dict) or not isinstance(copied_extras, list): + raise ParameterValidationError("PARAMETER_JSON_INVALID") + if len(encoded) > _MAX_BYTES: + raise ParameterValidationError("EXTRA_API_PARAMETERS_LIMIT", keyword="bytes") + protected = [_tokens(path) for path in (*_CONTROLLED_PATHS, *parameter_paths, *protected_paths)] + accepted, leaf_count = [], 0 + for entry in copied_extras: + if not isinstance(entry, dict) or set(entry) != {"path", "value"}: + raise ParameterValidationError("EXTRA_API_PARAMETER_INVALID") + path = entry["path"] + tokens = _tokens(path) + if any(_overlap(tokens, controlled) for controlled in protected): + raise ParameterValidationError("RESERVED_API_PATH", path) + if any(_overlap(tokens, existing) for existing, _ in accepted): + raise ParameterValidationError("API_PATH_CONFLICT", path) + leaf_count += _leaves(entry["value"]) + if leaf_count > _MAX_LEAVES: + raise ParameterValidationError("EXTRA_API_PARAMETERS_LIMIT", keyword="leaves") + accepted.append((tokens, entry)) + for tokens, entry in accepted: + _set(copied_body, tokens, entry["value"], entry["path"]) + return copied_body + + + +def overlay_extra_api_parameters(profile, overrides): + """Apply historical explicit custom replacements/removals at a boundary.""" + if not isinstance(overrides, Mapping) or any(not isinstance(key, str) or not key for key in overrides): + raise ParameterValidationError("LEGACY_CUSTOM_PARAMETERS_INVALID") + extras = list(profile.extra_api_parameters) + for key, value in overrides.items(): + path = "/" + key.replace("~", "~0").replace("/", "~1") + extras = [entry for entry in extras if entry["path"] != path and not entry["path"].startswith(path + "/")] + if value is not None: + extras.append({"path": path, "value": value}) + apply_extra_api_parameters({}, extras, + parameter_paths=[rule["api_binding"]["path"] for rule in profile.parameter_rules if rule.get("api_binding")], + protected_paths=PROTECTED_CONNECTION_PATHS) + return extras 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..6440cbe2ae --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/resolved_profile.py @@ -0,0 +1,202 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""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/sdk/nexent/core/model_service/resource_conversion.py b/sdk/nexent/core/model_service/resource_conversion.py new file mode 100644 index 0000000000..4a19ab4abe --- /dev/null +++ b/sdk/nexent/core/model_service/resource_conversion.py @@ -0,0 +1,104 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Convert already-authorized resources; execution and prompt policy stay in Runtime.""" + +import json +from typing import Literal +from uuid import uuid4 + +from nexent.core.agent_runtime_support.contracts import RuntimeResourceView as _RuntimeResourceView +from nexent.core.model_service.contracts import ModelMessage, ModelRequest +from nexent.core.model_service.profiles.contracts import ProfileContract, VersionedReference +from nexent.core.model_service.profiles.instruction_mapping import validate_instruction_mapping + + +class ConvertedModelRequest(ProfileContract): + request: ModelRequest + tool_protocol: Literal["native", "text_block"] + + +def _text(data): + return {"type": "text", "text": json.dumps(data, ensure_ascii=False, allow_nan=False)} + + +def _code_message(message): + value = message.model_dump() + value["reasoning_parts"] = [] # Diagnostic history is not a portable provider reasoning continuation. + if message.tool_calls: + value["content_parts"].append(_text({ + "historical_tool_calls": [ + {"id": call.provider_call_id, "name": call.name, "arguments": call.raw_arguments} + for call in message.tool_calls + ], + })) + value["tool_calls"] = [] + if message.semantic_role in {"tool_result", "tool"}: + value["semantic_role"] = "reference_data" + value["content_parts"].insert(0, _text({"historical_tool_result": message.tool_call_id})) + value["tool_call_id"] = None + return ModelMessage.model_validate(value) + + +class ResourceRequestConverter: + """One registered conversion per mode, chosen before any model request.""" + + def __init__(self, *, native=False): + if type(native) is not bool: + raise TypeError("Native mode must be boolean") + self.native = native + self.reference = VersionedReference(ref="native_tools_v1" if native else "code_messages_v1", version=1) + + def convert(self, resources, profile, *, stream=False, request_id=None, purpose="agent", run_id=None): + # Detach mutable collections before sorting or producing a request. + resources = _RuntimeResourceView.model_validate(resources.model_dump()) + validate_instruction_mapping(profile.instruction_mapping) + native = self.native and profile.capabilities.tools.status == "supported" + priorities = {name: index for index, name in enumerate(profile.instruction_mapping.ordering)} + instructions = sorted(resources.instructions, key=lambda item: ( + priorities.get("platform_instruction" if item.message.semantic_role == "platform_instruction" + else item.source, len(priorities)), item.order, + )) + instruction_ids = {item.message.message_id for item in instructions} + remainder = sorted((item for item in resources.items() if item.message.message_id not in instruction_ids), + key=lambda item: item.order) + messages = [item.message.model_copy(update={"source": item.source}) for item in instructions + remainder] + if native: + # Code histories can contain observations without provider-native call IDs. + messages = [_code_message(message) if message.semantic_role in {"tool_result", "tool"} + and message.tool_call_id is None else message for message in messages] + else: + messages = [_code_message(message) for message in messages] + if resources.tool_definitions: + description = ModelMessage( + semantic_role="reference_data", source="authorized_tool_definitions", + content_parts=[_text({"available_tools": [tool.model_dump() for tool in resources.tool_definitions]})], + ) + # Keep the current input in its original position relative to other resources. + index = next(index for index, message in enumerate(messages) + if message.message_id == resources.current_user_input.message.message_id) + messages.insert(index, description) + return ConvertedModelRequest( + request=ModelRequest( + request_id=request_id if request_id is not None else str(uuid4()), outlet_id=profile.outlet_id, + config_revision=profile.config_revision, messages=messages, + tools=resources.tool_definitions if native else [], stream=stream, purpose=purpose, run_id=run_id, + ), + tool_protocol="native" if native else "text_block", + ) + + +class ResourceConverterRegistry: + def __init__(self): + self._converters = tuple(ResourceRequestConverter(native=native) for native in (False, True)) + + def references(self, mode): + if mode not in {"code_converter", "native_converter"}: + raise ValueError("Unknown converter mode") + return tuple(converter.reference for converter in self._converters + if converter.native == (mode == "native_converter")) + + def resolve(self, reference): + for converter in self._converters: + if converter.reference == reference: + return converter + raise ValueError("MODEL_CONVERTER_UNREGISTERED") diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml index 4f95a9ac31..b1f928ca20 100644 --- a/sdk/pyproject.toml +++ b/sdk/pyproject.toml @@ -103,6 +103,7 @@ exclude = ["tests*", "examples*"] namespaces = true [tool.setuptools.package-data] +"nexent.core.model_service.profiles" = ["**/*.toml", "README.md", "LICENSE"] "nexent.core.prompts" = ["zh/*/*.yaml", "en/*/*.yaml"] "nexent.assets" = ["*.png", "*.jpg", "*.gif", "*.webp"] diff --git a/test/sdk/core/model_service/resource_fixtures.py b/test/sdk/core/model_service/resource_fixtures.py new file mode 100644 index 0000000000..110faca230 --- /dev/null +++ b/test/sdk/core/model_service/resource_fixtures.py @@ -0,0 +1,28 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Controlled model-visible resources shared by boundary and converter unit tests.""" + +from nexent.core.agent_runtime_support.contracts import ResourceItem, RuntimeResourceView +from nexent.core.model_service.contracts import ModelMessage, ToolCall, ToolDefinition + + +def item(role, text, order, source="history", **kwargs): + return ResourceItem(message=ModelMessage( + semantic_role=role, content_parts=[{"type": "text", "text": text}], **kwargs, + ), source=source, order=order) + + +def resources(): + return RuntimeResourceView( + instructions=[item("application_instruction", "skill", 2, "activated_skill_instruction"), + item("platform_instruction", "platform", 1, "platform_instruction")], + references=[item("reference_data", "knowledge", 3, "knowledge")], + history=[item("assistant_history", "progress", 4, tool_calls=[ToolCall( + provider_call_id="call-1", index=0, name="lookup", raw_arguments='{"q":"x"}', + )])], + tool_observations=[item("tool_result", "result", 5, "tool", tool_call_id="call-1")], + authorized_metadata=[item("reference_data", "approved metadata", 6, "metadata")], + current_user_input=item("user_input", "continue", 7, "current_input"), + tool_definitions=[ToolDefinition(name="lookup", description="Find data", input_schema={"type": "object"})], + ) + diff --git a/test/sdk/core/model_service/test_catalog_inventory.py b/test/sdk/core/model_service/test_catalog_inventory.py new file mode 100644 index 0000000000..c76bccf094 --- /dev/null +++ b/test/sdk/core/model_service/test_catalog_inventory.py @@ -0,0 +1,80 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""AC-01/16: integrity of the reviewed source inventory, not model support.""" + +import hashlib +import tomllib +from collections import Counter +from pathlib import Path + +import pytest + +CATALOG_ROOT = ( + Path(__file__).resolve().parents[4] / "sdk/nexent/core/model_service/profiles" +) +MANIFEST = tomllib.loads((CATALOG_ROOT / "manifest.toml").read_text()) + + +def test_reviewed_inventory_is_pinned_and_complete(): + assert MANIFEST["schema_version"] == 1 + assert MANIFEST["source_schema_ref"] == "models_dev_authored_v1" + assert MANIFEST["upstream_revision"] == "e69e1844210e5cfe7c7e7f68d57328bc7451a51c" + sources = MANIFEST["sources"] + assert Counter(source["kind"] for source in sources) == { + "model": 26, + "provider_model": 35, + "provider": 6, + } + paths = [source["path"] for source in sources] + assert len(paths) == len(set(paths)) + actual = { + str(path.relative_to(CATALOG_ROOT)) + for folder in ("models", "providers") + for path in (CATALOG_ROOT / folder).rglob("*.toml") + } + assert actual == set(paths) + + +@pytest.mark.parametrize( + "source", MANIFEST["sources"], ids=lambda source: source["path"] +) +def test_source_bytes_match_manifest_and_dependencies_are_present(source): + path = CATALOG_ROOT / source["path"] + data = path.read_bytes() + assert hashlib.sha256(data).hexdigest() == source["content_sha256"] + parsed = tomllib.loads(data.decode("utf-8")) + if "base_model" in parsed: + assert f"models/{parsed['base_model']}.toml" in { + item["path"] for item in MANIFEST["sources"] if item["kind"] == "model" + } + if source["kind"] == "provider": + assert parsed["api"].startswith("https://") + assert isinstance(parsed["env"], list) + + +def test_license_and_scope_notice_ship_with_inventory(): + license_text = (CATALOG_ROOT / "LICENSE").read_text() + assert "Copyright (c) 2025 models.dev" in license_text + assert "Permission is hereby granted, free of charge" in license_text + assert "THE SOFTWARE IS PROVIDED" in license_text + readme = (CATALOG_ROOT / "README.md").read_text() + assert MANIFEST["upstream_revision"] in readme + assert "not a verified support list" in readme + + +def test_requested_china_inventory_is_a_union_with_explicit_local_origin(): + paths = {source["path"] for source in MANIFEST["sources"]} + assert {"models/" + key + ".toml" for key in [ + "deepseek/deepseek-v4-flash", "deepseek/deepseek-v4.1-flash", "deepseek/deepseek-v4-pro", + "bytedance-seed/seed-2.1-pro", "zhipuai/glm-5", "zhipuai/glm-5.1", "zhipuai/glm-5.2", + "zhipuai/glm-5.3", "zhipuai/glm-5.3-flash", "moonshotai/kimi-k3", "minimax/MiniMax-M3", + "alibaba/qwen3.7-max", "alibaba/qwen3.7-plus", "alibaba/qwen3.8-max", "alibaba/qwen3.8-flash", + ]} <= paths + assert {"providers/" + key + "/provider.toml" for key in [ + "siliconflow-cn", "alibaba-cn", "volcengine", "volcengine-coding-plan", "deepseek", "tokenpony", + ]} <= paths + local = [source for source in MANIFEST["sources"] if source.get("origin") == "nexent"] + assert len(local) == 1 + assert local[0]["path"] == "providers/tokenpony/provider.toml" + assert local[0]["source_url"] == "https://docs.tokenpony.cn/" + assert local[0]["source_revision"] == "nexent_tokenpony_provider_v1" diff --git a/test/sdk/core/model_service/test_http_contracts.py b/test/sdk/core/model_service/test_http_contracts.py new file mode 100644 index 0000000000..390bf1d86a --- /dev/null +++ b/test/sdk/core/model_service/test_http_contracts.py @@ -0,0 +1,135 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Prepared HTTP boundary validation without external I/O.""" + +import json + +import pytest +from nexent.core.model_service.http_contracts import ( + PreparedHttpRequest, + model_request_url, + validate_headers, +) +from nexent.core.model_service.profiles.resolved_profile import CredentialReference + + +def prepared(**overrides): + values = { + "request_id": "request-1", + "method": "POST", + "base_url": "https://example.test/v1?api-version=1", + "relative_path": "chat/completions", + "headers": {}, + "credential_ref": CredentialReference(kind="none", model_id=None), + "json_body": {"model": "exact-model", "messages": []}, + "timeout_seconds": 10, + "ssl_verify": True, + } + return PreparedHttpRequest(**(values | overrides)) + + +def test_exact_root_query_and_body_are_preserved_and_detached(): + body = { + "model": "exact-model", + "messages": [{"content": "中文"}], + "temperature": 0, + "enable_thinking": False, + } + headers = {"Accept": "application/json"} + request = prepared(json_body=body, headers=headers) + body["messages"][0]["content"] = "mutated" + headers["Accept"] = "changed" + assert request.url == "https://example.test/v1/chat/completions?api-version=1" + assert json.loads(request.body_bytes)["messages"] == [{"content": "中文"}] + assert json.loads(request.body_bytes)["temperature"] == 0 + assert json.loads(request.body_bytes)["enable_thinking"] is False + assert request.headers == {"accept": "application/json"} + with pytest.raises(TypeError): + request.headers["accept"] = "changed" + assert "中文" not in repr(request) + assert "api-version" not in repr(request) + + +@pytest.mark.parametrize( + "root,path", + [ + ("ftp://example.test", "chat"), + ("https://user:password@example.test", "chat"), + ("https://example.test/#fragment", "chat"), + ("https://example.test:99999", "chat"), + ("https://example.test", "https://other.test/chat"), + ("https://example.test", "//other.test/chat"), + ("https://example.test", "../chat"), + ("https://example.test", "x/%2e%2e/chat"), + ("https://example.test", "chat?token=private"), + ("https://example.test", "chat#x"), + ("https://example.test", "chat\\x"), + ("https://example.test", "chat//x"), + ("https://example.test", ""), + ("https://example.test\n", "chat"), + (None, "chat"), + ], +) +def test_url_rejects_credentials_and_root_overrides_with_safe_error(root, path): + with pytest.raises(ValueError, match="^Invalid model HTTP URL$"): + model_request_url(root, path) + + +@pytest.mark.parametrize( + "headers", + [ + {"Authorization": "private"}, + {"Host": "other.test"}, + {"a": "private\n"}, + {"bad name": "private"}, + {"A": "1", "a": "2"}, + {"a": 3}, + None, + {"x-api-key": "private"}, + {"a": "中文"}, + ], +) +def test_fixed_headers_do_not_accept_auth_transport_or_invalid_values(headers): + with pytest.raises(ValueError, match="^Invalid model HTTP headers$"): + validate_headers(headers) + + +def test_explicit_credential_headers_accept_authorization_without_exposing_value(): + assert validate_headers({"Authorization": "Bearer dummy"}, credentials=True) == { + "authorization": "Bearer dummy" + } + + +@pytest.mark.parametrize( + "overrides", + [ + {"timeout_seconds": 0}, + {"timeout_seconds": float("nan")}, + {"timeout_seconds": float("inf")}, + {"timeout_seconds": True}, + {"ssl_verify": 1}, + {"method": "DELETE"}, + {"stream_protocol": "unknown"}, + {"json_body": []}, + {"request_id": " "}, + {"credential_ref": {}}, + ], +) +def test_invalid_prepared_request_is_rejected(overrides): + with pytest.raises(ValueError, match="^Invalid prepared model HTTP request$"): + prepared(**overrides) + + +@pytest.mark.parametrize( + "body", [{"value": float("nan")}, {"value": object()}, {"value": "\ud800"}] +) +def test_invalid_json_body_is_rejected_without_payload_echo(body): + with pytest.raises(ValueError, match="^Invalid model HTTP body$"): + prepared(json_body=body) + + +def test_leading_path_separator_preserves_api_root(): + assert ( + model_request_url("https://example.test/v1", "/chat/completions") + == "https://example.test/v1/chat/completions" + ) diff --git a/test/sdk/core/model_service/test_instruction_mapping.py b/test/sdk/core/model_service/test_instruction_mapping.py new file mode 100644 index 0000000000..fc977b5131 --- /dev/null +++ b/test/sdk/core/model_service/test_instruction_mapping.py @@ -0,0 +1,112 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Traditional instruction declarations and registry boundaries.""" + +import pytest +from nexent.core.model_service.profiles.contracts import ( + InstructionMappingDefinition, + VersionedReference, +) +from nexent.core.model_service.profiles.instruction_mapping import ( + InstructionMappingError, + resolve_instruction_mapping, + validate_instruction_mapping, +) +from pydantic import ValidationError + + +def traditional(): + return resolve_instruction_mapping( + VersionedReference(ref="system_only_v1", version=1) + ) + + +def test_traditional_strategy_maps_every_semantic_resource_explicitly(): + profile = traditional() + assert profile.instruction_hierarchy == ["system", "user"] + assert profile.supported_roles == ["system", "user", "assistant", "tool"] + assert profile.mappings.model_dump() == { + "platform_instruction": "system", + "application_instruction": "system", + "user_input": "user", + "assistant_history": "assistant", + "tool_result": "tool", + "reference_data": "user", + } + assert profile.ordering == [ + "platform_instruction", + "agent_instruction", + "activated_skill_instruction", + ] + assert profile.reference_placement == "user_data_block" + assert validate_instruction_mapping(profile) == VersionedReference( + ref="system_only_v1", version=1 + ) + + +@pytest.mark.parametrize( + "ref,version", [("system_only_v1", 2), ("developer_v1", 1), ("package.module", 1)] +) +def test_unknown_strategy_or_version_never_imports_or_falls_back(ref, version): + with pytest.raises( + InstructionMappingError, match="UNSUPPORTED_INSTRUCTION_MAPPING" + ): + resolve_instruction_mapping(VersionedReference(ref=ref, version=version)) + + +@pytest.mark.parametrize( + "field,value", + [ + ("supported_roles", ["system", "user", "assistant"]), + ("supported_roles", ["system", "user", "assistant", "tool", "tool"]), + ("instruction_hierarchy", []), + ("instruction_hierarchy", ["developer", "user"]), + ("ordering", []), + ("strategy_version", True), + ("strategy_version", 0), + ("unknown", 1), + ], +) +def test_contract_rejects_undefined_or_missing_role_semantics(field, value): + data = traditional().model_dump() + data[field] = value + with pytest.raises(ValidationError): + InstructionMappingDefinition.model_validate(data) + + +@pytest.mark.parametrize( + "field,value", + [ + ("instruction_hierarchy", ["user", "system"]), + ("supported_roles", ["system", "user", "assistant", "tool", "developer"]), + ("reference_placement", "tool"), + ("ordering", ["agent_instruction", "platform_instruction"]), + ], +) +def test_structurally_valid_unregistered_behavior_is_not_accepted(field, value): + data = traditional().model_dump() + data[field] = value + with pytest.raises( + InstructionMappingError, match="UNSUPPORTED_INSTRUCTION_MAPPING" + ): + validate_instruction_mapping(InstructionMappingDefinition.model_validate(data)) + + +def test_semantic_role_changes_require_a_registered_strategy(): + data = traditional().model_dump() + data["mappings"]["platform_instruction"] = "user" + with pytest.raises(InstructionMappingError): + validate_instruction_mapping(InstructionMappingDefinition.model_validate(data)) + del data["mappings"]["tool_result"] + with pytest.raises(ValidationError): + InstructionMappingDefinition.model_validate(data) + + +def test_returned_declarations_do_not_mutate_registry_defaults(): + profile = traditional() + profile.supported_roles.append("developer") + profile.ordering.reverse() + assert traditional().supported_roles == ["system", "user", "assistant", "tool"] + assert traditional().ordering[0] == "platform_instruction" + with pytest.raises(ValidationError): + profile.strategy_id = "developer" diff --git a/test/sdk/core/model_service/test_parameter_bindings.py b/test/sdk/core/model_service/test_parameter_bindings.py new file mode 100644 index 0000000000..bbd860922d --- /dev/null +++ b/test/sdk/core/model_service/test_parameter_bindings.py @@ -0,0 +1,139 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Parameter controls require explicit, registered and nonoverlapping body bindings.""" + +import pytest +from nexent.core.model_service.profiles.contracts import VersionedReference +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 ParameterValidationError + +IDENTITY = VersionedReference(ref="identity_v1", version=1) + + +def rule(key="temperature", path="/temperature", **changes): + declaration = { + "key": key, + "schema": {"type": "number"}, + "visible": True, + "editable": True, + "required": False, + "allowed_scopes": ["outlet", "conversation"], + "ui_group": "sampling", + "owner": "provider", + "source": {"ref": "fixture"}, + "api_binding": { + "path": path, + "transform_ref": "identity_v1", + "transform_version": 1, + }, + } + declaration.update(changes) + return declaration + + +def validate(declarations, transforms=(IDENTITY,), **options): + return validate_parameter_bindings( + compile_parameter_rules(declarations), transforms, **options + ) + + +def test_registered_binding_is_immutable_and_detached(): + declaration = rule() + bindings = validate([declaration]) + declaration["api_binding"]["path"] = "/different" + assert bindings["temperature"]["path"] == "/temperature" + with pytest.raises(TypeError): + bindings["temperature"]["path"] = "/different" + + +@pytest.mark.parametrize( + "visible,editable", [(True, True), (True, False), (False, True)] +) +def test_exposed_control_cannot_be_declared_without_binding(visible, editable): + with pytest.raises(ParameterValidationError, match="PARAMETER_BINDING_MISSING"): + validate([rule(api_binding=None, visible=visible, editable=editable)]) + + +def test_hidden_readonly_metadata_does_not_create_request_field(): + assert dict(validate([rule(api_binding=None, visible=False, editable=False)])) == {} + + +@pytest.mark.parametrize( + "binding", + [ + {"path": "/temperature", "transform_ref": "identity_v1"}, + { + "path": "/temperature", + "transform_ref": "identity_v1", + "transform_version": True, + }, + { + "path": "/temperature", + "transform_ref": "identity_v1", + "transform_version": 0, + }, + { + "path": "/temperature", + "transform_ref": "identity_v1", + "transform_version": 1, + "script": "secret-body", + }, + {"path": "temperature", "transform_ref": "identity_v1", "transform_version": 1}, + {"path": "/bad~escape", "transform_ref": "identity_v1", "transform_version": 1}, + ], +) +def test_invalid_binding_has_value_free_field_diagnostic(binding): + with pytest.raises( + ParameterValidationError, match="PARAMETER_BINDING_INVALID" + ) as captured: + validate([rule(api_binding=binding)]) + assert captured.value.path == "/parameter_rules/temperature/api_binding" + assert "secret-body" not in str(captured.value) + + +def test_reference_must_match_registered_name_and_version(): + for transforms in [(), (VersionedReference(ref="identity_v1", version=2),)]: + with pytest.raises( + ParameterValidationError, match="PARAMETER_TRANSFORM_UNREGISTERED" + ): + validate([rule()], transforms) + + +@pytest.mark.parametrize( + "path", + [ + "/model", + "/messages/0", + "/tools", + "/tool_choice", + "/stream", + "/stream_options/include_usage", + ], +) +def test_parameters_cannot_overwrite_runtime_owned_fields(path): + with pytest.raises(ParameterValidationError, match="RESERVED_API_PATH"): + validate([rule(path=path)]) + + +def test_adapter_owned_field_is_protected(): + with pytest.raises(ParameterValidationError, match="RESERVED_API_PATH"): + validate([rule(path="/options/auth")], protected_paths=["/options"]) + + +@pytest.mark.parametrize( + "left,right", + [("/options", "/options/effort"), ("/effort", "/effort"), ("/a~1b", "/a~1b/value")], +) +def test_duplicate_and_parent_child_bindings_are_rejected(left, right): + with pytest.raises(ParameterValidationError, match="PARAMETER_BINDING_CONFLICT"): + validate([rule("first", left), rule("second", right)]) + + +def test_distinct_escaped_fields_and_siblings_remain_distinct(): + bindings = validate( + [rule("first", "/a~1b"), rule("second", "/a/b"), rule("third", "/a/c")] + ) + assert set(bindings) == {"first", "second", "third"} diff --git a/test/sdk/core/model_service/test_parameter_rules.py b/test/sdk/core/model_service/test_parameter_rules.py new file mode 100644 index 0000000000..e3c7879125 --- /dev/null +++ b/test/sdk/core/model_service/test_parameter_rules.py @@ -0,0 +1,241 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Parameter policy preserves values and enforces explicit permissions.""" + +from copy import deepcopy + +import pytest +from nexent.core.model_service.profiles.parameter_rules import ( + apply_parameter_values, + compile_parameter_rules, +) +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError + + +def rule(key="temperature", **overrides): + result = { + "key": key, + "schema": {}, + "visible": True, + "editable": True, + "required": False, + "allowed_scopes": ["outlet", "conversation"], + "ui_group": "common", + "owner": "provider", + "source": {"rule_id": "test-v1"}, + } + result.update(overrides) + return result + + +def apply(rules, submitted=None, saved=None, scope="outlet"): + return apply_parameter_values( + compile_parameter_rules(rules), + submitted or {}, + saved_values=saved or {}, + scope=scope, + ) + + +@pytest.mark.parametrize("value", [None, False, 0, "", [], {}]) +def test_explicit_values_and_defaults_preserve_presence(value): + assert apply([rule(default_value="default")], {"temperature": value}) == { + "temperature": value + } + assert apply([rule(default_value=value)]) == {"temperature": value} + assert apply([rule()], saved={"temperature": value}) == {"temperature": value} + assert apply([rule()]) == {} + + +@pytest.mark.parametrize("value", [None, False, 0, "", {"private": "fixed-secret"}]) +def test_fixed_value_is_written_and_equal_submission_is_allowed(value): + fixed = rule(fixed_value=value, visible=False, editable=False, allowed_scopes=[]) + assert apply([fixed]) == {"temperature": value} + assert apply([fixed], {"temperature": value}, scope="conversation") == { + "temperature": value + } + with pytest.raises(ParameterValidationError) as captured: + apply([fixed], {"temperature": "different-secret"}) + assert captured.value.category == "FIXED_PARAMETER" + assert "secret" not in str(captured.value) + + +def test_fixed_saved_conflict_is_not_silently_overwritten(): + with pytest.raises(ParameterValidationError, match="FIXED_PARAMETER"): + apply([rule(fixed_value=False)], saved={"temperature": 0}) + + +@pytest.mark.parametrize( + "overrides", + [ + {"editable": False}, + {"visible": False, "editable": False}, + {"allowed_scopes": ["outlet"]}, + ], +) +def test_conversation_override_requires_explicit_edit_permission(overrides): + declaration = rule(default_value=0, **overrides) + with pytest.raises(ParameterValidationError, match="PARAMETER_NOT_EDITABLE"): + apply([declaration], {"temperature": 1}, scope="conversation") + assert apply([declaration], scope="conversation") == {"temperature": 0} + + +def test_visibility_and_required_flags_are_independent(): + assert apply([rule(visible=False)], {"temperature": 1}) == {"temperature": 1} + with pytest.raises(ParameterValidationError, match="PARAMETER_VALUE_REQUIRED"): + apply([rule(required=True)]) + + +@pytest.mark.parametrize( + "condition,enabled,expected", + [ + ({"key": "enabled", "op": "eq", "value": True}, True, True), + ({"key": "enabled", "op": "eq", "value": 1}, True, False), + ({"key": "enabled", "op": "in", "value": [False, None]}, False, True), + ({"key": "enabled", "op": "exists", "value": True}, None, True), + ({"all": [{"key": "enabled", "op": "eq", "value": True}]}, False, False), + ( + { + "any": [ + {"key": "enabled", "op": "eq", "value": True}, + {"key": "enabled", "op": "eq", "value": False}, + ] + }, + False, + True, + ), + ], +) +def test_conditions_filter_derived_values_and_reject_explicit_inactive_values( + condition, enabled, expected +): + declarations = [ + rule("enabled"), + rule("budget", condition=condition, default_value=0, required=True), + ] + result = apply(declarations, {"enabled": enabled}) + assert ("budget" in result) is expected + if not expected: + with pytest.raises(ParameterValidationError, match="PARAMETER_DEPENDENCY"): + apply(declarations, {"enabled": enabled, "budget": 0}) + + +def test_missing_is_distinct_from_null_for_conditions(): + declarations = [ + rule("enabled"), + rule( + "budget", + condition={"key": "enabled", "op": "eq", "value": None}, + default_value=0, + ), + ] + assert apply(declarations) == {} + assert apply(declarations, {"enabled": None}) == {"enabled": None, "budget": 0} + + +def test_dependency_order_does_not_depend_on_declaration_order(): + declarations = [ + rule( + "last", + default_value=2, + condition={"key": "middle", "op": "exists", "value": True}, + ), + rule( + "middle", + default_value=1, + condition={"key": "enabled", "op": "eq", "value": True}, + ), + rule("enabled", default_value=False), + ] + assert apply(declarations) == {"enabled": False} + assert apply(declarations, {"enabled": True}) == { + "enabled": True, + "middle": 1, + "last": 2, + } + + +@pytest.mark.parametrize( + "condition", + [ + {}, + {"all": []}, + {"any": []}, + {"all": [{}], "any": [{}]}, + {"key": "unknown", "op": "eq", "value": 1}, + {"key": "temperature", "op": "eval", "value": "secret"}, + {"key": "temperature", "op": "exists", "value": 1}, + {"key": "temperature", "op": "in", "value": []}, + {"key": [], "op": "eq", "value": 1}, + ], +) +def test_invalid_conditions_fail_before_use(condition): + with pytest.raises(ParameterValidationError, match="PARAMETER_CONDITION_INVALID"): + compile_parameter_rules([rule(condition=condition)]) + + +def test_cycles_and_duplicate_keys_are_rejected(): + with pytest.raises(ParameterValidationError, match="PARAMETER_CONDITION_CYCLE"): + compile_parameter_rules( + [rule(condition={"key": "temperature", "op": "exists", "value": True})] + ) + with pytest.raises(ParameterValidationError, match="PARAMETER_RULE_INVALID"): + compile_parameter_rules([rule(), rule()]) + + +def test_nested_condition_must_be_an_object(): + with pytest.raises(ParameterValidationError, match="PARAMETER_CONDITION_INVALID"): + compile_parameter_rules([rule(condition={"all": [True]})]) + + +def test_empty_rule_set_is_valid_and_still_rejects_unknown_parameters(): + assert apply([]) == {} + with pytest.raises(ParameterValidationError, match="PARAMETER_UNKNOWN"): + apply([], {"temperature": 1}) + + +@pytest.mark.parametrize( + "overrides", + [ + {"allowed_scopes": ["outlet", "outlet"]}, + {"editable": "true"}, + {"unknown": 1}, + {"default_value": True, "schema": {"type": "number"}}, + {"fixed_value": None, "schema": {"type": "integer"}}, + {"default_value": 0, "fixed_value": False}, + ], +) +def test_invalid_declarations_do_not_compile(overrides): + with pytest.raises(ParameterValidationError): + compile_parameter_rules([rule(**overrides)]) + + +def test_snapshots_and_inputs_are_independent(): + declarations = [rule(default_value={"nested": [1]})] + original = deepcopy(declarations) + compiled = compile_parameter_rules(declarations) + declarations[0]["default_value"]["nested"].append(2) + view = compiled.declarations + view[0]["default_value"]["nested"].append(3) + saved = {"temperature": {"nested": [4]}} + result = apply_parameter_values(compiled, {}, saved_values=saved, scope="outlet") + result["temperature"]["nested"].append(5) + assert saved == {"temperature": {"nested": [4]}} + assert compiled.declarations == original + + +@pytest.mark.parametrize( + "submitted,saved", [({"unknown": 1}, {}), ({}, {"unknown": 1})] +) +def test_unknown_values_fail_explicitly(submitted, saved): + with pytest.raises(ParameterValidationError, match="PARAMETER_UNKNOWN"): + apply([rule()], submitted, saved) + + +def test_values_are_checked_including_saved_values_and_null(): + declarations = [rule(schema={"type": "number", "minimum": 0})] + for submitted, saved in [({"temperature": None}, {}), ({}, {"temperature": -1})]: + with pytest.raises(ParameterValidationError, match="PARAMETER_VALUE_INVALID"): + apply(declarations, submitted, saved) + with pytest.raises(ParameterValidationError, match="PARAMETER_SCOPE_INVALID"): + apply(declarations, scope="unknown") diff --git a/test/sdk/core/model_service/test_parameter_schema.py b/test/sdk/core/model_service/test_parameter_schema.py new file mode 100644 index 0000000000..12e18de636 --- /dev/null +++ b/test/sdk/core/model_service/test_parameter_schema.py @@ -0,0 +1,167 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Finite parameter schema compilation and value validation boundaries.""" + +import pytest +from nexent.core.model_service.profiles.parameter_schema import ( + ParameterValidationError, + compile_parameter_schema, + json_values_equal, + validate_parameter_value, +) + + +@pytest.mark.parametrize( + "schema,value", + [ + ({"type": "boolean"}, False), + ({"type": "integer", "minimum": 0, "maximum": 10}, 0), + ({"type": ["number", "null"]}, None), + ({"type": "number"}, 0.5), + ({"type": "string", "minLength": 0, "maxLength": 2}, ""), + ({"enum": [False, 0, None, "default"]}, False), + ({"enum": [False, 0, None]}, 0), + ({"const": None}, None), + ({"type": "array", "items": {"type": "integer"}}, [1, 2]), + ( + { + "type": "object", + "properties": {"enabled": {"type": "boolean"}}, + "required": ["enabled"], + "additionalProperties": False, + }, + {"enabled": False}, + ), + ( + {"type": "object", "additionalProperties": {"type": "number"}}, + {"custom": 0.1}, + ), + ], +) +def test_declared_schema_accepts_valid_boundary_values(schema, value): + validate_parameter_value(compile_parameter_schema(schema), value) + + +@pytest.mark.parametrize( + "schema,value,keyword,path", + [ + ({"type": "integer"}, True, "type", ""), + ({"type": "number"}, False, "type", ""), + ({"type": "integer"}, 1.0, "type", ""), + ({"type": "boolean"}, 0, "type", ""), + ({"type": "number", "minimum": 0}, -1, "minimum", ""), + ({"type": "number", "maximum": 1}, 2, "maximum", ""), + ({"type": "string", "minLength": 2}, "", "minLength", ""), + ({"type": "string", "maxLength": 1}, "ab", "maxLength", ""), + ({"enum": [0]}, False, "enum", ""), + ({"const": False}, 0, "const", ""), + ({"type": "array", "items": {"type": "boolean"}}, [False, 0], "type", "/1"), + ({"type": "object", "required": ["enabled"]}, {}, "required", "/enabled"), + ( + {"properties": {"a/b~c": {"type": "boolean"}}}, + {"a/b~c": 0}, + "type", + "/a~1b~0c", + ), + ( + {"additionalProperties": False}, + {"extra": "sensitive-marker"}, + "additionalProperties", + "/extra", + ), + ({"additionalProperties": {"type": "boolean"}}, {"extra": 0}, "type", "/extra"), + ], +) +def test_invalid_values_are_not_coerced_and_diagnostics_are_value_free( + schema, value, keyword, path +): + with pytest.raises(ParameterValidationError) as error: + validate_parameter_value(compile_parameter_schema(schema), value) + assert error.value.keyword == keyword + assert error.value.path == path + assert "sensitive-marker" not in str(error.value) + + +@pytest.mark.parametrize( + "schema", + [ + {"type": "unknown"}, + {"type": []}, + {"type": ["boolean", "boolean"]}, + {"type": [False]}, + {"enum": []}, + {"enum": [1, 1.0]}, + {"enum": "values"}, + {"minimum": False}, + {"minimum": 10, "maximum": 1}, + {"maximum": "high"}, + {"minLength": -1}, + {"maxLength": True}, + {"minLength": 5, "maxLength": 2}, + {"required": "field"}, + {"required": [False]}, + {"required": ["field", "field"]}, + {"properties": []}, + {"properties": {"field": True}}, + {"items": []}, + {"additionalProperties": None}, + ], +) +def test_invalid_schema_shapes_and_constraints_fail_before_use(schema): + with pytest.raises(ParameterValidationError, match="PARAMETER_SCHEMA_INVALID"): + compile_parameter_schema(schema) + + +@pytest.mark.parametrize( + "schema", + [ + {"$ref": "https://example.com/schema"}, + {"format": "email"}, + {"oneOf": []}, + {"properties": {"field": {"pattern": "script"}}}, + {"items": {"default": 0}}, + ], +) +def test_unregistered_keywords_and_remote_references_are_rejected(schema): + with pytest.raises(ParameterValidationError) as error: + compile_parameter_schema(schema) + assert error.value.category == "PARAMETER_SCHEMA_KEYWORD_UNSUPPORTED" + + +@pytest.mark.parametrize( + "value", [float("nan"), float("inf"), {1: "invalid-key"}, object(), "\ud800"] +) +def test_non_json_values_and_invalid_utf8_are_rejected(value): + with pytest.raises(ParameterValidationError, match="PARAMETER_JSON_INVALID"): + validate_parameter_value(compile_parameter_schema({}), value) + + +def test_cyclic_values_are_explicitly_rejected_without_recursion_failure(): + value = [] + value.append(value) + with pytest.raises(ParameterValidationError, match="PARAMETER_JSON_INVALID"): + validate_parameter_value(compile_parameter_schema({}), value) + + +def test_compiled_schema_is_an_independent_immutable_snapshot(): + source = { + "type": "object", + "properties": {"field": {"type": "boolean"}}, + "additionalProperties": False, + } + schema = compile_parameter_schema(source) + source["properties"]["field"]["type"] = "number" + copy = schema.schema + copy["properties"]["field"]["type"] = "number" + validate_parameter_value(schema, {"field": False}) + with pytest.raises(ParameterValidationError): + validate_parameter_value(schema, {"field": 0}) + + +def test_json_equality_preserves_boolean_numeric_and_structural_semantics(): + assert json_values_equal(1, 1.0) + assert not json_values_equal(False, 0) + assert not json_values_equal(True, 1) + assert json_values_equal({"x": [None, False, 1]}, {"x": (None, False, 1.0)}) + assert not json_values_equal({"x": False}, {"x": 0}) + assert not json_values_equal([1], [1, 2]) diff --git a/test/sdk/core/model_service/test_parameter_transform_values.py b/test/sdk/core/model_service/test_parameter_transform_values.py new file mode 100644 index 0000000000..11a0c0278f --- /dev/null +++ b/test/sdk/core/model_service/test_parameter_transform_values.py @@ -0,0 +1,37 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Pure transform domains and copy semantics, independent of persistence or HTTP.""" + +import pytest +from nexent.core.model_service.parameter_transforms import transform_parameter +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError + + +def binding(ref="thinking_toggle_v1", version=1): + return {"path": "/thinking/type", "transform_ref": ref, "transform_version": version} + + +@pytest.mark.parametrize("value,expected", [(True, "enabled"), (False, "disabled")]) +def test_boolean_thinking_transform_has_no_truthiness_coercion(value, expected): + assert transform_parameter(binding(), value) == expected + + +@pytest.mark.parametrize("value", [None, 0, 1, "false", {}, []]) +def test_non_boolean_thinking_value_is_rejected(value): + with pytest.raises(ParameterValidationError) as error: + transform_parameter(binding(), value) + assert error.value.category == "PARAMETER_TRANSFORM_VALUE_INVALID" + + +def test_unknown_transform_version_is_not_approximated(): + with pytest.raises(ParameterValidationError) as error: + transform_parameter(binding(version=2), True) + assert error.value.category == "PARAMETER_TRANSFORM_UNREGISTERED" + + +def test_identity_result_is_detached(): + source = {"items": [1, False]} + result = transform_parameter(binding(ref="identity_v1"), source) + result["items"].append("changed") + assert source == {"items": [1, False]} + diff --git a/test/sdk/core/model_service/test_profile_capabilities.py b/test/sdk/core/model_service/test_profile_capabilities.py new file mode 100644 index 0000000000..782ce22c2d --- /dev/null +++ b/test/sdk/core/model_service/test_profile_capabilities.py @@ -0,0 +1,122 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Complete capability contracts preserve unknown state and reject undeclared fields.""" + +import pytest +from nexent.core.model_service.profiles.contracts import ProfileCapabilities +from pydantic import ValidationError + + +def test_missing_capabilities_are_unknown_not_supported_or_false(): + capabilities = ProfileCapabilities() + assert capabilities.context.combined_window is None + assert capabilities.reasoning.status == "unknown" + assert capabilities.reasoning.controls is None + assert capabilities.tools.status == "unknown" + assert capabilities.modalities.input is None + 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( + "group,field", + [ + ("modalities", "input"), + ("modalities", "output"), + ("response", "supported_finish_reasons"), + ], +) +def test_empty_list_is_explicit_and_not_missing(group, field): + value = ProfileCapabilities.model_validate({group: {field: []}}) + assert getattr(getattr(value, group), field) == [] + with pytest.raises(ValidationError): + ProfileCapabilities.model_validate({group: {field: ["same", "same"]}}) + + +@pytest.mark.parametrize( + "group", + [ + "context", + "reasoning", + "tools", + "sampling", + "structured_output", + "modalities", + "response", + "usage", + ], +) +def test_all_groups_reject_unknown_nested_fields(group): + with pytest.raises(ValidationError): + ProfileCapabilities.model_validate({group: {"unknown": 1}}) + + +def test_sampling_support_is_not_a_numeric_default(): + for invalid in [False, 0.7, {"status": True}]: + with pytest.raises(ValidationError): + ProfileCapabilities.model_validate({"sampling": {"temperature": invalid}}) + assert ( + ProfileCapabilities.model_validate( + {"sampling": {"temperature": {"status": "supported"}}} + ).sampling.temperature.status + == "supported" + ) + + +def test_tool_constraint_reference_is_explicit_and_typed(): + value = ProfileCapabilities.model_validate( + { + "tools": { + "status": "supported", + "schema_constraints": {"ref": "tools_schema_v1", "version": 1}, + "result_format": "tool_messages_v1", + } + } + ) + assert value.tools.schema_constraints.version == 1 + with pytest.raises(ValidationError): + ProfileCapabilities.model_validate( + {"tools": {"schema_constraints": {"ref": "unknown", "version": True}}} + ) + + +def test_usage_semantics_never_coerce_boolean_to_status(): + with pytest.raises(ValidationError): + ProfileCapabilities.model_validate({"usage": {"input_includes_cached": True}}) + value = ProfileCapabilities.model_validate( + { + "usage": { + "semantics_id": "usage_v1", + "input_includes_cached": "supported", + "output_includes_reasoning": "unsupported", + } + } + ) + assert value.usage.semantics_id == "usage_v1" + assert value.usage.output_includes_reasoning == "unsupported" diff --git a/test/sdk/core/model_service/test_profile_contracts.py b/test/sdk/core/model_service/test_profile_contracts.py new file mode 100644 index 0000000000..8bfbe9d439 --- /dev/null +++ b/test/sdk/core/model_service/test_profile_contracts.py @@ -0,0 +1,222 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""AC-01/02: strict profile primitives, without provider or database I/O.""" + +import pytest +from nexent.core.model_service.profiles.contracts import ( + AtomicProfileReference, + BudgetTokensControl, + ContextCapabilities, + EffortControl, + ReasoningCapabilities, + ToggleControl, +) +from pydantic import ValidationError + + +def test_context_unknown_and_independent_limits(): + assert ContextCapabilities().model_dump() == { + "combined_window": None, + "input_limit": None, + "output_limit": None, + } + profile = ContextCapabilities( + combined_window=100, input_limit=100, output_limit=100 + ) + assert ContextCapabilities.model_validate_json(profile.model_dump_json()) == profile + assert ContextCapabilities(output_limit=1).combined_window is None + + +@pytest.mark.parametrize("value", [True, False, "200000", 1.0, 0, -1]) +@pytest.mark.parametrize("field", ["combined_window", "input_limit", "output_limit"]) +def test_context_rejects_invalid_token_limits(field, value): + with pytest.raises(ValidationError): + ContextCapabilities(**{field: value}) + + +@pytest.mark.parametrize("field", ["input_limit", "output_limit"]) +def test_context_rejects_service_limit_above_window(field): + with pytest.raises(ValidationError): + ContextCapabilities(combined_window=100, **{field: 101}) + + +def test_reasoning_controls_preserve_declared_values_and_round_trip(): + profile = ReasoningCapabilities( + status="supported", + controls=[ + ToggleControl(values=[False, True]), + EffortControl(values=["low", "xhigh", "provider-specific"]), + BudgetTokensControl(min=0, max=100, special_values=[-1]), + ], + response_paths=["choices[].delta.reasoning_content"], + ) + assert ( + ReasoningCapabilities.model_validate_json(profile.model_dump_json()) == profile + ) + assert profile.controls[0].values[0] is False + assert profile.controls[2].special_values == [-1] + assert "current_value" not in profile.model_dump_json() + + +def test_reasoning_unknown_and_explicit_no_controls_remain_distinct(): + assert ReasoningCapabilities().controls is None + assert ReasoningCapabilities(status="unsupported", controls=[]).controls == [] + assert ( + ReasoningCapabilities(controls=[ToggleControl(values=[True])]).status + == "unknown" + ) + + +@pytest.mark.parametrize( + "control", + [ + {"mode": "toggle", "values": [1]}, + {"mode": "toggle", "values": [False, False]}, + {"mode": "toggle", "values": []}, + {"mode": "toggle", "values": [True], "parameter_key": "reasoning"}, + {"mode": "effort", "values": ["high", "high"]}, + {"mode": "effort", "values": [" "]}, + {"mode": "effort", "values": [1]}, + {"mode": "effort", "values": []}, + {"mode": "budget_tokens", "min": 10, "max": 9}, + {"mode": "budget_tokens", "min": -1, "max": 9}, + {"mode": "budget_tokens", "min": 0, "max": True}, + {"mode": "budget_tokens", "min": 0, "max": 9, "special_values": [False]}, + {"mode": "budget_tokens", "min": 0, "max": 9, "special_values": [-1, -1]}, + {"mode": "unknown"}, + {"mode": "toggle", "values": [True], "unexpected": 1}, + ], +) +def test_reasoning_rejects_invalid_controls(control): + with pytest.raises(ValidationError): + ReasoningCapabilities(controls=[control]) + + +@pytest.mark.parametrize( + "payload", + [ + {"status": "unsupported", "controls": [{"mode": "toggle", "values": [True]}]}, + {"status": "unsupported", "response_paths": ["reasoning_content"]}, + { + "status": "supported", + "controls": [ + {"mode": "effort", "values": ["low"]}, + {"mode": "effort", "values": ["high"]}, + ], + }, + {"response_paths": ["reasoning_content", "reasoning_content"]}, + {"response_paths": [""]}, + {"status": True}, + {"status": "unknown", "extra": 1}, + ], +) +def test_reasoning_rejects_conflicting_or_unknown_fields(payload): + with pytest.raises(ValidationError): + ReasoningCapabilities(**payload) + + +def test_reasoning_size_limit_counts_utf8_bytes(): + ReasoningCapabilities(controls=[EffortControl(values=["a" * 65000])]) + with pytest.raises(ValidationError, match="64 KiB"): + ReasoningCapabilities(controls=[EffortControl(values=["思" * 22000])]) + + +def reference_payload(): + return { + "key": "qwen/Qwen3.8", + "catalog_revision": "catalog-v1", + "file_path": "models/qwen/model.toml", + "content_sha256": "a" * 64, + } + + +def test_atomic_reference_round_trip_preserves_exact_identity(): + profile = AtomicProfileReference(**reference_payload()) + assert ( + AtomicProfileReference.model_validate_json(profile.model_dump_json()) == profile + ) + assert profile.key == "qwen/Qwen3.8" + + +@pytest.mark.parametrize( + "path", + [ + "/tmp/model.toml", + "../x.toml", + "models/../x.toml", + "models/./x.toml", + "models//x.toml", + "C:/x.toml", + "models\\x.toml", + "", + "models/", + "models/\x00file.toml", + ], +) +def test_atomic_reference_rejects_unsafe_paths(path): + with pytest.raises(ValidationError): + AtomicProfileReference(**{**reference_payload(), "file_path": path}) + + +@pytest.mark.parametrize( + "updates", + [ + {"content_sha256": "a" * 63}, + {"content_sha256": "A" * 64}, + {"content_sha256": "g" * 64}, + {"key": " "}, + {"catalog_revision": ""}, + {"unknown": "field"}, + ], +) +def test_atomic_reference_rejects_invalid_provenance(updates): + with pytest.raises(ValidationError): + AtomicProfileReference(**{**reference_payload(), **updates}) + + +def test_context_rejects_unknown_fields(): + with pytest.raises(ValidationError): + ContextCapabilities(max_tokens=100) + + +def test_contract_fields_cannot_be_reassigned_without_validation(): + profile = ContextCapabilities(combined_window=100) + with pytest.raises(ValidationError): + profile.combined_window = 0 + assert profile.combined_window == 100 + + +@pytest.mark.parametrize( + "bounds", [{}, {"max": 81920}, {"min": 1}, {"min": 0}, {"max": 0}] +) +def test_reasoning_budget_preserves_missing_bounds_without_defaults(bounds): + control = BudgetTokensControl(**bounds) + assert control.min == bounds.get("min") + assert control.max == bounds.get("max") + assert BudgetTokensControl.model_validate_json(control.model_dump_json()) == control + profile = ReasoningCapabilities(status="supported", controls=[control]) + restored = ReasoningCapabilities.model_validate_json(profile.model_dump_json()) + assert restored.controls[0].min == bounds.get("min") + assert restored.controls[0].max == bounds.get("max") + + +@pytest.mark.parametrize("field", ["min", "max"]) +@pytest.mark.parametrize("invalid", [True, "100", 1.5, -1]) +def test_reasoning_budget_rejects_invalid_partial_bounds(field, invalid): + with pytest.raises(ValidationError): + BudgetTokensControl(**{field: invalid}) + + +def test_reasoning_budget_keeps_declared_sentinel_and_known_zero_separate(): + control = BudgetTokensControl(max=0, special_values=[-1]) + assert control.min is None + assert control.max == 0 + assert control.special_values == [-1] + + +def test_effort_null_sentinel_is_an_explicit_value_not_absent_control(): + control = EffortControl(values=["none", None, "default"]) + restored = EffortControl.model_validate_json(control.model_dump_json()) + assert restored.values == ["none", None, "default"] + with pytest.raises(ValueError): + EffortControl(values=[None, None]) diff --git a/test/sdk/core/model_service/test_request_parameters.py b/test/sdk/core/model_service/test_request_parameters.py new file mode 100644 index 0000000000..bc2e5ca41e --- /dev/null +++ b/test/sdk/core/model_service/test_request_parameters.py @@ -0,0 +1,179 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Extra body parameters cannot overwrite owned request fields or caller state.""" + +import json +from copy import deepcopy +from types import MappingProxyType + +import pytest +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError +from nexent.core.model_service.profiles.request_parameters import ( + apply_extra_api_parameters, +) + + +def extra(path, value): + return {"path": path, "value": value} + + +def test_extra_values_are_body_only_and_inputs_are_independent(): + body = { + "model": "exact", + "messages": [], + "options": {"old": 1}, + "array": [{"old": 2}], + } + original = deepcopy(body) + extras = [ + extra("/options/new", False), + extra("/array/0/custom", None), + extra("/nested/value", [0]), + ] + result = apply_extra_api_parameters(body, extras) + assert result == { + "model": "exact", + "messages": [], + "options": {"old": 1, "new": False}, + "array": [{"old": 2, "custom": None}], + "nested": {"value": [0]}, + } + result["nested"]["value"].append(1) + assert body == original + assert extras[-1]["value"] == [0] + + +def test_escaped_pointer_keys_and_numeric_object_keys_keep_literal_meanings(): + result = apply_extra_api_parameters( + {}, [extra("/a~1b/~0key", 0), extra("/object/00", "")] + ) + assert result == {"a/b": {"~key": 0}, "object": {"00": ""}} + assert apply_extra_api_parameters( + MappingProxyType({"nested": MappingProxyType({})}), [] + ) == {"nested": {}} + + +@pytest.mark.parametrize( + "path", + [ + "/model", + "/messages/0/content", + "/tools", + "/tool_choice", + "/stream", + "/stream_options/include_usage", + "/custom", + "/custom/owned/value", + ], +) +def test_owned_paths_and_ancestors_cannot_be_overridden(path): + with pytest.raises(ParameterValidationError, match="RESERVED_API_PATH"): + apply_extra_api_parameters( + {}, [extra(path, "secret")], parameter_paths=["/custom/owned"] + ) + + +def test_provider_protected_paths_are_checked_after_pointer_decoding(): + with pytest.raises(ParameterValidationError, match="RESERVED_API_PATH"): + apply_extra_api_parameters( + {}, [extra("/a~1b", 0)], protected_paths=["/a~1b/owned"] + ) + + +@pytest.mark.parametrize( + "paths", + [ + ["/custom", "/custom"], + ["/custom", "/custom/nested"], + ["/custom/nested", "/custom"], + ], +) +def test_duplicate_and_parent_child_parameters_conflict(paths): + with pytest.raises(ParameterValidationError, match="API_PATH_CONFLICT"): + apply_extra_api_parameters({}, [extra(path, 0) for path in paths]) + + +@pytest.mark.parametrize("path", ["", "model", "#/custom", "/~", "/bad~2key"]) +def test_malformed_pointers_are_rejected(path): + with pytest.raises(ParameterValidationError, match="API_PATH_INVALID"): + apply_extra_api_parameters({}, [extra(path, 0)]) + + +@pytest.mark.parametrize( + "index", ["00", "01", "-", "-1", "1", "999999999999999999999999"] +) +def test_array_aliases_append_and_out_of_range_indices_are_rejected(index): + body = {"array": [0]} + with pytest.raises(ParameterValidationError, match="API_PATH_INVALID"): + apply_extra_api_parameters(body, [extra("/array/" + index, 1)]) + assert body == {"array": [0]} + + +@pytest.mark.parametrize("path", ["/scalar/nested", "/scalar/nested/value"]) +def test_scalar_parent_conflict_has_no_partial_mutation(path): + body = {"scalar": 0} + with pytest.raises(ParameterValidationError, match="API_PATH_CONFLICT"): + apply_extra_api_parameters( + body, [extra("/valid", 1), extra(path, 2)] + ) + assert body == {"scalar": 0} + + +@pytest.mark.parametrize( + "entry", + [{"path": "/custom"}, {"path": "/custom", "value": 1, "header": "secret"}, None], +) +def test_unknown_entry_fields_are_not_a_header_or_auth_escape(entry): + with pytest.raises(ParameterValidationError, match="EXTRA_API_PARAMETER_INVALID"): + apply_extra_api_parameters({}, [entry]) + + +def test_leaf_limit_boundary_and_nonfinite_values(): + assert ( + len( + apply_extra_api_parameters({}, [extra("/custom", list(range(128)))])[ + "custom" + ] + ) + == 128 + ) + with pytest.raises(ParameterValidationError, match="EXTRA_API_PARAMETERS_LIMIT"): + apply_extra_api_parameters({}, [extra("/custom", list(range(129)))]) + for value in [float("inf"), float("nan"), object()]: + with pytest.raises(ParameterValidationError, match="PARAMETER_JSON_INVALID"): + apply_extra_api_parameters({}, [extra("/custom", value)]) + + +def test_byte_limit_is_utf8_serialized_extras(): + overhead = len( + json.dumps( + [extra("/custom", "")], ensure_ascii=False, separators=(",", ":") + ).encode() + ) + limit_value = "a" * (65536 - overhead) + assert ( + apply_extra_api_parameters({}, [extra("/custom", limit_value)])["custom"] + == limit_value + ) + with pytest.raises(ParameterValidationError, match="EXTRA_API_PARAMETERS_LIMIT"): + apply_extra_api_parameters({}, [extra("/custom", limit_value + "中")]) + + +def test_depth_limit_is_checked_without_modifying_source(): + value = 0 + for _ in range(16): + value = {"nested": value} + assert apply_extra_api_parameters({}, [extra("/custom", value)])["custom"] == value + with pytest.raises(ParameterValidationError, match="EXTRA_API_PARAMETERS_LIMIT"): + apply_extra_api_parameters({}, [extra("/custom", {"nested": value})]) + + +def test_invalid_json_containers_and_encoding_are_sanitized(): + for body, extras in [ + ([], []), + ({}, {}), + ({}, [extra("/custom", "\ud800")]), + ({}, [extra("/custom", {0: 1})]), + ]: + with pytest.raises(ParameterValidationError, match="PARAMETER_JSON_INVALID"): + apply_extra_api_parameters(body, extras) diff --git a/test/sdk/core/model_service/test_source_reader.py b/test/sdk/core/model_service/test_source_reader.py new file mode 100644 index 0000000000..aa7ea2dc34 --- /dev/null +++ b/test/sdk/core/model_service/test_source_reader.py @@ -0,0 +1,161 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Unit proof for controlled local sources; normalization is a separate contract.""" + +import hashlib +from pathlib import Path + +import pytest +from nexent.core.model_service.profiles._models_dev.reader import ( + SourceReadError, + read_sources, +) + +CATALOG_ROOT = ( + Path(__file__).resolve().parents[4] / "sdk/nexent/core/model_service/profiles" +) + + +def write_inventory( + root, + *, + data=b'name = "Test"\n[limit]\ncontext = 100\n', + path="models/test/model.toml", + kind="model", + digest=None, + version="1", + extra="", + copies=1, +): + digest = digest or hashlib.sha256(data).hexdigest() + (root / "manifest.toml").write_text( + f'schema_version = {version}\nsource_schema_ref = "models_dev_authored_v1"\n' + 'upstream_url = "https://github.com/anomalyco/models.dev"\n' + f'upstream_revision = "{"a" * 40}"\n{extra}\n' + + ( + f'[[sources]]\nkind = "{kind}"\npath = "{path}"\ncontent_sha256 = "{digest}"\n' + * copies + ) + ) + if not path.startswith("/") and ".." not in path.split("/"): + target = root / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + + +def test_loads_pinned_inventory_with_exact_nested_model_identity(): + documents = read_sources(CATALOG_ROOT) + assert len(documents) == 67 + assert any( + document.key == "alibaba-cn/MiniMax/MiniMax-M2.7" for document in documents + ) + assert {document.key for document in documents if document.kind == "provider"} == { + "alibaba-cn", + "volcengine", + "volcengine-coding-plan", + "siliconflow-cn", + "deepseek", + "tokenpony", + } + + +def test_authored_documents_are_deeply_immutable(tmp_path): + write_inventory( + tmp_path, + data=b'name = "Test"\nvalues = ["low", "high"]\n[limit]\ncontext = 100\n', + ) + documents = read_sources(tmp_path) + assert isinstance(documents, tuple) + doc = documents[0] + assert doc.data["values"] == ("low", "high") + with pytest.raises(TypeError): + doc.data["limit"]["context"] = 0 + with pytest.raises(TypeError): + doc.data["name"] = "Changed" + + +@pytest.mark.parametrize( + "changes,category", + [ + ({"path": "../outside.toml"}, "SOURCE_REFERENCE_INVALID"), + ({"path": "/tmp/outside.toml"}, "SOURCE_REFERENCE_INVALID"), + ({"path": "models/./test.toml"}, "SOURCE_REFERENCE_INVALID"), + ( + {"path": "providers/test/provider.toml", "kind": "model"}, + "SOURCE_KIND_PATH_MISMATCH", + ), + ({"digest": "a" * 64}, "SOURCE_DIGEST_MISMATCH"), + ({"digest": "wrong"}, "SOURCE_REFERENCE_INVALID"), + ({"data": b"name = ["}, "SOURCE_TOML_INVALID"), + ({"data": b"\xff"}, "SOURCE_TOML_INVALID"), + ({"copies": 2}, "SOURCE_DUPLICATE"), + ({"copies": 0}, "SOURCE_MANIFEST_INVALID"), + ({"version": "2"}, "SOURCE_MANIFEST_INVALID"), + ({"version": "1.0"}, "SOURCE_MANIFEST_INVALID"), + ({"path": "models/test/model.json"}, "SOURCE_KIND_PATH_MISMATCH"), + ({"path": "models/test/.toml"}, "SOURCE_KIND_PATH_MISMATCH"), + ({"version": "true"}, "SOURCE_MANIFEST_INVALID"), + ({"extra": "unexpected = 1"}, "SOURCE_MANIFEST_INVALID"), + ], +) +def test_rejects_invalid_inventory_before_returning_partial_data( + tmp_path, changes, category +): + write_inventory(tmp_path, **changes) + with pytest.raises(SourceReadError) as caught: + read_sources(tmp_path) + assert caught.value.category == category + assert "name =" not in str(caught.value) + assert caught.value.rule_id == "source_inventory_v1" + + +def test_missing_manifest_and_source_are_explicit_errors(tmp_path): + with pytest.raises(SourceReadError, match="SOURCE_FILE_UNAVAILABLE"): + read_sources(tmp_path) + write_inventory(tmp_path) + (tmp_path / "models/test/model.toml").unlink() + with pytest.raises(SourceReadError, match="SOURCE_FILE_UNAVAILABLE"): + read_sources(tmp_path) + + +def test_symlink_to_outside_root_is_rejected(tmp_path): + root = tmp_path / "catalog" + root.mkdir() + write_inventory(root) + target = root / "models/test/model.toml" + target.unlink() + outside = tmp_path / "outside.toml" + outside.write_text('name = "Outside"') + target.symlink_to(outside) + with pytest.raises(SourceReadError, match="SOURCE_PATH_OUTSIDE_ROOT"): + read_sources(root) + + +def test_invalid_upstream_commit_is_rejected(tmp_path): + write_inventory(tmp_path) + manifest = tmp_path / "manifest.toml" + manifest.write_text(manifest.read_text().replace("a" * 40, "not-a-commit")) + with pytest.raises(SourceReadError, match="SOURCE_MANIFEST_INVALID"): + read_sources(tmp_path) + + +@pytest.mark.parametrize("declarations", [ + 'origin = "nexent"\n', + 'origin = "nexent"\nsource_revision = "local-v1"\n', + 'origin = "nexent"\nsource_url = "https://docs.example.com/"\n', + 'origin = "nexent"\nsource_revision = "local-v1"\nsource_url = "file:///tmp/doc"\n', + 'source_revision = "pretend-upstream"\nsource_url = "https://docs.example.com/"\n', +]) +def test_local_sources_reject_incomplete_or_misrepresented_origin(tmp_path, declarations): + write_inventory(tmp_path) + manifest = tmp_path / "manifest.toml" + manifest.write_text(manifest.read_text() + declarations) + with pytest.raises(SourceReadError, match="SOURCE_MANIFEST_INVALID"): + read_sources(tmp_path) + + +def test_local_source_keeps_origin_separate_from_upstream_commit(): + local = next(document for document in read_sources(CATALOG_ROOT) if document.key == "tokenpony") + assert local.origin == "nexent" + assert local.source_revision == "nexent_tokenpony_provider_v1" + assert local.source_url == "https://docs.tokenpony.cn/" diff --git a/test/sdk/core/model_service/test_source_validation.py b/test/sdk/core/model_service/test_source_validation.py new file mode 100644 index 0000000000..1129f598b9 --- /dev/null +++ b/test/sdk/core/model_service/test_source_validation.py @@ -0,0 +1,186 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Isolated source-schema rules before component normalization is connected.""" + +import pytest +from nexent.core.model_service.profiles._models_dev.reader import ( + SourceDocument, + SourceReadError, + _freeze, +) +from nexent.core.model_service.profiles._models_dev.validation import ( + validate_source_document, +) + + +def document(kind, data): + return SourceDocument( + kind, "lab/model", "models/lab/model.toml", "a" * 64, _freeze(data) + ) + + +@pytest.fixture +def hosted(): + return { + "name": "Model", + "description": "Description", + "attachment": False, + "reasoning": True, + "tool_call": True, + "release_date": "2024-02-29", + "last_updated": "2026-10", + "modalities": {"input": ["text"], "output": ["text"]}, + "open_weights": True, + "limit": {"context": 200000, "output": 20000}, + "reasoning_options": [ + {"type": "toggle"}, + {"type": "budget_tokens", "max": 10000}, + ], + } + + +def test_complete_hosted_definition_accepts_missing_budget_min_without_mutation(hosted): + source = document("provider_model", hosted) + validate_source_document(source, effective=True) + assert "min" not in source.data["reasoning_options"][1] + assert hosted["reasoning_options"] == [ + {"type": "toggle"}, + {"type": "budget_tokens", "max": 10000}, + ] + + +def test_raw_override_allows_partial_nested_objects_but_complete_result_does_not(): + source = document( + "provider_model", {"base_model": "lab/base", "limit": {"output": 10}} + ) + validate_source_document(source) + with pytest.raises(SourceReadError) as error: + validate_source_document( + document("provider_model", {"limit": {"output": 10}}), effective=True + ) + assert error.value.category == "SOURCE_FIELD_REQUIRED" + assert error.value.source_path == "limit.context" + + +@pytest.mark.parametrize( + "field,value,path", + [ + ("reasoning", "true", "reasoning"), + ( + "modalities", + {"input": ["unknown"], "output": ["text"]}, + "modalities.input[0]", + ), + ("limit", {"context": True, "output": 10}, "limit.context"), + ("limit", {"context": 1.5, "output": 10}, "limit.context"), + ("limit", {"context": -1, "output": 10}, "limit.context"), + ( + "reasoning_options", + [{"type": "effort", "values": ["super"]}], + "reasoning_options[0].values[0]", + ), + ( + "reasoning_options", + [{"type": "budget_tokens", "min": -2}], + "reasoning_options[0].min", + ), + ( + "reasoning_options", + [{"type": "budget_tokens", "min": 20, "max": 10}], + "reasoning_options[0].min", + ), + ("reasoning_options", [{"type": "invented"}], "reasoning_options[0].type"), + ("reasoning_options", False, "reasoning_options"), + ("interleaved", False, "interleaved"), + ("limit", [], "limit"), + ], +) +def test_invalid_values_report_exact_path_without_echoing_input( + hosted, field, value, path +): + hosted[field] = value + with pytest.raises(SourceReadError) as error: + validate_source_document(document("provider_model", hosted), effective=True) + assert error.value.source_path == path + assert error.value.rule_id == "models_dev_authored_v1" + assert str(value) not in str(error.value) + + +@pytest.mark.parametrize( + "update,path", + [ + ({"reasoning_effort": "high"}, "reasoning_effort"), + ({"limit": {"context": 100, "output": 10, "extra": 1}}, "limit.extra"), + ( + {"reasoning_options": [{"type": "toggle", "default": True}]}, + "reasoning_options[0].default", + ), + ], +) +def test_unregistered_fields_are_not_aliases_or_silently_ignored(hosted, update, path): + hosted.update(update) + with pytest.raises(SourceReadError) as error: + validate_source_document(document("provider_model", hosted)) + assert error.value.category == "SOURCE_FIELD_UNHANDLED" + assert error.value.source_path == path + + +@pytest.mark.parametrize("reasoning,options", [(True, None), (False, [])]) +def test_reasoning_options_follow_explicit_upstream_cross_field_contract( + hosted, reasoning, options +): + hosted["reasoning"] = reasoning + if options is None: + del hosted["reasoning_options"] + else: + hosted["reasoning_options"] = options + with pytest.raises(SourceReadError) as error: + validate_source_document(document("provider_model", hosted)) + assert error.value.source_path == "reasoning_options" + assert error.value.category == "SOURCE_CONSTRAINT_INVALID" + + +def test_atomic_model_has_optional_capabilities_and_separate_schema(): + raw = {"name": "Model", "description": "Description"} + validate_source_document(document("model", raw)) + raw["reasoning_options"] = [] + with pytest.raises(SourceReadError, match="SOURCE_FIELD_UNHANDLED"): + validate_source_document(document("model", raw)) + + +@pytest.mark.parametrize("kind,raw", [ + ("provider", {"name": "Provider", "env": [], "npm": "unknown", "api": "https://example.com"}), + ("model", {"name": "Model", "family": "future-family", "release_date": "free-form", "links": [{"url": "free text", "new_field": True}], "benchmarks": [{"new_score": 5}]}), +]) +def test_unused_reference_metadata_has_no_submission_schema(kind, raw): + validate_source_document(document(kind, raw)) + + +def test_atomic_reference_metadata_is_checked_without_becoming_runtime_capability(): + raw = { + "name": "Model", + "description": "Description", + "family": "qwen", + "links": [{"url": "https://example.com/docs", "type": "docs"}], + "weights": [{"url": "https://example.com/weights"}], + "benchmarks": [{"name": "Test", "score": 99.5, "date": "2024-02"}], + } + validate_source_document(document("model", raw)) + raw["benchmarks"][0]["score"] = "pass" + validate_source_document(document("model", raw)) + assert "reasoning" not in raw + + +def test_explicit_interleaved_and_effort_values_are_schema_checked(hosted): + hosted["interleaved"] = {"field": "reasoning_content"} + hosted["reasoning_options"] = [ + {"type": "effort", "values": ["none", "default", "null"]} + ] + hosted["cost"] = {"input": 0.1, "output": 0.2} + validate_source_document(document("provider_model", hosted)) + hosted["interleaved"] = True + validate_source_document(document("provider_model", hosted)) + hosted["reasoning_options"] = [{"type": "effort"}] + with pytest.raises(SourceReadError) as error: + validate_source_document(document("provider_model", hosted)) + assert error.value.source_path == "reasoning_options[0].values"