From 1b8642ec7abb9dc6cdf8ccee4a0bfc4e08f40fac Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 13 Oct 2026 10:02:53 +0800 Subject: [PATCH 1/2] chore: bootstrap model profile contracts draft From 69bd1aa548c8df19ff2b54410dc12af3446598c1 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 13 Oct 2026 10:26:05 +0800 Subject: [PATCH 2/2] feat: add strict model profile contract primitives --- sdk/nexent/core/model_service/__init__.py | 1 + .../core/model_service/profiles/__init__.py | 1 + .../core/model_service/profiles/contracts.py | 131 +++++++++++++ .../MODEL-PROFILE-CONTRACTS-20261013.yaml | 28 +++ .../SDK-MODEL-PROFILE-CONTRACTS/cases.md | 5 + .../SDK-MODEL-PROFILE-CONTRACTS/feature.yaml | 20 ++ .../model_service/test_profile_contracts.py | 184 ++++++++++++++++++ 7 files changed, 370 insertions(+) create mode 100644 sdk/nexent/core/model_service/__init__.py create mode 100644 sdk/nexent/core/model_service/profiles/__init__.py create mode 100644 sdk/nexent/core/model_service/profiles/contracts.py create mode 100644 test-e2e/changes/requirements/MODEL-PROFILE-CONTRACTS-20261013.yaml create mode 100644 test-e2e/features/SDK-MODEL-PROFILE-CONTRACTS/cases.md create mode 100644 test-e2e/features/SDK-MODEL-PROFILE-CONTRACTS/feature.yaml create mode 100644 test/sdk/core/model_service/test_profile_contracts.py diff --git a/sdk/nexent/core/model_service/__init__.py b/sdk/nexent/core/model_service/__init__.py new file mode 100644 index 0000000000..8bbf7e4f38 --- /dev/null +++ b/sdk/nexent/core/model_service/__init__.py @@ -0,0 +1 @@ +"""Model service contracts and provider adapters.""" 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..b32bda4f07 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/__init__.py @@ -0,0 +1 @@ +"""Local model profile definitions, loading and resolution.""" 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..1e80413cca --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/contracts.py @@ -0,0 +1,131 @@ +"""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] = 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): + """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)] + max: Annotated[int, Field(ge=0)] + special_values: list[int] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_range(self) -> Self: + if 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 diff --git a/test-e2e/changes/requirements/MODEL-PROFILE-CONTRACTS-20261013.yaml b/test-e2e/changes/requirements/MODEL-PROFILE-CONTRACTS-20261013.yaml new file mode 100644 index 0000000000..e260eb7bf3 --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-PROFILE-CONTRACTS-20261013.yaml @@ -0,0 +1,28 @@ +schema_version: '1.0' +change: + change_id: MODEL-PROFILE-CONTRACTS-20261013 + change_type: requirement + requirement_id: MODEL-PROFILE-CONTRACTS-20261013 + title: Add strict model profile contract primitives + description: First local implementation slice of the approved model-access / NativeAgent SPEC; no existing runtime is connected or modified. + affected_features: + existing: [] + added: [SDK-MODEL-PROFILE-CONTRACTS] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Unknown capacity remains null and known limits reject booleans, numeric strings, zero, negatives and service limits above the combined window. + - Toggle, effort and budget controls preserve explicitly declared values and reject duplicate modes/keys, invalid types, ranges, unknown fields and unsupported-with-controls. + - Source references retain exact identity/digests, reject unsafe paths and round-trip through strict JSON validation without loading external sources. + reason: These are isolated domain validation contracts; existing model UI/runtime Cases do not exercise them, and a duplicate formal D1 would add no component evidence. Catalog parsing/composition and live runtime verification belong to subsequent slices. + test_selectors: + - test/sdk/core/model_service/test_profile_contracts.py + no_case_change_reason: No API, provider, database, tool execution or user journey is connected in this slice. Preserve all historical Cases; formal integration coverage will be added with the owning implementation. + notes: Implements only profile primitives under SPEC AC-01/02. Overall feature acceptance remains pending; neither formal runtime acceptance nor model compatibility is claimed. diff --git a/test-e2e/features/SDK-MODEL-PROFILE-CONTRACTS/cases.md b/test-e2e/features/SDK-MODEL-PROFILE-CONTRACTS/cases.md new file mode 100644 index 0000000000..903c7086ec --- /dev/null +++ b/test-e2e/features/SDK-MODEL-PROFILE-CONTRACTS/cases.md @@ -0,0 +1,5 @@ +# SDK-MODEL-PROFILE-CONTRACTS 关联用例 + +此页由结构化用例生成,不手工编辑。 + +当前测试基线未保留关联用例。 diff --git a/test-e2e/features/SDK-MODEL-PROFILE-CONTRACTS/feature.yaml b/test-e2e/features/SDK-MODEL-PROFILE-CONTRACTS/feature.yaml new file mode 100644 index 0000000000..5b6a238185 --- /dev/null +++ b/test-e2e/features/SDK-MODEL-PROFILE-CONTRACTS/feature.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' +module: + id: SDK-MODEL-SERVICE + name: SDK model service +feature: + feature_id: SDK-MODEL-PROFILE-CONTRACTS + name: Model profile contract primitives + status: planned + description: Strict provider-neutral capacity, reasoning and source-reference contracts used by the upcoming catalog and outlet layers. + business_rules: + - rule_id: SDK-MODEL-PROFILE-CONTRACTS-RULE-01 + description: Unknown capacity and capability stay distinct from explicit unsupported; known token limits are positive strict integers. + - rule_id: SDK-MODEL-PROFILE-CONTRACTS-RULE-02 + description: Reasoning controls use declared toggle, effort or token-budget domains; duplicates, invalid types, inconsistent support and unknown fields fail validation. + - rule_id: SDK-MODEL-PROFILE-CONTRACTS-RULE-03 + description: Atomic references carry exact source identity and digests with relative source paths; they are traceability, not runtime recomposition instructions. + required_test_stages: [] + source: + requirement_ids: [MODEL-PROFILE-CONTRACTS-20261013] + notes: SPEC AC-01 and AC-02 subset only; the current slice has no catalog, persistence, provider or Agent runtime integration. Traditional UT proves these domain contracts. Later slices supply independent component/API/runtime evidence. 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..0bd9ee1c7b --- /dev/null +++ b/test/sdk/core/model_service/test_profile_contracts.py @@ -0,0 +1,184 @@ +"""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