From 287216230efa5c3c1e00c5d679612ccec18e40b5 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 13 Oct 2026 10:59:39 +0800 Subject: [PATCH 1/5] chore: bootstrap source profile normalization draft From 2afa8f93c8cb20605c39d64a26db8f589dc9eeb5 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 13 Oct 2026 11:19:02 +0800 Subject: [PATCH 2/5] feat(model-service): preserve source inheritance and field ownership --- .../profiles/_models_dev/inheritance.py | 136 +++++++++++ .../core/model_service/profiles/contracts.py | 8 +- .../MODEL-PROFILE-NORMALIZATION-20261013.yaml | 44 ++++ .../SDK-MODEL-PROFILE-CONTRACTS/feature.yaml | 29 ++- .../SDK-MODEL-PROFILE-NORMALIZATION/cases.md | 5 + .../feature.yaml | 28 +++ .../model_service/test_profile_contracts.py | 28 +++ .../model_service/test_source_inheritance.py | 231 ++++++++++++++++++ 8 files changed, 496 insertions(+), 13 deletions(-) create mode 100644 sdk/nexent/core/model_service/profiles/_models_dev/inheritance.py create mode 100644 test-e2e/changes/requirements/MODEL-PROFILE-NORMALIZATION-20261013.yaml create mode 100644 test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/cases.md create mode 100644 test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/feature.yaml create mode 100644 test/sdk/core/model_service/test_source_inheritance.py diff --git a/sdk/nexent/core/model_service/profiles/_models_dev/inheritance.py b/sdk/nexent/core/model_service/profiles/_models_dev/inheritance.py new file mode 100644 index 0000000000..624ebcd3ca --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/_models_dev/inheritance.py @@ -0,0 +1,136 @@ +"""Pinned authored base_model inheritance with per-field source ownership.""" + +from dataclasses import dataclass +from typing import Mapping + +from nexent.core.model_service.profiles._models_dev.reader import SourceDocument, SourceReadError, _freeze + + +_EXCLUDED_BASE_FIELDS = frozenset({"id", "license", "links", "weights", "benchmarks"}) + + +@dataclass(frozen=True) +class FieldOrigin: + """Source ownership before the versioned semantic mapping is applied.""" + + source_kind: str + file_path: str + source_path: str + content_sha256: str + + +@dataclass(frozen=True) +class EffectiveSourceModel: + """Read-only source result, distinct from a resolved Nexent outlet.""" + + document: SourceDocument + data: Mapping[str, object] + field_origins: Mapping[str, FieldOrigin] + dependencies: tuple[SourceDocument, ...] + omitted_paths: tuple[str, ...] + + +def _error(category: str, document: SourceDocument, source_path: str) -> SourceReadError: + error = SourceReadError(category, document.file_path, document.kind) + error.source_path = source_path + error.rule_id = "source_inheritance_v1" + return error + + +def _thaw(value: object) -> object: + if isinstance(value, Mapping): + return {key: _thaw(item) for key, item in value.items()} + if isinstance(value, (tuple, list)): + return [_thaw(item) for item in value] + return value + + +def _collect_origins(value: object, path: str, document: SourceDocument, origins: dict) -> None: + if isinstance(value, Mapping) and value: + for key, item in value.items(): + _collect_origins(item, f"{path}.{key}" if path else key, document, origins) + elif isinstance(value, (tuple, list)) and value: + for index, item in enumerate(value): + _collect_origins(item, f"{path}[{index}]", document, origins) + elif path: + origins[path] = FieldOrigin(document.kind, document.file_path, path, document.content_sha256) + + +def _clear_origins(origins: dict, path: str) -> None: + for key in tuple(origins): + if key == path or key.startswith(path + ".") or key.startswith(path + "["): + del origins[key] + + +def _overlay(target: dict, updates: Mapping, path: str, document: SourceDocument, origins: dict) -> None: + for key, value in updates.items(): + field_path = f"{path}.{key}" if path else key + if isinstance(target.get(key), dict) and isinstance(value, Mapping): + if value: + origins.pop(field_path, None) + _overlay(target[key], value, field_path, document, origins) + else: + _clear_origins(origins, field_path) + target[key] = _thaw(value) + _collect_origins(value, field_path, document, origins) + + +def _omit(target: dict, path: str, origins: dict) -> None: + parts = path.split(".") + current = target + parents = [] + for part in parts[:-1]: + child = current.get(part) + if not isinstance(child, dict): + return + parents.append((current, part)) + current = child + if parts[-1] not in current: + return + del current[parts[-1]] + _clear_origins(origins, path) + for parent, key in reversed(parents): + if parent[key]: + break + del parent[key] + + +def resolve_source_model(document: SourceDocument, sources: tuple[SourceDocument, ...]) -> EffectiveSourceModel: + """Use only explicit base references; never infer a model from its name.""" + if document.kind not in ("model", "provider_model"): + raise _error("SOURCE_MODEL_KIND_INVALID", document, "") + models = {} + for source in sources: + if source.kind == "model": + if source.key in models: + raise _error("SOURCE_DUPLICATE", source, "") + models[source.key] = source + + origins = {} + dependencies = (document,) + data = {} + if "base_model" in document.data: + base_key = document.data["base_model"] + if document.kind != "provider_model" or not isinstance(base_key, str) or not base_key.strip(): + raise _error("SOURCE_BASE_REFERENCE_INVALID", document, "base_model") + base = models.get(base_key) + if base is None: + raise _error("SOURCE_BASE_MODEL_MISSING", document, "base_model") + if "base_model" in base.data: + raise _error("SOURCE_BASE_REFERENCE_INVALID", base, "base_model") + data = _thaw({key: value for key, value in base.data.items() if key not in _EXCLUDED_BASE_FIELDS}) + _collect_origins(data, "", base, origins) + dependencies = (base, document) + elif "base_model_omit" in document.data: + raise _error("SOURCE_OMIT_WITHOUT_BASE", document, "base_model_omit") + + omissions = document.data.get("base_model_omit", ()) + if not isinstance(omissions, (tuple, list)) or any( + not isinstance(path, str) or any(not part for part in path.split(".")) for path in omissions + ): + raise _error("SOURCE_OMIT_INVALID", document, "base_model_omit") + updates = {key: value for key, value in document.data.items() if key not in ("base_model", "base_model_omit")} + _overlay(data, updates, "", document, origins) + for path in omissions: + _omit(data, path, origins) + return EffectiveSourceModel(document, _freeze(data), _freeze(origins), dependencies, tuple(omissions)) diff --git a/sdk/nexent/core/model_service/profiles/contracts.py b/sdk/nexent/core/model_service/profiles/contracts.py index 1e80413cca..3cb7f58d8f 100644 --- a/sdk/nexent/core/model_service/profiles/contracts.py +++ b/sdk/nexent/core/model_service/profiles/contracts.py @@ -62,17 +62,17 @@ def validate_values(self) -> Self: class BudgetTokensControl(ProfileContract): - """Token bounds and explicitly declared sentinel values.""" + """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)] - max: Annotated[int, Field(ge=0)] + 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 > self.max: + 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") diff --git a/test-e2e/changes/requirements/MODEL-PROFILE-NORMALIZATION-20261013.yaml b/test-e2e/changes/requirements/MODEL-PROFILE-NORMALIZATION-20261013.yaml new file mode 100644 index 0000000000..3d832324fc --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-PROFILE-NORMALIZATION-20261013.yaml @@ -0,0 +1,44 @@ +schema_version: '1.0' +change: + change_id: MODEL-PROFILE-NORMALIZATION-20261013 + change_type: requirement + requirement_id: MODEL-PROFILE-NORMALIZATION-20261013 + title: Normalize pinned model profile sources without invented capability defaults + description: Incremental source inheritance and normalization layer; first contract + step preserves absent budget bounds. + affected_features: + existing: + - SDK-MODEL-PROFILE-CONTRACTS + added: + - SDK-MODEL-PROFILE-NORMALIZATION + modified: + - SDK-MODEL-PROFILE-CONTRACTS + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: strengthen + behaviors: + - Absent reasoning budget minimum or maximum remains unknown; explicit zero and + declared special values remain distinct. + - Only known bounds participate in range validation; strict type errors and JSON + round trips preserve the declared domain. + - Inheritance keeps scalar/array replacements and recursive object merging distinct, + applies omit semantics, excludes identity/license/weights/links/benchmarks from + provider inheritance, and does not mutate source documents. + - Unknown/missing base references, duplicate source identities and invalid omission + declarations fail explicitly; surviving fields retain exact source file and + path. + reason: Existing profile contract UT owns isolated budget validation; no component + or runtime normalization is connected in this first step. + test_selectors: + - test/sdk/core/model_service/test_profile_contracts.py + - test/sdk/core/model_service/test_source_inheritance.py + no_case_change_reason: Current steps cover isolated budget and inheritance rules. + Component Cases will be registered before wiring reader, source validator and + normalization; existing formal Cases stay unchanged. + notes: AC-01/02 subset remains pending as a whole. This change does not infer provider + API behavior or modify a saved outlet. diff --git a/test-e2e/features/SDK-MODEL-PROFILE-CONTRACTS/feature.yaml b/test-e2e/features/SDK-MODEL-PROFILE-CONTRACTS/feature.yaml index 5b6a238185..e2a8f0ee02 100644 --- a/test-e2e/features/SDK-MODEL-PROFILE-CONTRACTS/feature.yaml +++ b/test-e2e/features/SDK-MODEL-PROFILE-CONTRACTS/feature.yaml @@ -6,15 +6,26 @@ 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. + 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. + - 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. + - rule_id: SDK-MODEL-PROFILE-CONTRACTS-RULE-04 + description: Undeclared reasoning budget bounds remain unknown; explicit zero, + numeric bounds and special values are preserved separately. 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. + requirement_ids: + - MODEL-PROFILE-CONTRACTS-20261013 + - MODEL-PROFILE-NORMALIZATION-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-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/cases.md b/test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/cases.md new file mode 100644 index 0000000000..724bda5f73 --- /dev/null +++ b/test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/cases.md @@ -0,0 +1,5 @@ +# SDK-MODEL-PROFILE-NORMALIZATION 关联用例 + +此页由结构化用例生成,不手工编辑。 + +当前测试基线未保留关联用例。 diff --git a/test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/feature.yaml b/test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/feature.yaml new file mode 100644 index 0000000000..f7e54ce0cd --- /dev/null +++ b/test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/feature.yaml @@ -0,0 +1,28 @@ +schema_version: '1.0' +module: + id: SDK-MODEL-SERVICE + name: SDK model service +feature: + feature_id: SDK-MODEL-PROFILE-NORMALIZATION + name: Model profile source inheritance and normalization + status: planned + description: Versioned conversion from pinned source definitions to provider-neutral + capabilities with explicit field origins. + business_rules: + - rule_id: SDK-MODEL-PROFILE-NORMALIZATION-RULE-01 + description: Pinned authored inheritance overlays objects recursively, replaces + arrays/scalars, honors omissions, excludes reference-only metadata, and leaves + original documents unchanged. + - rule_id: SDK-MODEL-PROFILE-NORMALIZATION-RULE-02 + description: Missing capabilities and bounds stay unknown; boolean abilities and + configurable values remain separate. + - rule_id: SDK-MODEL-PROFILE-NORMALIZATION-RULE-03 + description: Every source field needs a versioned disposition; unknown fields + and invalid types fail the full load with source diagnostics. + required_test_stages: [] + source: + requirement_ids: + - MODEL-PROFILE-NORMALIZATION-20261013 + notes: Incremental implementation. Budget/inheritance UT does not complete the + normalization component; pipeline component proof will be registered before + connection. No provider/API/database/Agent acceptance claimed. diff --git a/test/sdk/core/model_service/test_profile_contracts.py b/test/sdk/core/model_service/test_profile_contracts.py index 0bd9ee1c7b..2aca9bae8a 100644 --- a/test/sdk/core/model_service/test_profile_contracts.py +++ b/test/sdk/core/model_service/test_profile_contracts.py @@ -182,3 +182,31 @@ def test_contract_fields_cannot_be_reassigned_without_validation(): 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] diff --git a/test/sdk/core/model_service/test_source_inheritance.py b/test/sdk/core/model_service/test_source_inheritance.py new file mode 100644 index 0000000000..fdb928d69b --- /dev/null +++ b/test/sdk/core/model_service/test_source_inheritance.py @@ -0,0 +1,231 @@ +"""Pinned inheritance rules and source ownership, independent of semantic mapping.""" + +import pytest +from nexent.core.model_service.profiles._models_dev.inheritance import ( + resolve_source_model, +) +from nexent.core.model_service.profiles._models_dev.reader import ( + SourceDocument, + SourceReadError, + _freeze, +) + + +def source(kind, key, data): + path = f"models/{key}.toml" if kind == "model" else f"providers/{key}.toml" + return SourceDocument(kind, key, path, "a" * 64, _freeze(data)) + + +def test_inheritance_merges_objects_replaces_arrays_and_preserves_field_ownership(): + base = source( + "model", + "lab/model", + { + "name": "Base", + "reasoning": True, + "tool_call": True, + "limit": {"context": 100, "input": 90, "output": 20}, + "modalities": {"input": ["text", "image"], "output": ["text"]}, + }, + ) + outlet = source( + "provider_model", + "vendor/model", + { + "base_model": base.key, + "name": "Hosted", + "reasoning": False, + "limit": {"output": 10}, + "modalities": {"input": ["text"]}, + }, + ) + effective = resolve_source_model(outlet, (base, outlet)) + assert effective.data["name"] == "Hosted" + assert effective.data["reasoning"] is False + assert dict(effective.data["limit"]) == {"context": 100, "input": 90, "output": 10} + assert effective.data["modalities"]["input"] == ("text",) + assert "base_model" not in effective.data + assert effective.dependencies == (base, outlet) + assert effective.document.key == "vendor/model" + assert effective.field_origins["limit.context"].file_path == base.file_path + assert effective.field_origins["limit.output"].file_path == outlet.file_path + assert ( + effective.field_origins["modalities.input[0]"].source_path + == "modalities.input[0]" + ) + assert "modalities.input[1]" not in effective.field_origins + assert base.data["limit"]["output"] == 20 + assert base.data["modalities"]["input"] == ("text", "image") + with pytest.raises(TypeError): + effective.data["limit"]["output"] = 1 + + +def test_provider_inheritance_excludes_reference_only_fields(): + base = source( + "model", + "lab/model", + { + "name": "Base", + "id": "base-id", + "license": "MIT", + "links": ["docs"], + "weights": ["weights"], + "benchmarks": [{"name": "score", "score": 1}], + }, + ) + outlet = source("provider_model", "vendor/model", {"base_model": base.key}) + result = resolve_source_model(outlet, (base, outlet)) + assert dict(result.data) == {"name": "Base"} + assert set(result.field_origins) == {"name"} + assert base.data["license"] == "MIT" + atomic = resolve_source_model(base, (base, outlet)) + assert atomic.data["license"] == "MIT" + + +def test_omit_deletes_nested_paths_and_prunes_empty_parents(): + base = source( + "model", + "lab/model", + { + "name": "Base", + "nested": {"only": {"field": 1}}, + "limit": {"context": 100, "output": 10}, + }, + ) + outlet = source( + "provider_model", + "vendor/model", + { + "base_model": base.key, + "base_model_omit": ["nested.only.field", "limit.output", "missing.path"], + }, + ) + result = resolve_source_model(outlet, (base, outlet)) + assert "nested" not in result.data + assert dict(result.data["limit"]) == {"context": 100} + assert "limit.output" not in result.field_origins + assert result.omitted_paths == ("nested.only.field", "limit.output", "missing.path") + assert "base_model_omit" not in result.data + assert base.data["nested"]["only"]["field"] == 1 + + +def test_omit_does_not_traverse_arrays_or_scalars(): + base = source("model", "lab/model", {"array": ["item"], "scalar": 1}) + outlet = source( + "provider_model", + "vendor/model", + { + "base_model": base.key, + "base_model_omit": ["array.0", "scalar.child"], + }, + ) + assert dict(resolve_source_model(outlet, (base, outlet)).data) == { + "array": ("item",), + "scalar": 1, + } + + +def test_empty_array_replacement_preserves_explicit_empty_declaration(): + base = source("model", "lab/model", {"values": ["low", "high"]}) + outlet = source( + "provider_model", "vendor/model", {"base_model": base.key, "values": []} + ) + result = resolve_source_model(outlet, (base, outlet)) + assert result.data["values"] == () + assert set(result.field_origins) == {"values"} + assert result.field_origins["values"].file_path == outlet.file_path + + +def test_without_base_never_infers_identity_or_capabilities_from_name(): + base = source("model", "lab/model", {"name": "Same", "reasoning": True}) + outlet = source("provider_model", "vendor/model", {"name": "Same"}) + result = resolve_source_model(outlet, (base, outlet)) + assert dict(result.data) == {"name": "Same"} + assert result.dependencies == (outlet,) + + +@pytest.mark.parametrize( + "data,category", + [ + ({"base_model": "missing"}, "SOURCE_BASE_MODEL_MISSING"), + ({"base_model": False}, "SOURCE_BASE_REFERENCE_INVALID"), + ({"base_model": " "}, "SOURCE_BASE_REFERENCE_INVALID"), + ({"base_model": "lab/model", "base_model_omit": "name"}, "SOURCE_OMIT_INVALID"), + ( + {"base_model": "lab/model", "base_model_omit": [False]}, + "SOURCE_OMIT_INVALID", + ), + ( + {"base_model": "lab/model", "base_model_omit": ["limit..output"]}, + "SOURCE_OMIT_INVALID", + ), + ({"base_model_omit": ["name"]}, "SOURCE_OMIT_WITHOUT_BASE"), + ], +) +def test_invalid_references_and_omissions_fail_explicitly(data, category): + base = source("model", "lab/model", {"name": "Base"}) + outlet = source("provider_model", "vendor/model", data) + with pytest.raises(SourceReadError) as caught: + resolve_source_model(outlet, (base, outlet)) + assert caught.value.category == category + assert caught.value.file_path == outlet.file_path + assert caught.value.rule_id == "source_inheritance_v1" + + +def test_duplicate_model_identity_is_rejected(): + base = source("model", "lab/model", {"name": "Base"}) + outlet = source("provider_model", "vendor/model", {"base_model": base.key}) + with pytest.raises(SourceReadError, match="SOURCE_DUPLICATE"): + resolve_source_model(outlet, (base, base, outlet)) + + +def test_atomic_model_cannot_declare_nested_or_cyclic_base_in_this_source_schema(): + base = source("model", "lab/model", {"base_model": "lab/model"}) + outlet = source("provider_model", "vendor/model", {"base_model": base.key}) + for document in (base, outlet): + with pytest.raises(SourceReadError, match="SOURCE_BASE_REFERENCE_INVALID"): + resolve_source_model(document, (base, outlet)) + + +def test_provider_metadata_is_not_a_model_document(): + provider = source("provider", "vendor", {"name": "Vendor"}) + with pytest.raises(SourceReadError, match="SOURCE_MODEL_KIND_INVALID"): + resolve_source_model(provider, (provider,)) + + +def test_nonempty_overlay_replaces_empty_container_origin_with_leaf_ownership(): + base = source("model", "lab/model", {"limit": {}}) + outlet = source( + "provider_model", + "vendor/model", + {"base_model": base.key, "limit": {"output": 10}}, + ) + result = resolve_source_model(outlet, (base, outlet)) + assert dict(result.data["limit"]) == {"output": 10} + assert set(result.field_origins) == {"limit.output"} + assert result.field_origins["limit.output"].file_path == outlet.file_path + assert dict(base.data["limit"]) == {} + + +def test_empty_object_overlay_preserves_inherited_leaf_values_and_origins(): + base = source("model", "lab/model", {"limit": {"context": 100}}) + outlet = source( + "provider_model", "vendor/model", {"base_model": base.key, "limit": {}} + ) + result = resolve_source_model(outlet, (base, outlet)) + assert dict(result.data["limit"]) == {"context": 100} + assert set(result.field_origins) == {"limit.context"} + assert result.field_origins["limit.context"].file_path == base.file_path + + +def test_omit_prunes_only_empty_parents_and_keeps_sibling_origins(): + base = source("model", "lab/model", {"nested": {"remove": {"leaf": 1}, "keep": 2}}) + outlet = source( + "provider_model", + "vendor/model", + {"base_model": base.key, "base_model_omit": ["nested.remove.leaf"]}, + ) + result = resolve_source_model(outlet, (base, outlet)) + assert dict(result.data["nested"]) == {"keep": 2} + assert set(result.field_origins) == {"nested.keep"} + assert result.field_origins["nested.keep"].file_path == base.file_path From b16d27e04a4957770d8c504afcb5b9aff2700c57 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 13 Oct 2026 11:25:12 +0800 Subject: [PATCH 3/5] feat(model-service): validate pinned authored profile fields --- .../profiles/_models_dev/validation.py | 480 ++++++++++++++++++ .../MODEL-PROFILE-NORMALIZATION-20261013.yaml | 6 + .../model_service/test_source_validation.py | 248 +++++++++ 3 files changed, 734 insertions(+) create mode 100644 sdk/nexent/core/model_service/profiles/_models_dev/validation.py create mode 100644 test/sdk/core/model_service/test_source_validation.py 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..c2feead617 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/_models_dev/validation.py @@ -0,0 +1,480 @@ +"""Strict selected authored schemas pinned to models.dev e69e184. + +No provider request binding or capability defaults are inferred here. +""" + +import math +import re +from collections.abc import Mapping +from dataclasses import dataclass +from urllib.parse import urlsplit + +from nexent.core.model_service.profiles._models_dev.reader import SourceDocument, SourceReadError + + +@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 _date(value: str) -> bool: + if not re.fullmatch(r"\d{4}-\d{2}(-\d{2})?", value): + return False + parts = [int(part) for part in value.split("-")] + year, month = parts[:2] + if not 1 <= month <= 12: + return False + if len(parts) == 2: + return True + leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) + days = (31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + return 1 <= parts[2] <= days[month - 1] + + +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 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 == "date": + valid = isinstance(value, str) and _date(value) + elif schema == "url": + if isinstance(value, str): + try: + parsed = urlsplit(value) + valid = bool( + parsed.scheme and (parsed.netloc if parsed.scheme in ("http", "https", "ftp") else parsed.path) + ) + valid = valid and not any(character.isspace() for character in value) + except ValueError: + valid = False + 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",)) +_LINK = ObjectSchema( + { + "url": "url", + "label": "nonempty", + "type": frozenset({"announcement", "blog", "docs", "license", "model_card", "paper", "weights", "other"}), + }, + ("url",), +) +_WEIGHT = ObjectSchema({"url": "url", "label": "nonempty", "format": "nonempty", "quantization": "nonempty"}, ("url",)) +_BENCHMARK = ObjectSchema( + { + "name": "nonempty", + "score": "score", + "metric": "nonempty", + "harness": "nonempty", + "variant": "nonempty", + "dataset": "nonempty", + "version": "nonempty", + "source": "url", + "date": "date", + }, + ("name", "score"), +) +_COST_FIELDS = dict.fromkeys( + ("input", "output", "reasoning", "cache_read", "cache_write", "input_audio", "output_audio"), "nonnegative" +) +_COST_TIER = ObjectSchema( + {**_COST_FIELDS, "tier": ObjectSchema({"type": frozenset({"context"}), "size": "tokens"}, ("size",))}, + ("input", "output", "tier"), +) +_COST = ObjectSchema({**_COST_FIELDS, "tiers": ArraySchema(_COST_TIER)}, ("input", "output")) +_FAMILIES = frozenset( + ( + "trinity", + "trinity-mini", + "gpt", + "gpt-codex", + "gpt-codex-spark", + "gpt-codex-mini", + "gpt-pro", + "gpt-mini", + "gpt-nano", + "gpt-sol", + "gpt-terra", + "gpt-luna", + "gpt-astra", + "gpt-oss", + "gpt-image", + "o", + "o-mini", + "o-pro", + "claude", + "claude-haiku", + "claude-sonnet", + "claude-opus", + "claude-fable", + "claude-mythos", + "gemini", + "gemini-pro", + "gemini-flash", + "gemini-flash-lite", + "gemini-embedding", + "glm", + "glmv", + "glm-air", + "glm-flash", + "glm-free", + "glm-z", + "llama", + "muse", + "muse-free", + "qwen", + "qwen3.5", + "qwen3.6", + "qwen3.7-plus", + "qwen3.7-max", + "qwen3.8-max", + "qwen-free", + "ornith", + "deepseek", + "deepseek-thinking", + "deepseek-flash", + "deepseek-flash-free", + "deepseek-flash-think", + "phi", + "kimi", + "kimi-k2", + "kimi-k3", + "kimi-free", + "kimi-thinking", + "motif", + "laguna", + "laguna-s", + "mistral", + "mistral-large", + "mistral-medium", + "mistral-small", + "mistral-nemo", + "ministral", + "codestral", + "devstral", + "leanstral", + "pixtral", + "mixtral", + "grok", + "grok-build", + "grok-vision", + "grok-beta", + "gemma", + "nova", + "nova-pro", + "nova-lite", + "nova-micro", + "command", + "command-r", + "command-a", + "command-light", + "north", + "north-free", + "jamba", + "nemotron", + "nemotron-free", + "titan", + "titan-embed", + "minimax", + "minimax-m2.5", + "minimax-m2.7", + "minimax-m3", + "minimax-m3-free", + "minimax-free", + "hunyuan", + "Hy", + "yi", + "granite", + "reka", + "sonar", + "sonar-pro", + "sonar-reasoning", + "sonar-deep-research", + "solar", + "solar-mini", + "solar-pro", + "step", + "text-embedding", + "cohere-embed", + "voyage", + "mistral-embed", + "bge", + "plamo", + "codestral-embed", + "dall-e", + "flux", + "imagen", + "recraft", + "stable-diffusion", + "ideogram", + "dreamshaper", + "sora", + "veo", + "runway", + "dream-machine", + "whisper", + "elevenlabs", + "lyria", + "melotts", + "ernie", + "hermes", + "zephyr", + "openchat", + "starling", + "qvq", + "sherlock", + "pony", + "mercury", + "cogito", + "mimo", + "mimo-pro", + "mimo-omni", + "mimo-v2-pro", + "mimo-v2-omni", + "mimo-v2.5-pro", + "mimo-v2.5", + "mimo-v2.5-free", + "mimo-pro-free", + "mimo-omni-free", + "mimo-flash-free", + "mm-poly", + "longcat", + "magistral", + "magistral-small", + "magistral-medium", + "phoenix", + "lucid", + "agi", + "intellect", + "aura", + "jais", + "sarvam", + "falcon", + "baichuan", + "skywork", + "bart", + "distilbert", + "resnet", + "m2m", + "indictrans", + "llava", + "seed", + "ray", + "tstars", + "rnj", + "hy3", + "hy3-free", + "ling", + "ling-flash-free", + "ring", + "ring-1t-free", + "kat-coder", + "sqlcoder", + "discolm", + "osmosis", + "parakeet", + "nemoretriever", + "nano-banana", + "una-cybertron", + "morph", + "voxtral", + "venice", + "auto", + "model-router", + "fugu", + "sakana-namazu", + "v0", + "tako", + "mai", + "rednote", + "smart-turn", + "qwerky", + "big-pickle", + "chutesai", + "opengvlab", + "tngtech", + "topazlabs", + "unsloth", + "nousresearch", + "alpha", + "oswe", + "neural-chat", + "pangu", + "liquid", + "sourceful", + "allenai", + "palmyra", + "allam", + "canopylabs", + "groq", + "elephant", + ) +) +_COMMON = { + "name": "nonempty", + "description": "nonempty", + "family": _FAMILIES, + **dict.fromkeys( + ("attachment", "reasoning", "tool_call", "structured_output", "temperature", "open_weights"), "boolean" + ), + **dict.fromkeys(("knowledge", "release_date", "last_updated"), "date"), + "modalities": _MODALITIES, + "limit": _LIMIT, +} +_MODEL = ObjectSchema( + { + **_COMMON, + "license": "nonempty", + "links": ArraySchema(_LINK), + "weights": ArraySchema(_WEIGHT), + "benchmarks": ArraySchema(_BENCHMARK), + }, + ("name", "description"), +) +_PROVIDER_MODEL = ObjectSchema( + { + **_COMMON, + "limit": ObjectSchema(_LIMIT.fields, ("context", "output")), + "reasoning_options": ArraySchema("reasoning_option"), + "interleaved": "interleaved", + "cost": _COST, + "status": frozenset({"alpha", "beta", "deprecated"}), + }, + ( + "name", + "description", + "attachment", + "reasoning", + "tool_call", + "release_date", + "last_updated", + "modalities", + "open_weights", + "limit", + ), +) +_PROVIDER = ObjectSchema( + {"name": "nonempty", "env": ArraySchema("string"), "npm": "nonempty", "api": "string", "doc": "nonempty"}, + ("name", "env", "npm", "doc"), +) + + +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": + if not document.data["env"]: + raise _error(document, "env", "SOURCE_CONSTRAINT_INVALID") + npm = document.data["npm"] + api = "api" in document.data + required = {"@ai-sdk/openai-compatible", "@openrouter/ai-sdk-provider", "merge-gateway-ai-sdk-provider"} + optional = {"@ai-sdk/openai", "@ai-sdk/anthropic", "kiro-acp-ai-provider"} + if (npm in required and not api) or (npm not in required | optional and api): + raise _error(document, "api", "SOURCE_CONSTRAINT_INVALID") + 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") + cost = document.data.get("cost", {}) + if not reasoning and "reasoning" in cost: + raise _error(document, "cost.reasoning", "SOURCE_CONSTRAINT_INVALID") + sizes = [tier["tier"]["size"] for tier in cost.get("tiers", ())] + if len(set(sizes)) != len(sizes): + raise _error(document, "cost.tiers", "SOURCE_CONSTRAINT_INVALID") diff --git a/test-e2e/changes/requirements/MODEL-PROFILE-NORMALIZATION-20261013.yaml b/test-e2e/changes/requirements/MODEL-PROFILE-NORMALIZATION-20261013.yaml index 3d832324fc..cd86eb7b00 100644 --- a/test-e2e/changes/requirements/MODEL-PROFILE-NORMALIZATION-20261013.yaml +++ b/test-e2e/changes/requirements/MODEL-PROFILE-NORMALIZATION-20261013.yaml @@ -32,11 +32,17 @@ change: - Unknown/missing base references, duplicate source identities and invalid omission declarations fail explicitly; surviving fields retain exact source file and path. + - Selected authored source schemas reject unknown nested fields, incorrect scalar + types, invalid dates, modalities, reasoning variants and cost tiers with exact + sanitized source paths. Raw base-model overrides allow partial objects; effective + provider-model validation requires the inherited mandatory fields and reasoning + cross-field constraints. reason: Existing profile contract UT owns isolated budget validation; no component or runtime normalization is connected in this first step. test_selectors: - test/sdk/core/model_service/test_profile_contracts.py - test/sdk/core/model_service/test_source_inheritance.py + - test/sdk/core/model_service/test_source_validation.py no_case_change_reason: Current steps cover isolated budget and inheritance rules. Component Cases will be registered before wiring reader, source validator and normalization; existing formal Cases stay unchanged. 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..3a20a6bf15 --- /dev/null +++ b/test/sdk/core/model_service/test_source_validation.py @@ -0,0 +1,248 @@ +"""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"), + ("family", "invented-family", "family"), + ("release_date", "2025-02-29", "release_date"), + ("last_updated", "2026-13", "last_updated"), + ("release_date", "2026-01-00", "release_date"), + ("release_date", "2026/01/01", "release_date"), + ( + "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"), + ("cost", {"input": float("inf"), "output": 1}, "cost.input"), + ("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", + ), + ({"cost": {"input": 1, "output": 2, "extra": 3}}, "cost.extra"), + ], +) +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)) + + +def test_cost_tier_duplicates_and_reasoning_cost_conflicts_are_rejected(hosted): + tier = {"tier": {"size": 200000}, "input": 1, "output": 2} + hosted["cost"] = {"input": 1, "output": 2, "tiers": [tier, tier]} + with pytest.raises(SourceReadError) as error: + validate_source_document(document("provider_model", hosted)) + assert error.value.source_path == "cost.tiers" + hosted["reasoning"] = False + del hosted["reasoning_options"] + hosted["cost"] = {"input": 1, "output": 2, "reasoning": 1} + with pytest.raises(SourceReadError) as error: + validate_source_document(document("provider_model", hosted)) + assert error.value.source_path == "cost.reasoning" + + +@pytest.mark.parametrize( + "npm,api,valid", + [ + ("@ai-sdk/openai-compatible", True, True), + ("@ai-sdk/openai-compatible", False, False), + ("@ai-sdk/openai", False, True), + ("unknown-sdk", True, False), + ("unknown-sdk", False, True), + ], +) +def test_provider_connection_metadata_validates_without_selecting_api_protocol( + npm, api, valid +): + raw = { + "name": "Provider", + "env": ["API_KEY"], + "npm": npm, + "doc": "https://example.com/docs", + } + if api: + raw["api"] = "https://example.com/v1" + source = document("provider", raw) + if valid: + validate_source_document(source) + else: + with pytest.raises(SourceReadError) as error: + validate_source_document(source) + assert error.value.source_path == "api" + + +def test_empty_provider_env_is_invalid(): + with pytest.raises(SourceReadError) as error: + validate_source_document( + document( + "provider", + {"name": "Provider", "env": [], "npm": "@ai-sdk/openai", "doc": "docs"}, + ) + ) + assert error.value.source_path == "env" + + +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 + + +@pytest.mark.parametrize( + "url", ["invalid", "https://", "https://bad host", "https://[invalid"] +) +def test_invalid_reference_urls_are_rejected(url): + raw = {"name": "Model", "description": "Description", "links": [{"url": url}]} + with pytest.raises(SourceReadError) as error: + validate_source_document(document("model", raw)) + assert error.value.source_path == "links[0].url" + + +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" From ac9fd39a4372607ac15b546f3f2b109a4d3f9af6 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 13 Oct 2026 11:34:21 +0800 Subject: [PATCH 4/5] feat(model-service): register explicit source field dispositions --- .../profiles/_models_dev/mapping.py | 167 ++ .../rules/mappings/models_dev/v1.toml | 1417 +++++++++++++++++ test-e2e/cases/MPN-D1-001/case.yaml | 69 + .../MODEL-PROFILE-NORMALIZATION-20261013.yaml | 11 +- .../SDK-MODEL-PROFILE-NORMALIZATION/cases.md | 2 +- .../feature.yaml | 9 +- .../core/model_service/test_source_mapping.py | 200 +++ 7 files changed, 1866 insertions(+), 9 deletions(-) create mode 100644 sdk/nexent/core/model_service/profiles/_models_dev/mapping.py create mode 100644 sdk/nexent/core/model_service/profiles/rules/mappings/models_dev/v1.toml create mode 100644 test-e2e/cases/MPN-D1-001/case.yaml create mode 100644 test/sdk/core/model_service/test_source_mapping.py diff --git a/sdk/nexent/core/model_service/profiles/_models_dev/mapping.py b/sdk/nexent/core/model_service/profiles/_models_dev/mapping.py new file mode 100644 index 0000000000..3bc2c748b6 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/_models_dev/mapping.py @@ -0,0 +1,167 @@ +"""Versioned source dispositions, separate from runtime API field bindings.""" + +import re +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, Mapping + +from pydantic import Field, ValidationError + +from nexent.core.model_service.profiles._models_dev.inheritance import EffectiveSourceModel, FieldOrigin +from nexent.core.model_service.profiles._models_dev.reader import SourceDocument, SourceReadError, _freeze +from nexent.core.model_service.profiles.contracts import NonBlankString, ProfileContract + + +class SourceMappingRule(ProfileContract): + """One explicitly scoped authored field disposition.""" + + rule_id: NonBlankString + rule_version: int = Field(ge=1, le=1) + source_schema_ref: Literal["models_dev_authored_v1"] + source_kind: Literal["model", "provider", "provider_model"] + source_path: NonBlankString + source_type: Literal["boolean", "string", "integer", "number", "number_or_string", "object", "array"] + target_path: NonBlankString + transform_ref: Literal["identity_v1", "boolean_capability_v1", "zero_unknown_tokens_v1"] + missing_policy: Literal["omit", "unknown"] + special_value_policy: Literal["preserve", "zero_is_unknown", "strict_boolean"] + disposition: Literal["map", "reference"] + evidence: NonBlankString + + +class SourceMappingRegistry(ProfileContract): + mapping_id: Literal["models_dev_source_v1"] + mapping_version: int = Field(ge=1, le=1) + rules: tuple[SourceMappingRule, ...] = Field(min_length=1) + + +@dataclass(frozen=True) +class MappedFieldOrigin: + source: FieldOrigin + rule_id: str + rule_version: int + target_path: str + + +@dataclass(frozen=True) +class MappedSourceFields: + """Canonical fields before supplements and runtime profile composition.""" + + source: EffectiveSourceModel | SourceDocument + mapping_id: str + mapping_version: int + values: Mapping[str, object] + field_origins: Mapping[str, MappedFieldOrigin] + + +def _error(category: str, file_path: str, kind: str, path: str = "") -> SourceReadError: + error = SourceReadError(category, file_path, kind) + error.source_path = path + error.rule_id = "models_dev_source_v1" + return error + + +def load_mapping_rules(path: Path) -> SourceMappingRegistry: + """Load the explicitly selected local registry; unknown rules fail closed.""" + try: + raw = tomllib.loads(path.read_text(encoding="utf-8")) + if isinstance(raw.get("rules"), list): + raw["rules"] = tuple(raw["rules"]) + registry = SourceMappingRegistry.model_validate(raw) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError, ValidationError): + raise _error("SOURCE_MAPPING_INVALID", path.name, "mapping") from None + identities = set() + rule_ids = set() + targets = set() + for rule in registry.rules: + identity = (rule.source_kind, rule.source_path) + if identity in identities or rule.rule_id in rule_ids: + raise _error("SOURCE_MAPPING_DUPLICATE", path.name, "mapping", rule.source_path) + identities.add(identity) + rule_ids.add(rule.rule_id) + target_identity = (rule.source_kind, rule.target_path) + if target_identity in targets: + raise _error("SOURCE_TARGET_CONFLICT", path.name, "mapping", rule.source_path) + targets.add(target_identity) + if ( + not rule.target_path.startswith("/") + or not re.fullmatch( + r"[a-zA-Z_][a-zA-Z0-9_]*(?:\[\])?(?:\.[a-zA-Z_][a-zA-Z0-9_]*(?:\[\])?)*", rule.source_path + ) + or rule.source_path.count("[]") != rule.target_path.count("[]") + ): + raise _error("SOURCE_MAPPING_INVALID", path.name, "mapping", rule.source_path) + if (rule.transform_ref == "boolean_capability_v1" and rule.source_type != "boolean") or ( + rule.transform_ref == "zero_unknown_tokens_v1" and rule.source_type != "integer" + ): + raise _error("SOURCE_MAPPING_INVALID", path.name, "mapping", rule.source_path) + expected_policy = { + "identity_v1": "preserve", + "boolean_capability_v1": "strict_boolean", + "zero_unknown_tokens_v1": "zero_is_unknown", + }[rule.transform_ref] + if rule.special_value_policy != expected_policy: + raise _error("SOURCE_MAPPING_INVALID", path.name, "mapping", rule.source_path) + return registry + + +def _leaves(value: object, path: str = ""): + if isinstance(value, Mapping) and value: + for key, item in value.items(): + yield from _leaves(item, f"{path}.{key}" if path else key) + elif isinstance(value, (tuple, list)) and value: + for index, item in enumerate(value): + yield from _leaves(item, f"{path}[{index}]") + elif path: + yield path, value + + +def _typed(value: object, source_type: str) -> bool: + return { + "boolean": type(value) is bool, + "string": isinstance(value, str), + "integer": type(value) is int, + "number": type(value) in (int, float), + "number_or_string": type(value) in (int, float) or isinstance(value, str), + "object": isinstance(value, Mapping), + "array": isinstance(value, (tuple, list)), + }[source_type] + + +def map_source_fields( + source: EffectiveSourceModel | SourceDocument, registry: SourceMappingRegistry +) -> MappedSourceFields: + """Map every validated leaf explicitly; no field-name aliases or API paths.""" + document = source.document if isinstance(source, EffectiveSourceModel) else source + rules = {rule.source_path: rule for rule in registry.rules if rule.source_kind == document.kind} + values = {} + origins = {} + for path, value in _leaves(source.data): + pattern = re.sub(r"\[\d+\]", "[]", path) + rule = rules.get(pattern) + if rule is None: + raise _error("SOURCE_FIELD_UNHANDLED", document.file_path, document.kind, path) + if not _typed(value, rule.source_type): + raise _error("SOURCE_VALUE_INVALID", document.file_path, document.kind, path) + indexes = iter(re.findall(r"\[(\d+)\]", path)) + target = re.sub(r"\[\]", lambda _: "/" + next(indexes), rule.target_path) + if target in values: + raise _error("SOURCE_TARGET_CONFLICT", document.file_path, document.kind, path) + if rule.transform_ref == "boolean_capability_v1": + value = "supported" if value else "unsupported" + elif rule.transform_ref == "zero_unknown_tokens_v1" and value == 0: + value = None + values[target] = value + origins[target] = MappedFieldOrigin( + source.field_origins[path] + if isinstance(source, EffectiveSourceModel) + else FieldOrigin(document.kind, document.file_path, path, document.content_sha256), + rule.rule_id, + rule.rule_version, + target, + ) + for path, rule in rules.items(): + if "[]" not in path and rule.missing_policy == "unknown" and rule.target_path not in values: + values[rule.target_path] = "unknown" if rule.transform_ref == "boolean_capability_v1" else None + return MappedSourceFields(source, registry.mapping_id, registry.mapping_version, _freeze(values), _freeze(origins)) diff --git a/sdk/nexent/core/model_service/profiles/rules/mappings/models_dev/v1.toml b/sdk/nexent/core/model_service/profiles/rules/mappings/models_dev/v1.toml new file mode 100644 index 0000000000..1a9c98cddf --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/rules/mappings/models_dev/v1.toml @@ -0,0 +1,1417 @@ +# Explicit source dispositions; API bindings are managed separately. +mapping_id = "models_dev_source_v1" +mapping_version = 1 + +[[rules]] +rule_id = "model.name" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "name" +source_type = "string" +target_path = "/identity/name" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.description" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "description" +source_type = "string" +target_path = "/identity/description" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.family" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "family" +source_type = "string" +target_path = "/identity/family" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.attachment" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "attachment" +source_type = "boolean" +target_path = "/reference_metadata/attachment" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.reasoning" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "reasoning" +source_type = "boolean" +target_path = "/reasoning/status" +transform_ref = "boolean_capability_v1" +missing_policy = "unknown" +special_value_policy = "strict_boolean" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.tool_call" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "tool_call" +source_type = "boolean" +target_path = "/tools/status" +transform_ref = "boolean_capability_v1" +missing_policy = "unknown" +special_value_policy = "strict_boolean" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.structured_output" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "structured_output" +source_type = "boolean" +target_path = "/structured_output/status" +transform_ref = "boolean_capability_v1" +missing_policy = "unknown" +special_value_policy = "strict_boolean" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.temperature" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "temperature" +source_type = "boolean" +target_path = "/sampling/temperature/status" +transform_ref = "boolean_capability_v1" +missing_policy = "unknown" +special_value_policy = "strict_boolean" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.open_weights" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "open_weights" +source_type = "boolean" +target_path = "/reference_metadata/open_weights" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.knowledge" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "knowledge" +source_type = "string" +target_path = "/reference_metadata/knowledge" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.release_date" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "release_date" +source_type = "string" +target_path = "/reference_metadata/release_date" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.last_updated" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "last_updated" +source_type = "string" +target_path = "/reference_metadata/last_updated" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.modalities" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "modalities" +source_type = "object" +target_path = "/modalities" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.modalities.input" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "modalities.input" +source_type = "array" +target_path = "/modalities/input" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.modalities.input[]" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "modalities.input[]" +source_type = "string" +target_path = "/modalities/input[]" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.modalities.output" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "modalities.output" +source_type = "array" +target_path = "/modalities/output" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.modalities.output[]" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "modalities.output[]" +source_type = "string" +target_path = "/modalities/output[]" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.limit" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "limit" +source_type = "object" +target_path = "/reference_metadata/limit" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.limit.context" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "limit.context" +source_type = "integer" +target_path = "/context/combined_window" +transform_ref = "zero_unknown_tokens_v1" +missing_policy = "unknown" +special_value_policy = "zero_is_unknown" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.limit.input" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "limit.input" +source_type = "integer" +target_path = "/context/input_limit" +transform_ref = "zero_unknown_tokens_v1" +missing_policy = "unknown" +special_value_policy = "zero_is_unknown" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.limit.output" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "limit.output" +source_type = "integer" +target_path = "/context/output_limit" +transform_ref = "zero_unknown_tokens_v1" +missing_policy = "unknown" +special_value_policy = "zero_is_unknown" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.license" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "license" +source_type = "string" +target_path = "/reference_metadata/license" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.links" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "links" +source_type = "array" +target_path = "/reference_metadata/links" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.links[]" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "links[]" +source_type = "object" +target_path = "/reference_metadata/links[]" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.links[].url" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "links[].url" +source_type = "string" +target_path = "/reference_metadata/links[]/url" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.links[].label" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "links[].label" +source_type = "string" +target_path = "/reference_metadata/links[]/label" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.links[].type" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "links[].type" +source_type = "string" +target_path = "/reference_metadata/links[]/type" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.weights" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "weights" +source_type = "array" +target_path = "/reference_metadata/weights" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.weights[]" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "weights[]" +source_type = "object" +target_path = "/reference_metadata/weights[]" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.weights[].url" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "weights[].url" +source_type = "string" +target_path = "/reference_metadata/weights[]/url" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.weights[].label" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "weights[].label" +source_type = "string" +target_path = "/reference_metadata/weights[]/label" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.weights[].format" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "weights[].format" +source_type = "string" +target_path = "/reference_metadata/weights[]/format" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.weights[].quantization" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "weights[].quantization" +source_type = "string" +target_path = "/reference_metadata/weights[]/quantization" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.benchmarks" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "benchmarks" +source_type = "array" +target_path = "/reference_metadata/benchmarks" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.benchmarks[]" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "benchmarks[]" +source_type = "object" +target_path = "/reference_metadata/benchmarks[]" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.benchmarks[].name" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "benchmarks[].name" +source_type = "string" +target_path = "/reference_metadata/benchmarks[]/name" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.benchmarks[].score" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "benchmarks[].score" +source_type = "number_or_string" +target_path = "/reference_metadata/benchmarks[]/score" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.benchmarks[].metric" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "benchmarks[].metric" +source_type = "string" +target_path = "/reference_metadata/benchmarks[]/metric" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.benchmarks[].harness" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "benchmarks[].harness" +source_type = "string" +target_path = "/reference_metadata/benchmarks[]/harness" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.benchmarks[].variant" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "benchmarks[].variant" +source_type = "string" +target_path = "/reference_metadata/benchmarks[]/variant" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.benchmarks[].dataset" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "benchmarks[].dataset" +source_type = "string" +target_path = "/reference_metadata/benchmarks[]/dataset" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.benchmarks[].version" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "benchmarks[].version" +source_type = "string" +target_path = "/reference_metadata/benchmarks[]/version" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.benchmarks[].source" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "benchmarks[].source" +source_type = "string" +target_path = "/reference_metadata/benchmarks[]/source" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "model.benchmarks[].date" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "model" +source_path = "benchmarks[].date" +source_type = "string" +target_path = "/reference_metadata/benchmarks[]/date" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider.name" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider" +source_path = "name" +source_type = "string" +target_path = "/identity/name" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider.env" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider" +source_path = "env" +source_type = "array" +target_path = "/reference_metadata/env" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider.env[]" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider" +source_path = "env[]" +source_type = "string" +target_path = "/reference_metadata/env[]" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider.npm" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider" +source_path = "npm" +source_type = "string" +target_path = "/reference_metadata/npm" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider.api" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider" +source_path = "api" +source_type = "string" +target_path = "/connection_suggestions/base_url" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider.doc" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider" +source_path = "doc" +source_type = "string" +target_path = "/reference_metadata/doc" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.name" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "name" +source_type = "string" +target_path = "/identity/name" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.description" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "description" +source_type = "string" +target_path = "/identity/description" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.family" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "family" +source_type = "string" +target_path = "/identity/family" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.attachment" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "attachment" +source_type = "boolean" +target_path = "/reference_metadata/attachment" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.reasoning" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "reasoning" +source_type = "boolean" +target_path = "/reasoning/status" +transform_ref = "boolean_capability_v1" +missing_policy = "unknown" +special_value_policy = "strict_boolean" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.tool_call" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "tool_call" +source_type = "boolean" +target_path = "/tools/status" +transform_ref = "boolean_capability_v1" +missing_policy = "unknown" +special_value_policy = "strict_boolean" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.structured_output" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "structured_output" +source_type = "boolean" +target_path = "/structured_output/status" +transform_ref = "boolean_capability_v1" +missing_policy = "unknown" +special_value_policy = "strict_boolean" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.temperature" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "temperature" +source_type = "boolean" +target_path = "/sampling/temperature/status" +transform_ref = "boolean_capability_v1" +missing_policy = "unknown" +special_value_policy = "strict_boolean" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.open_weights" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "open_weights" +source_type = "boolean" +target_path = "/reference_metadata/open_weights" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.knowledge" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "knowledge" +source_type = "string" +target_path = "/reference_metadata/knowledge" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.release_date" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "release_date" +source_type = "string" +target_path = "/reference_metadata/release_date" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.last_updated" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "last_updated" +source_type = "string" +target_path = "/reference_metadata/last_updated" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.modalities" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "modalities" +source_type = "object" +target_path = "/modalities" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.modalities.input" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "modalities.input" +source_type = "array" +target_path = "/modalities/input" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.modalities.input[]" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "modalities.input[]" +source_type = "string" +target_path = "/modalities/input[]" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.modalities.output" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "modalities.output" +source_type = "array" +target_path = "/modalities/output" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.modalities.output[]" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "modalities.output[]" +source_type = "string" +target_path = "/modalities/output[]" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.limit" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "limit" +source_type = "object" +target_path = "/reference_metadata/limit" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.limit.context" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "limit.context" +source_type = "integer" +target_path = "/context/combined_window" +transform_ref = "zero_unknown_tokens_v1" +missing_policy = "unknown" +special_value_policy = "zero_is_unknown" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.limit.input" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "limit.input" +source_type = "integer" +target_path = "/context/input_limit" +transform_ref = "zero_unknown_tokens_v1" +missing_policy = "unknown" +special_value_policy = "zero_is_unknown" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.limit.output" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "limit.output" +source_type = "integer" +target_path = "/context/output_limit" +transform_ref = "zero_unknown_tokens_v1" +missing_policy = "unknown" +special_value_policy = "zero_is_unknown" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.reasoning_options" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "reasoning_options" +source_type = "array" +target_path = "/reasoning/control_candidates" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.reasoning_options[]" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "reasoning_options[]" +source_type = "object" +target_path = "/reasoning/control_candidates[]" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.reasoning_options[].type" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "reasoning_options[].type" +source_type = "string" +target_path = "/reasoning/control_candidates[]/type" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.reasoning_options[].values" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "reasoning_options[].values" +source_type = "array" +target_path = "/reasoning/control_candidates[]/values" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.reasoning_options[].values[]" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "reasoning_options[].values[]" +source_type = "string" +target_path = "/reasoning/control_candidates[]/values[]" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.reasoning_options[].min" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "reasoning_options[].min" +source_type = "integer" +target_path = "/reasoning/control_candidates[]/min" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.reasoning_options[].max" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "reasoning_options[].max" +source_type = "integer" +target_path = "/reasoning/control_candidates[]/max" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "map" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.interleaved" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "interleaved" +source_type = "boolean" +target_path = "/reference_metadata/interleaved" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.interleaved.field" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "interleaved.field" +source_type = "string" +target_path = "/reference_metadata/interleaved/field" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost" +source_type = "object" +target_path = "/reference_metadata/cost" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.input" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.input" +source_type = "number" +target_path = "/reference_metadata/cost/input" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.output" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.output" +source_type = "number" +target_path = "/reference_metadata/cost/output" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.reasoning" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.reasoning" +source_type = "number" +target_path = "/reference_metadata/cost/reasoning" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.cache_read" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.cache_read" +source_type = "number" +target_path = "/reference_metadata/cost/cache_read" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.cache_write" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.cache_write" +source_type = "number" +target_path = "/reference_metadata/cost/cache_write" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.input_audio" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.input_audio" +source_type = "number" +target_path = "/reference_metadata/cost/input_audio" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.output_audio" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.output_audio" +source_type = "number" +target_path = "/reference_metadata/cost/output_audio" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers" +source_type = "array" +target_path = "/reference_metadata/cost/tiers" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers[]" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers[]" +source_type = "object" +target_path = "/reference_metadata/cost/tiers[]" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers[].input" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers[].input" +source_type = "number" +target_path = "/reference_metadata/cost/tiers[]/input" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers[].output" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers[].output" +source_type = "number" +target_path = "/reference_metadata/cost/tiers[]/output" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers[].reasoning" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers[].reasoning" +source_type = "number" +target_path = "/reference_metadata/cost/tiers[]/reasoning" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers[].cache_read" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers[].cache_read" +source_type = "number" +target_path = "/reference_metadata/cost/tiers[]/cache_read" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers[].cache_write" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers[].cache_write" +source_type = "number" +target_path = "/reference_metadata/cost/tiers[]/cache_write" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers[].input_audio" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers[].input_audio" +source_type = "number" +target_path = "/reference_metadata/cost/tiers[]/input_audio" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers[].output_audio" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers[].output_audio" +source_type = "number" +target_path = "/reference_metadata/cost/tiers[]/output_audio" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers[].tier" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers[].tier" +source_type = "object" +target_path = "/reference_metadata/cost/tiers[]/tier" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers[].tier.type" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers[].tier.type" +source_type = "string" +target_path = "/reference_metadata/cost/tiers[]/tier/type" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.cost.tiers[].tier.size" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "cost.tiers[].tier.size" +source_type = "integer" +target_path = "/reference_metadata/cost/tiers[]/tier/size" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" + +[[rules]] +rule_id = "provider_model.status" +rule_version = 1 +source_schema_ref = "models_dev_authored_v1" +source_kind = "provider_model" +source_path = "status" +source_type = "string" +target_path = "/reference_metadata/status" +transform_ref = "identity_v1" +missing_policy = "omit" +special_value_policy = "preserve" +disposition = "reference" +evidence = "models.dev e69e184 schema.ts; selected authored fields; API bindings separate" diff --git a/test-e2e/cases/MPN-D1-001/case.yaml b/test-e2e/cases/MPN-D1-001/case.yaml new file mode 100644 index 0000000000..67198ace62 --- /dev/null +++ b/test-e2e/cases/MPN-D1-001/case.yaml @@ -0,0 +1,69 @@ +schema_version: '1.0' +stage: D1 +module: SDK-MODEL-SERVICE +case: + case_id: MPN-D1-001 + feature_id: SDK-MODEL-PROFILE-NORMALIZATION + business_rule_ids: + - SDK-MODEL-PROFILE-NORMALIZATION-RULE-01 + - SDK-MODEL-PROFILE-NORMALIZATION-RULE-02 + - SDK-MODEL-PROFILE-NORMALIZATION-RULE-03 + title: Pinned catalog composes source semantics and origins atomically + type: SDK-UT + priority: P1 + status: active + automation: automated + objective: The catalog component reads, validates, inherits and normalizes the complete pinned inventory with deterministic versions and exact provenance, rejecting invalid catalogs without partial publication. + preconditions: + - The repository contains the pinned 45-source inventory and versioned source mapping rules. + - Controlled temporary copies may alter source bytes, declared digests and mapping rules without changing repository files. + test_data: + source_count: 45 + model_count: 21 + provider_count: 3 + provider_model_count: 21 + steps: + - order: 1 + action: Load the pinned inventory through the catalog component twice and inspect China-region model and provider entries. + expected: Both loads produce the same immutable complete catalog and revision; identity, inherited capability limits and field origins agree with the pinned source data. + - order: 2 + action: Change a declared source value in a temporary catalog and update only its manifest digest; then change a mapping rule in a separate temporary catalog. + expected: Each valid source or rule change changes the catalog revision and the affected normalized value or mapping provenance; the original catalog remains unchanged. + - order: 3 + action: Introduce an unhandled field, invalid inherited model reference or incompatible rule version in controlled copies and load each through the same component. + expected: Each full load fails with an explicit sanitized source diagnostic; no partial catalog is returned and a previously loaded catalog remains usable. + expected_results: + - The complete pinned inventory is normalized through real reader, schema validation, inheritance and mapping collaboration. + - Revision identity accounts for source bytes and mapping/parser versions; field origins retain source and rule ownership. + - Invalid catalogs fail atomically without mutating the previously returned catalog. + forbidden_side_effects: + - No provider HTTP requests, environment credential reads or database writes occur. + - No mutation of repository source files or previously returned immutable catalog values occurs. + unit_boundary: Real SDK catalog component collaboration across inventory reading, schema validation, inheritance, mapping, profile normalization and revision calculation; temporary local catalog files control the external filesystem boundary. Existing UT covers each helper independently, but not full inventory publication, cross-layer provenance and revision behavior. + inputs: + - Complete pinned authored TOML inventory and source mapping rules + - Controlled source/rule changes and invalid catalog variants + assertions: + - Complete counts, China provider identities, selected exact model capabilities and provenance survive the full component pipeline + - Relevant source and mapping changes affect revision; repeated unchanged loads are deterministic + - Errors return structured sanitized diagnostics and never a partially published or mutated catalog + execution: + profiles: + - name: mock + required_services: [] + acceptance: + - id: AC-01 + description: Full pinned catalog normalization preserves capabilities and exact source/rule provenance without external service access. + steps: [1] + expected_results: [1, 2] + forbidden_side_effects: [1, 2] + - id: AC-02 + description: Valid source and mapping changes produce a changed deterministic revision while preserving the original snapshot. + steps: [2] + expected_results: [2] + forbidden_side_effects: [2] + - id: AC-03 + description: Invalid catalogs fail atomically and leave previously loaded results usable. + steps: [3] + expected_results: [3] + forbidden_side_effects: [1, 2] diff --git a/test-e2e/changes/requirements/MODEL-PROFILE-NORMALIZATION-20261013.yaml b/test-e2e/changes/requirements/MODEL-PROFILE-NORMALIZATION-20261013.yaml index cd86eb7b00..b930676c70 100644 --- a/test-e2e/changes/requirements/MODEL-PROFILE-NORMALIZATION-20261013.yaml +++ b/test-e2e/changes/requirements/MODEL-PROFILE-NORMALIZATION-20261013.yaml @@ -16,7 +16,8 @@ change: retired: [] affected_cases: existing: [] - added: [] + added: + - MPN-D1-001 modified: [] retired: [] traditional_ut: @@ -37,14 +38,16 @@ change: sanitized source paths. Raw base-model overrides allow partial objects; effective provider-model validation requires the inherited mandatory fields and reasoning cross-field constraints. + - Versioned mapping rules reject duplicate source identities, unknown transforms + and incompatible versions; source capabilities preserve unknown, false and zero + separately from request bindings. Each handled leaf retains mapping and source + provenance; reference metadata never becomes an API setting. reason: Existing profile contract UT owns isolated budget validation; no component or runtime normalization is connected in this first step. test_selectors: - test/sdk/core/model_service/test_profile_contracts.py - test/sdk/core/model_service/test_source_inheritance.py - test/sdk/core/model_service/test_source_validation.py - no_case_change_reason: Current steps cover isolated budget and inheritance rules. - Component Cases will be registered before wiring reader, source validator and - normalization; existing formal Cases stay unchanged. + - test/sdk/core/model_service/test_source_mapping.py notes: AC-01/02 subset remains pending as a whole. This change does not infer provider API behavior or modify a saved outlet. diff --git a/test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/cases.md b/test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/cases.md index 724bda5f73..2652e7776a 100644 --- a/test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/cases.md +++ b/test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/cases.md @@ -2,4 +2,4 @@ 此页由结构化用例生成,不手工编辑。 -当前测试基线未保留关联用例。 +- D1 [MPN-D1-001](../../cases/MPN-D1-001/case.yaml) · active diff --git a/test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/feature.yaml b/test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/feature.yaml index f7e54ce0cd..762ba04082 100644 --- a/test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/feature.yaml +++ b/test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/feature.yaml @@ -19,10 +19,11 @@ feature: - rule_id: SDK-MODEL-PROFILE-NORMALIZATION-RULE-03 description: Every source field needs a versioned disposition; unknown fields and invalid types fail the full load with source diagnostics. - required_test_stages: [] + required_test_stages: + - D1 source: requirement_ids: - MODEL-PROFILE-NORMALIZATION-20261013 - notes: Incremental implementation. Budget/inheritance UT does not complete the - normalization component; pipeline component proof will be registered before - connection. No provider/API/database/Agent acceptance claimed. + notes: Isolated UT does not complete component acceptance. MPN-D1-001 was + designed before connecting the catalog pipeline to prove complete publication, + provenance and revision behavior. No provider/API/database/Agent acceptance claimed. diff --git a/test/sdk/core/model_service/test_source_mapping.py b/test/sdk/core/model_service/test_source_mapping.py new file mode 100644 index 0000000000..25f1e47022 --- /dev/null +++ b/test/sdk/core/model_service/test_source_mapping.py @@ -0,0 +1,200 @@ +"""Versioned source dispositions without API bindings or catalog execution.""" + +from pathlib import Path + +import pytest +from nexent.core.model_service.profiles._models_dev.inheritance import ( + resolve_source_model, +) +from nexent.core.model_service.profiles._models_dev.mapping import ( + load_mapping_rules, + map_source_fields, +) +from nexent.core.model_service.profiles._models_dev.reader import ( + SourceDocument, + SourceReadError, + _freeze, +) + +RULE_PATH = ( + Path(__file__).parents[4] + / "sdk/nexent/core/model_service/profiles/rules/mappings/models_dev/v1.toml" +) + + +def doc(kind, key, data): + return SourceDocument(kind, key, f"{kind}/{key}.toml", "a" * 64, _freeze(data)) + + +def test_mapping_keeps_unknown_false_and_zero_separate_and_records_rule_origin(): + source = doc( + "model", + "lab/model", + {"name": "Model", "reasoning": False, "limit": {"context": 0, "output": 10}}, + ) + result = map_source_fields(source, load_mapping_rules(RULE_PATH)) + assert result.values["/reasoning/status"] == "unsupported" + assert result.values["/tools/status"] == "unknown" + assert result.values["/context/combined_window"] is None + assert result.values["/context/input_limit"] is None + assert result.values["/context/output_limit"] == 10 + origin = result.field_origins["/context/combined_window"] + assert origin.rule_id == "model.limit.context" + assert origin.rule_version == 1 + assert origin.source.source_path == "limit.context" + assert "/tools/status" not in result.field_origins + assert source.data["limit"]["context"] == 0 + with pytest.raises(TypeError): + result.values["/reasoning/status"] = "supported" + + +def test_inherited_field_ownership_is_preserved_through_mapping(): + base = doc("model", "lab/model", {"tool_call": True, "limit": {"context": 100}}) + hosted = doc( + "provider_model", + "vendor/model", + {"base_model": base.key, "limit": {"output": 10}}, + ) + result = map_source_fields( + resolve_source_model(hosted, (base, hosted)), load_mapping_rules(RULE_PATH) + ) + assert result.field_origins["/tools/status"].source.file_path == base.file_path + assert ( + result.field_origins["/context/output_limit"].source.file_path + == hosted.file_path + ) + assert result.field_origins["/tools/status"].rule_id == "provider_model.tool_call" + + +def test_array_reference_fields_and_control_candidates_preserve_exact_values(): + source = doc( + "provider_model", + "vendor/model", + { + "reasoning_options": [ + {"type": "effort", "values": ["none", "null"]}, + {"type": "budget_tokens", "min": -1}, + ], + "cost": {"input": 0.1, "output": 0.2}, + "modalities": {"input": ["text", "image"], "output": []}, + }, + ) + result = map_source_fields(source, load_mapping_rules(RULE_PATH)) + assert result.values["/reasoning/control_candidates/0/values/0"] == "none" + assert result.values["/reasoning/control_candidates/0/values/1"] == "null" + assert result.values["/reasoning/control_candidates/1/min"] == -1 + assert result.values["/reference_metadata/cost/input"] == 0.1 + assert result.values["/modalities/output"] == () + assert not any("api" in key or "body" in key for key in result.values) + + +def test_provider_metadata_remains_a_suggestion_without_api_selection(): + source = doc( + "provider", + "vendor", + {"api": "https://example.com", "env": ["PRIVATE_API_KEY"], "npm": "sdk"}, + ) + result = map_source_fields(source, load_mapping_rules(RULE_PATH)) + assert result.values["/connection_suggestions/base_url"] == "https://example.com" + assert result.values["/reference_metadata/env/0"] == "PRIVATE_API_KEY" + assert not any("protocol" in key or "credential" in key for key in result.values) + + +@pytest.mark.parametrize( + "data,category,path", + [ + ({"reasoning_effort": "high"}, "SOURCE_FIELD_UNHANDLED", "reasoning_effort"), + ({"reasoning": "true"}, "SOURCE_VALUE_INVALID", "reasoning"), + ], +) +def test_mapping_rejects_unregistered_aliases_and_wrong_types(data, category, path): + with pytest.raises(SourceReadError) as error: + map_source_fields( + doc("model", "lab/model", data), load_mapping_rules(RULE_PATH) + ) + assert error.value.category == category + assert error.value.source_path == path + + +@pytest.mark.parametrize( + "replacement", ["mapping_version = true", "mapping_version = 2"] +) +def test_registry_version_is_strict_and_explicit(tmp_path, replacement): + path = tmp_path / "mapping.toml" + path.write_text(RULE_PATH.read_text().replace("mapping_version = 1", replacement)) + with pytest.raises(SourceReadError, match="SOURCE_MAPPING_INVALID"): + load_mapping_rules(path) + + +@pytest.mark.parametrize( + "before,after,category", + [ + ( + 'rule_id = "model.name"', + 'rule_id = "model.description"', + "SOURCE_MAPPING_DUPLICATE", + ), + ( + 'source_path = "name"', + 'source_path = "description"', + "SOURCE_MAPPING_DUPLICATE", + ), + ( + 'transform_ref = "identity_v1"', + 'transform_ref = "guess_v1"', + "SOURCE_MAPPING_INVALID", + ), + ('source_path = "name"', 'source_path = "*"', "SOURCE_MAPPING_INVALID"), + ( + 'target_path = "/identity/name"', + 'target_path = "name"', + "SOURCE_MAPPING_INVALID", + ), + ('source_type = "string"', 'source_type = "boolean"', "SOURCE_MAPPING_INVALID"), + ], +) +def test_invalid_registry_rules_fail_explicitly(tmp_path, before, after, category): + path = tmp_path / "mapping.toml" + # The final variant makes the identity transform legal, so test its effect below. + if before == 'source_type = "string"': + before = 'source_type = "boolean"\ntarget_path = "/reasoning/status"' + after = 'source_type = "string"\ntarget_path = "/reasoning/status"' + path.write_text(RULE_PATH.read_text().replace(before, after, 1)) + with pytest.raises(SourceReadError, match=category): + load_mapping_rules(path) + + +def test_registry_rejects_conflicting_target_ownership(tmp_path): + path = tmp_path / "mapping.toml" + path.write_text( + RULE_PATH.read_text().replace( + 'target_path = "/identity/name"', 'target_path = "/identity/description"', 1 + ) + ) + with pytest.raises(SourceReadError, match="SOURCE_TARGET_CONFLICT"): + load_mapping_rules(path) + + +def test_registry_rejects_policy_inconsistent_with_registered_transform(tmp_path): + path = tmp_path / "mapping.toml" + path.write_text( + RULE_PATH.read_text().replace( + 'special_value_policy = "strict_boolean"', + 'special_value_policy = "preserve"', + 1, + ) + ) + with pytest.raises(SourceReadError, match="SOURCE_MAPPING_INVALID"): + load_mapping_rules(path) + + +def test_empty_reference_arrays_and_text_benchmark_scores_remain_reference_data(): + source = doc( + "model", + "lab/model", + {"links": [], "benchmarks": [{"name": "Score", "score": "pass"}]}, + ) + result = map_source_fields(source, load_mapping_rules(RULE_PATH)) + assert result.values["/reference_metadata/links"] == () + assert result.values["/reference_metadata/benchmarks/0/score"] == "pass" + assert result.values["/reasoning/status"] == "unknown" From 2e7b3ac06ace02e5dee8d061275a53c797feb197 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 13 Oct 2026 11:52:28 +0800 Subject: [PATCH 5/5] feat(model-service): load immutable normalized source catalogs --- .../profiles/_models_dev/mapping.py | 42 ++- .../profiles/_models_dev/reader.py | 27 +- .../core/model_service/profiles/contracts.py | 2 +- .../core/model_service/profiles/resolver.py | 277 ++++++++++++++++++ test-e2e/cases/MPN-D1-001/case.yaml | 2 +- test-e2e/cases/MPN-D1-001/execution.yaml | 15 + test-e2e/cases/MPN-D1-001/test.py | 229 +++++++++++++++ .../MODEL-PROFILE-NORMALIZATION-20261013.yaml | 3 + .../test_catalog_normalization.py | 148 ++++++++++ .../model_service/test_profile_contracts.py | 8 + .../core/model_service/test_source_mapping.py | 16 + 11 files changed, 755 insertions(+), 14 deletions(-) create mode 100644 sdk/nexent/core/model_service/profiles/resolver.py create mode 100644 test-e2e/cases/MPN-D1-001/execution.yaml create mode 100644 test-e2e/cases/MPN-D1-001/test.py create mode 100644 test/sdk/core/model_service/test_catalog_normalization.py diff --git a/sdk/nexent/core/model_service/profiles/_models_dev/mapping.py b/sdk/nexent/core/model_service/profiles/_models_dev/mapping.py index 3bc2c748b6..a18eb00baa 100644 --- a/sdk/nexent/core/model_service/profiles/_models_dev/mapping.py +++ b/sdk/nexent/core/model_service/profiles/_models_dev/mapping.py @@ -65,44 +65,68 @@ def _error(category: str, file_path: str, kind: str, path: str = "") -> SourceRe def load_mapping_rules(path: Path) -> SourceMappingRegistry: """Load the explicitly selected local registry; unknown rules fail closed.""" try: - raw = tomllib.loads(path.read_text(encoding="utf-8")) + data = path.read_bytes() + except OSError: + raise _error("SOURCE_MAPPING_INVALID", path.name, "mapping") from None + return parse_mapping_rules(data, path.name) + + +def parse_mapping_rules(data: bytes, file_path: str) -> SourceMappingRegistry: + """Parse a captured rule snapshot; revision hashing uses these same bytes.""" + try: + raw = tomllib.loads(data.decode("utf-8")) if isinstance(raw.get("rules"), list): raw["rules"] = tuple(raw["rules"]) registry = SourceMappingRegistry.model_validate(raw) - except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError, ValidationError): - raise _error("SOURCE_MAPPING_INVALID", path.name, "mapping") from None + except (UnicodeDecodeError, tomllib.TOMLDecodeError, ValidationError): + raise _error("SOURCE_MAPPING_INVALID", file_path, "mapping") from None identities = set() rule_ids = set() targets = set() for rule in registry.rules: identity = (rule.source_kind, rule.source_path) if identity in identities or rule.rule_id in rule_ids: - raise _error("SOURCE_MAPPING_DUPLICATE", path.name, "mapping", rule.source_path) + raise _error("SOURCE_MAPPING_DUPLICATE", file_path, "mapping", rule.source_path) identities.add(identity) rule_ids.add(rule.rule_id) target_identity = (rule.source_kind, rule.target_path) if target_identity in targets: - raise _error("SOURCE_TARGET_CONFLICT", path.name, "mapping", rule.source_path) + raise _error("SOURCE_TARGET_CONFLICT", file_path, "mapping", rule.source_path) targets.add(target_identity) if ( - not rule.target_path.startswith("/") + not re.fullmatch( + r"/[a-zA-Z_][a-zA-Z0-9_]*(?:\[\])?(?:/[a-zA-Z_][a-zA-Z0-9_]*(?:\[\])?)*", rule.target_path + ) or not re.fullmatch( r"[a-zA-Z_][a-zA-Z0-9_]*(?:\[\])?(?:\.[a-zA-Z_][a-zA-Z0-9_]*(?:\[\])?)*", rule.source_path ) or rule.source_path.count("[]") != rule.target_path.count("[]") ): - raise _error("SOURCE_MAPPING_INVALID", path.name, "mapping", rule.source_path) + raise _error("SOURCE_MAPPING_INVALID", file_path, "mapping", rule.source_path) if (rule.transform_ref == "boolean_capability_v1" and rule.source_type != "boolean") or ( rule.transform_ref == "zero_unknown_tokens_v1" and rule.source_type != "integer" ): - raise _error("SOURCE_MAPPING_INVALID", path.name, "mapping", rule.source_path) + raise _error("SOURCE_MAPPING_INVALID", file_path, "mapping", rule.source_path) expected_policy = { "identity_v1": "preserve", "boolean_capability_v1": "strict_boolean", "zero_unknown_tokens_v1": "zero_is_unknown", }[rule.transform_ref] if rule.special_value_policy != expected_policy: - raise _error("SOURCE_MAPPING_INVALID", path.name, "mapping", rule.source_path) + raise _error("SOURCE_MAPPING_INVALID", file_path, "mapping", rule.source_path) + for parent in registry.rules: + if parent.source_type in ("object", "array"): + continue + for child in registry.rules: + if ( + parent.source_kind == "provider_model" + and parent.source_path == "interleaved" + and child.source_kind == parent.source_kind + and child.source_path == "interleaved.field" + ): + continue + if child.source_kind == parent.source_kind and child.target_path.startswith(parent.target_path + "/"): + raise _error("SOURCE_TARGET_CONFLICT", file_path, "mapping", child.source_path) return registry diff --git a/sdk/nexent/core/model_service/profiles/_models_dev/reader.py b/sdk/nexent/core/model_service/profiles/_models_dev/reader.py index 0ab4bfb84e..6bef70179c 100644 --- a/sdk/nexent/core/model_service/profiles/_models_dev/reader.py +++ b/sdk/nexent/core/model_service/profiles/_models_dev/reader.py @@ -92,10 +92,21 @@ def _key(entry: SourceEntry) -> str: raise SourceReadError("SOURCE_KIND_PATH_MISMATCH", entry.path, entry.kind) -def read_sources(root: Path) -> tuple[SourceDocument, ...]: +@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() - raw_manifest = _parse(_read(root, "manifest.toml", "manifest"), "manifest.toml", "manifest") + 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( @@ -131,4 +142,14 @@ def read_sources(root: Path) -> tuple[SourceDocument, ...]: 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))) - return tuple(documents) + 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/contracts.py b/sdk/nexent/core/model_service/profiles/contracts.py index 3cb7f58d8f..4d4b1ac7a9 100644 --- a/sdk/nexent/core/model_service/profiles/contracts.py +++ b/sdk/nexent/core/model_service/profiles/contracts.py @@ -52,7 +52,7 @@ class EffortControl(ProfileContract): mode: Literal["effort"] = "effort" parameter_key: Literal["reasoning_effort"] = "reasoning_effort" - values: list[NonBlankString] = Field(min_length=1) + values: list[NonBlankString | None] = Field(min_length=1) @model_validator(mode="after") def validate_values(self) -> Self: diff --git a/sdk/nexent/core/model_service/profiles/resolver.py b/sdk/nexent/core/model_service/profiles/resolver.py new file mode 100644 index 0000000000..02d8dc068b --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/resolver.py @@ -0,0 +1,277 @@ +"""Atomic loading of pinned source profiles before Nexent supplements and API bindings.""" + +import hashlib +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping + +from pydantic import ValidationError + +from nexent.core.model_service.profiles._models_dev.inheritance import EffectiveSourceModel, resolve_source_model +from nexent.core.model_service.profiles._models_dev.mapping import ( + MappedFieldOrigin, + map_source_fields, + parse_mapping_rules, +) +from nexent.core.model_service.profiles._models_dev.reader import ( + SourceDocument, + SourceReadError, + _freeze, + _read, + read_inventory, +) +from nexent.core.model_service.profiles._models_dev.validation import validate_source_document +from nexent.core.model_service.profiles.contracts import ( + AtomicProfileReference, + ContextCapabilities, + ReasoningCapabilities, +) + + +PARSER_REF = "models_dev_source_catalog_v1" +SUPPORTED_UPSTREAM_REVISION = "e69e1844210e5cfe7c7e7f68d57328bc7451a51c" +MAPPING_PATH = "rules/mappings/models_dev/v1.toml" + + +@dataclass(frozen=True) +class CatalogFieldOrigin: + mapping: MappedFieldOrigin + target_path: str + normalization_ref: str = PARSER_REF + + +@dataclass(frozen=True) +class SourceCatalogEntry: + """Source-only declaration, not a complete ModelProfile or usable outlet.""" + + kind: str + key: str + identity: Mapping[str, object] + capabilities: Mapping[str, object] + reference_metadata: Mapping[str, object] + connection_suggestions: Mapping[str, object] + field_origins: Mapping[str, CatalogFieldOrigin] + source_refs: tuple[AtomicProfileReference, ...] + profile_revision: str + + +@dataclass(frozen=True) +class SourceCatalog: + revision: str + upstream_revision: str + parser_ref: str + mapping_id: str + mapping_version: int + models: Mapping[str, SourceCatalogEntry] + providers: Mapping[str, SourceCatalogEntry] + provider_models: Mapping[str, SourceCatalogEntry] + + +def _error(category: str, document: SourceDocument, path: str = "") -> SourceReadError: + error = SourceReadError(category, document.file_path, document.kind) + error.source_path = path + error.rule_id = PARSER_REF + return error + + +def _revision(parts: Mapping[str, str]) -> str: + digest = hashlib.sha256() + for key, value in sorted(parts.items()): + for item in (key, value): + data = item.encode("utf-8") + digest.update(len(data).to_bytes(8, "big")) + digest.update(data) + return digest.hexdigest() + + +def _tree(values: Mapping[str, object]) -> dict: + """Materialize canonical pointer fields, preserving explicit arrays and nulls.""" + root = {} + for pointer, value in values.items(): + parts = pointer.split("/")[1:] + current = root + for index, part in enumerate(parts): + last = index == len(parts) - 1 + key = int(part) if isinstance(current, list) else part + if isinstance(current, list): + while len(current) <= key: + current.append(None) + if last: + current[key] = value + else: + next_value = [] if parts[index + 1].isdigit() else {} + if isinstance(current, list): + if current[key] is None: + current[key] = next_value + elif key not in current: + current[key] = next_value + current = current[key] + return root + + +def _controls(reasoning: dict) -> list | None: + if "control_candidates" not in reasoning: + return None + controls = [] + for option in reasoning["control_candidates"]: + mode = option["type"] + if mode == "toggle": + controls.append({"mode": "toggle", "values": [True, False]}) + elif mode == "effort": + controls.append( + {"mode": "effort", "values": [None if value == "null" else value for value in option["values"]]} + ) + else: + lower = option.get("min") + controls.append( + { + "mode": "budget_tokens", + "min": None if lower == -1 else lower, + "max": option.get("max"), + "special_values": [-1] if lower == -1 else [], + } + ) + return controls + + +def _normalized_origins(mapped, controls) -> dict: + origins = {} + for path, origin in mapped.field_origins.items(): + target = path + if path.startswith("/reasoning/control_candidates"): + target = path.replace("/reasoning/control_candidates", "/capabilities/reasoning/controls", 1) + target = re.sub(r"/type$", "/mode", target) + if path.endswith("/min") and mapped.values[path] == -1: + target = target[: -len("min")] + "special_values/0" + elif path.split("/")[1] in {"context", "reasoning", "tools", "sampling", "structured_output", "modalities"}: + target = "/capabilities" + path + origins[target] = CatalogFieldOrigin(origin, target) + for index, control in enumerate(controls or ()): + prefix = f"/capabilities/reasoning/controls/{index}" + type_origin = origins[prefix + "/mode"].mapping + origins[prefix + "/parameter_key"] = CatalogFieldOrigin(type_origin, prefix + "/parameter_key") + if control["mode"] == "toggle": + for value_index in range(2): + path = prefix + f"/values/{value_index}" + origins[path] = CatalogFieldOrigin(type_origin, path) + return origins + + +def _entry( + document: SourceDocument, source, registry, providers: Mapping, revision: str, version_parts: dict +) -> SourceCatalogEntry: + mapped = map_source_fields(source, registry) + tree = _tree(mapped.values) + unexpected = set(tree) - { + "identity", + "context", + "reasoning", + "tools", + "sampling", + "structured_output", + "modalities", + "reference_metadata", + "connection_suggestions", + } + if unexpected: + raise _error("SOURCE_TARGET_UNHANDLED", document) + reasoning = tree.get("reasoning", {}) + controls = _controls(reasoning) + try: + context = ContextCapabilities.model_validate(tree.get("context", {})) + except ValidationError: + raise _error("SOURCE_NORMALIZATION_INVALID", document, "limit") from None + try: + thinking = ReasoningCapabilities(status=reasoning.get("status", "unknown"), controls=controls) + except ValidationError: + raise _error("SOURCE_NORMALIZATION_INVALID", document, "reasoning_options") from None + capabilities = { + "context": context.model_dump(), + "reasoning": thinking.model_dump(), + "tools": tree.get("tools", {"status": "unknown"}), + "sampling": tree.get("sampling", {}), + "structured_output": tree.get("structured_output", {"status": "unknown"}), + "modalities": tree.get("modalities", {"input": None, "output": None}), + } + if document.kind == "provider": + capabilities = {} + dependencies = source.dependencies if isinstance(source, EffectiveSourceModel) else (document,) + if document.kind == "provider_model": + provider_key = document.key.split("/", 1)[0] + if provider_key not in providers: + raise _error("SOURCE_PROVIDER_MISSING", document) + dependencies = (*dependencies, providers[provider_key]) + profile_parts = dict(version_parts) + refs = [] + for dependency in dependencies: + profile_parts[dependency.file_path] = dependency.content_sha256 + refs.append( + AtomicProfileReference( + key=dependency.key, + catalog_revision=revision, + file_path=dependency.file_path, + content_sha256=dependency.content_sha256, + ) + ) + return SourceCatalogEntry( + document.kind, + document.key, + _freeze(tree.get("identity", {})), + _freeze(capabilities), + _freeze(tree.get("reference_metadata", {})), + _freeze(tree.get("connection_suggestions", {})), + _freeze(_normalized_origins(mapped, controls)), + tuple(refs), + _revision(profile_parts), + ) + + +def load_source_catalog(root: Path | None = None) -> SourceCatalog: + """Publish only a fully validated immutable source catalog, without caching partial results.""" + root = (root if root is not None else Path(__file__).parent).resolve() + inventory = read_inventory(root) + if inventory.upstream_revision != SUPPORTED_UPSTREAM_REVISION: + error = SourceReadError("SOURCE_SCHEMA_UNSUPPORTED", "manifest.toml", "manifest") + error.source_path = "upstream_revision" + error.rule_id = PARSER_REF + raise error + rule_bytes = _read(root, MAPPING_PATH, "mapping") + registry = parse_mapping_rules(rule_bytes, MAPPING_PATH) + version_parts = { + "parser_ref": PARSER_REF, + "source_schema_ref": inventory.source_schema_ref, + "mapping_ref": registry.mapping_id, + "mapping_version": str(registry.mapping_version), + "mapping_sha256": hashlib.sha256(rule_bytes).hexdigest(), + "upstream_revision": inventory.upstream_revision, + } + catalog_parts = { + **version_parts, + "manifest_sha256": inventory.manifest_sha256, + **{document.file_path: document.content_sha256 for document in inventory.documents}, + } + revision = _revision(catalog_parts) + providers = {document.key: document for document in inventory.documents if document.kind == "provider"} + entries = {"model": {}, "provider": {}, "provider_model": {}} + for document in inventory.documents: + validate_source_document(document) + for document in inventory.documents: + source = document + if document.kind != "provider": + source = resolve_source_model(document, inventory.documents) + effective = SourceDocument( + document.kind, document.key, document.file_path, document.content_sha256, source.data + ) + validate_source_document(effective, effective=True) + entries[document.kind][document.key] = _entry(document, source, registry, providers, revision, version_parts) + return SourceCatalog( + revision, + inventory.upstream_revision, + PARSER_REF, + registry.mapping_id, + registry.mapping_version, + _freeze(entries["model"]), + _freeze(entries["provider"]), + _freeze(entries["provider_model"]), + ) diff --git a/test-e2e/cases/MPN-D1-001/case.yaml b/test-e2e/cases/MPN-D1-001/case.yaml index 67198ace62..a3afcea802 100644 --- a/test-e2e/cases/MPN-D1-001/case.yaml +++ b/test-e2e/cases/MPN-D1-001/case.yaml @@ -30,7 +30,7 @@ case: action: Change a declared source value in a temporary catalog and update only its manifest digest; then change a mapping rule in a separate temporary catalog. expected: Each valid source or rule change changes the catalog revision and the affected normalized value or mapping provenance; the original catalog remains unchanged. - order: 3 - action: Introduce an unhandled field, invalid inherited model reference or incompatible rule version in controlled copies and load each through the same component. + action: Introduce an unhandled field, invalid inherited model reference incompatible rule version or unregistered upstream version in controlled copies and load each through the same component. expected: Each full load fails with an explicit sanitized source diagnostic; no partial catalog is returned and a previously loaded catalog remains usable. expected_results: - The complete pinned inventory is normalized through real reader, schema validation, inheritance and mapping collaboration. diff --git a/test-e2e/cases/MPN-D1-001/execution.yaml b/test-e2e/cases/MPN-D1-001/execution.yaml new file mode 100644 index 0000000000..f7449ac046 --- /dev/null +++ b/test-e2e/cases/MPN-D1-001/execution.yaml @@ -0,0 +1,15 @@ +schema_version: '1.0' +case_id: MPN-D1-001 +implementations: +- framework: pytest + file: test.py + selector: test_complete_catalog_capabilities_origins_and_immutable_snapshots + profiles: + - mock +acceptance_bindings: + AC-01: + - test_complete_catalog_capabilities_origins_and_immutable_snapshots + AC-02: + - test_source_and_rule_changes_affect_revision_and_preserve_original + AC-03: + - test_invalid_catalog_never_publishes_partial_results diff --git a/test-e2e/cases/MPN-D1-001/test.py b/test-e2e/cases/MPN-D1-001/test.py new file mode 100644 index 0000000000..27d725b610 --- /dev/null +++ b/test-e2e/cases/MPN-D1-001/test.py @@ -0,0 +1,229 @@ +"""MPN-D1-001: complete catalog publication through real internal collaborators.""" + +import hashlib +import os +import shutil +import socket +import tomllib +from pathlib import Path + +import pytest +from nexent.core.model_service.profiles._models_dev.reader import SourceReadError +from nexent.core.model_service.profiles.resolver import ( + MAPPING_PATH, + PARSER_REF, + load_source_catalog, +) + +ROOT = Path(__file__).resolve().parents[3] / "sdk/nexent/core/model_service/profiles" +pytestmark = [pytest.mark.case_id("MPN-D1-001"), pytest.mark.stage("D1")] + + +@pytest.fixture +def no_external_access(monkeypatch): + def forbidden(*args, **kwargs): + raise AssertionError( + "Catalog loading crossed an external service/credential boundary" + ) + + monkeypatch.setattr(socket, "create_connection", forbidden) + monkeypatch.setattr(socket.socket, "connect", forbidden) + monkeypatch.setattr(os, "getenv", forbidden) + + +def copy_catalog(tmp_path, name): + root = tmp_path / name + shutil.copytree(ROOT, root) + return root + + +def modify_source(root, path, transform): + file = root / path + old = file.read_bytes() + updated = transform(old.decode("utf-8")).encode("utf-8") + assert updated != old + file.write_bytes(updated) + manifest = root / "manifest.toml" + old_hash = hashlib.sha256(old).hexdigest() + new_hash = hashlib.sha256(updated).hexdigest() + blocks = manifest.read_text().split("[[sources]]") + matches = 0 + for index, block in enumerate(blocks[1:], 1): + entry = tomllib.loads(block) + if entry["path"] == path: + assert entry["content_sha256"] == old_hash + blocks[index] = block.replace(old_hash, new_hash) + matches += 1 + assert matches == 1 + manifest.write_text("[[sources]]".join(blocks)) + + +def test_complete_catalog_capabilities_origins_and_immutable_snapshots( + no_external_access, +): + before = { + path.relative_to(ROOT).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() + for path in ROOT.rglob("*.toml") + } + first = load_source_catalog(ROOT) + second = load_source_catalog(ROOT) + assert (len(first.models), len(first.providers), len(first.provider_models)) == ( + 21, + 3, + 21, + ) + assert first.revision == second.revision + assert first.parser_ref == PARSER_REF + assert set(first.providers) == { + "alibaba-cn", + "volcengine", + "volcengine-coding-plan", + } + assert "alibaba-cn/MiniMax/MiniMax-M2.7" in first.provider_models + entry = first.provider_models["alibaba-cn/qwen3.8-max"] + assert entry.capabilities["context"]["combined_window"] == 1_000_000 + assert entry.capabilities["tools"]["status"] == "supported" + origin = entry.field_origins["/capabilities/tools/status"] + assert origin.mapping.source.file_path == "models/alibaba/qwen3.8-max.toml" + assert origin.mapping.source.source_path == "tool_call" + assert origin.mapping.rule_id == "provider_model.tool_call" + assert origin.mapping.rule_version == 1 + assert origin.normalization_ref == first.parser_ref + assert {reference.key for reference in entry.source_refs} == { + "alibaba/qwen3.8-max", + "alibaba-cn/qwen3.8-max", + "alibaba-cn", + } + assert all( + reference.catalog_revision == first.revision for reference in entry.source_refs + ) + plus = first.provider_models["alibaba-cn/qwen3.5-plus"] + budget = plus.capabilities["reasoning"]["controls"][1] + assert budget["min"] is None and budget["max"] == 81920 + assert ( + first.provider_models["alibaba-cn/glm-5.1"].capabilities["tools"]["status"] + == "supported" + ) + assert ( + first.provider_models["volcengine/deepseek-v4-flash-ga-260731"].capabilities[ + "reasoning" + ]["status"] + == "supported" + ) + assert ( + first.provider_models["volcengine-coding-plan/minimax-m3"].capabilities[ + "context" + ]["combined_window"] + == 1048576 + ) + with pytest.raises(TypeError): + first.models["new"] = entry + with pytest.raises(TypeError): + plus.capabilities["reasoning"]["controls"][0]["values"][0] = False + after = { + path.relative_to(ROOT).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() + for path in ROOT.rglob("*.toml") + } + assert before == after + + +def test_source_and_rule_changes_affect_revision_and_preserve_original( + tmp_path, no_external_access +): + original = load_source_catalog(ROOT) + changed_source = copy_catalog(tmp_path, "source") + modify_source( + changed_source, + "models/alibaba/qwen3.8-max.toml", + lambda text: text.replace("tool_call = true", "tool_call = false", 1), + ) + changed = load_source_catalog(changed_source) + key = "alibaba-cn/qwen3.8-max" + assert changed.revision != original.revision + assert ( + changed.provider_models[key].profile_revision + != original.provider_models[key].profile_revision + ) + assert changed.provider_models[key].capabilities["tools"]["status"] == "unsupported" + assert original.provider_models[key].capabilities["tools"]["status"] == "supported" + assert ( + changed.provider_models["alibaba-cn/qwen3.5-plus"].profile_revision + == original.provider_models["alibaba-cn/qwen3.5-plus"].profile_revision + ) + changed_rules = copy_catalog(tmp_path, "rules") + rule_file = changed_rules / MAPPING_PATH + rule_file.write_text( + rule_file.read_text().replace( + 'rule_id = "provider_model.tool_call"', + 'rule_id = "provider_model.tool_call.reviewed"', + 1, + ) + ) + remapped = load_source_catalog(changed_rules) + assert remapped.revision != original.revision + assert ( + remapped.provider_models[key].profile_revision + != original.provider_models[key].profile_revision + ) + assert ( + remapped.provider_models[key] + .field_origins["/capabilities/tools/status"] + .mapping.rule_id + == "provider_model.tool_call.reviewed" + ) + assert ( + original.provider_models[key] + .field_origins["/capabilities/tools/status"] + .mapping.rule_id + == "provider_model.tool_call" + ) + assert load_source_catalog(changed_rules).revision == remapped.revision + + +@pytest.mark.parametrize( + "variant,category,source_path", + [ + ("unhandled", "SOURCE_FIELD_UNHANDLED", "reasoning_effort"), + ("base", "SOURCE_BASE_MODEL_MISSING", "base_model"), + ("version", "SOURCE_MAPPING_INVALID", ""), + ("upstream", "SOURCE_SCHEMA_UNSUPPORTED", "upstream_revision"), + ], +) +def test_invalid_catalog_never_publishes_partial_results( + tmp_path, no_external_access, variant, category, source_path +): + original = load_source_catalog(ROOT) + root = copy_catalog(tmp_path, variant) + if variant == "unhandled": + modify_source( + root, + "models/alibaba/qwen3.8-max.toml", + lambda text: 'reasoning_effort = "sensitive-marker"\n' + text, + ) + elif variant == "base": + modify_source( + root, + "providers/alibaba-cn/models/qwen3.8-max.toml", + lambda text: text.replace("alibaba/qwen3.8-max", "missing/model", 1), + ) + elif variant == "upstream": + path = root / "manifest.toml" + path.write_text(path.read_text().replace(original.upstream_revision, "a" * 40)) + else: + path = root / MAPPING_PATH + path.write_text( + path.read_text().replace("mapping_version = 1", "mapping_version = 2") + ) + with pytest.raises(SourceReadError) as error: + load_source_catalog(root) + assert error.value.category == category + assert error.value.source_path == source_path + assert "sensitive-marker" not in str(error.value) + assert str(tmp_path) not in str(error.value) + assert ( + original.provider_models["alibaba-cn/qwen3.8-max"].capabilities["tools"][ + "status" + ] + == "supported" + ) + assert load_source_catalog(ROOT).revision == original.revision diff --git a/test-e2e/changes/requirements/MODEL-PROFILE-NORMALIZATION-20261013.yaml b/test-e2e/changes/requirements/MODEL-PROFILE-NORMALIZATION-20261013.yaml index b930676c70..22cb8c587c 100644 --- a/test-e2e/changes/requirements/MODEL-PROFILE-NORMALIZATION-20261013.yaml +++ b/test-e2e/changes/requirements/MODEL-PROFILE-NORMALIZATION-20261013.yaml @@ -38,6 +38,8 @@ change: sanitized source paths. Raw base-model overrides allow partial objects; effective provider-model validation requires the inherited mandatory fields and reasoning cross-field constraints. + - Explicit effort null sentinels remain declared values; catalog tree materialization + preserves array/object/null distinctions and immutable normalized snapshots. - Versioned mapping rules reject duplicate source identities, unknown transforms and incompatible versions; source capabilities preserve unknown, false and zero separately from request bindings. Each handled leaf retains mapping and source @@ -49,5 +51,6 @@ change: - test/sdk/core/model_service/test_source_inheritance.py - test/sdk/core/model_service/test_source_validation.py - test/sdk/core/model_service/test_source_mapping.py + - test/sdk/core/model_service/test_catalog_normalization.py notes: AC-01/02 subset remains pending as a whole. This change does not infer provider API behavior or modify a saved outlet. diff --git a/test/sdk/core/model_service/test_catalog_normalization.py b/test/sdk/core/model_service/test_catalog_normalization.py new file mode 100644 index 0000000000..4df9928d18 --- /dev/null +++ b/test/sdk/core/model_service/test_catalog_normalization.py @@ -0,0 +1,148 @@ +"""Isolated materialization, normalization and revision boundary rules.""" + +from pathlib import Path + +import pytest +from nexent.core.model_service.profiles._models_dev.mapping import load_mapping_rules +from nexent.core.model_service.profiles._models_dev.reader import ( + SourceDocument, + SourceReadError, + _freeze, +) +from nexent.core.model_service.profiles.resolver import ( + _controls, + _entry, + _revision, + _tree, +) + +RULES = ( + Path(__file__).parents[4] + / "sdk/nexent/core/model_service/profiles/rules/mappings/models_dev/v1.toml" +) + + +def test_canonical_tree_preserves_arrays_empty_objects_and_explicit_null(): + tree = _tree( + { + "/reference_metadata/items/0/name": "first", + "/reference_metadata/items/1/name": "second", + "/reference_metadata/empty": {}, + "/modalities/output": (), + "/reasoning/control_candidates/0/values/0": None, + } + ) + assert tree["reference_metadata"]["items"] == [ + {"name": "first"}, + {"name": "second"}, + ] + assert tree["reference_metadata"]["empty"] == {} + assert tree["modalities"]["output"] == () + assert tree["reasoning"]["control_candidates"][0]["values"] == [None] + + +def test_declared_control_domains_preserve_sentinels_and_unknown_bounds(): + assert _controls({}) is None + assert _controls({"control_candidates": []}) == [] + assert _controls( + { + "control_candidates": [ + {"type": "toggle"}, + {"type": "effort", "values": ["none", "null", "default"]}, + {"type": "budget_tokens", "min": -1, "max": 0}, + ] + } + ) == [ + {"mode": "toggle", "values": [True, False]}, + {"mode": "effort", "values": ["none", None, "default"]}, + {"mode": "budget_tokens", "min": None, "max": 0, "special_values": [-1]}, + ] + assert _controls({"control_candidates": [{"type": "budget_tokens", "min": 0}]}) == [ + {"mode": "budget_tokens", "min": 0, "max": None, "special_values": []} + ] + + +def test_revision_is_order_independent_and_length_delimited(): + assert _revision({"parser": "v1", "mapping": "v1"}) == _revision( + {"mapping": "v1", "parser": "v1"} + ) + assert _revision({"ab": "c"}) != _revision({"a": "bc"}) + assert _revision({"parser": "v1"}) != _revision({"parser": "v2"}) + + +def test_normalized_source_snapshot_is_deeply_immutable_and_not_an_outlet(): + source = SourceDocument( + "provider_model", + "vendor/model", + "providers/vendor/models/model.toml", + "a" * 64, + _freeze( + { + "reasoning": True, + "reasoning_options": [ + {"type": "toggle"}, + {"type": "budget_tokens", "min": -1, "max": 10}, + ], + } + ), + ) + provider = SourceDocument( + "provider", + "vendor", + "providers/vendor/provider.toml", + "b" * 64, + _freeze({"name": "Vendor"}), + ) + entry = _entry( + source, + source, + load_mapping_rules(RULES), + {"vendor": provider}, + "catalog-v1", + {"parser": "v1"}, + ) + assert entry.capabilities["reasoning"]["controls"][1]["special_values"] == (-1,) + assert entry.capabilities["reasoning"]["controls"][1]["min"] is None + assert ( + entry.field_origins[ + "/capabilities/reasoning/controls/1/special_values/0" + ].mapping.source.source_path + == "reasoning_options[1].min" + ) + assert ( + entry.field_origins[ + "/capabilities/reasoning/controls/0/values/0" + ].mapping.source.source_path + == "reasoning_options[0].type" + ) + with pytest.raises(TypeError): + entry.capabilities["reasoning"]["controls"][0]["values"][0] = False + assert [ref.key for ref in entry.source_refs] == ["vendor/model", "vendor"] + assert not hasattr(entry, "outlet_id") + + +@pytest.mark.parametrize( + "data,path", + [ + ({"limit": {"context": 10, "output": 20}}, "limit"), + ( + { + "reasoning": True, + "reasoning_options": [{"type": "effort", "values": []}], + }, + "reasoning_options", + ), + ], +) +def test_invalid_normalized_domains_fail_with_sanitized_diagnostics(data, path): + source = SourceDocument( + "model" if path == "limit" else "provider_model", + "lab/model", + "models/lab/model.toml", + "a" * 64, + _freeze(data), + ) + with pytest.raises(SourceReadError) as error: + _entry(source, source, load_mapping_rules(RULES), {}, "catalog-v1", {}) + assert error.value.category == "SOURCE_NORMALIZATION_INVALID" + assert error.value.source_path == path diff --git a/test/sdk/core/model_service/test_profile_contracts.py b/test/sdk/core/model_service/test_profile_contracts.py index 2aca9bae8a..4912ca72a9 100644 --- a/test/sdk/core/model_service/test_profile_contracts.py +++ b/test/sdk/core/model_service/test_profile_contracts.py @@ -210,3 +210,11 @@ def test_reasoning_budget_keeps_declared_sentinel_and_known_zero_separate(): 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_source_mapping.py b/test/sdk/core/model_service/test_source_mapping.py index 25f1e47022..941640c39c 100644 --- a/test/sdk/core/model_service/test_source_mapping.py +++ b/test/sdk/core/model_service/test_source_mapping.py @@ -198,3 +198,19 @@ def test_empty_reference_arrays_and_text_benchmark_scores_remain_reference_data( assert result.values["/reference_metadata/links"] == () assert result.values["/reference_metadata/benchmarks/0/score"] == "pass" assert result.values["/reasoning/status"] == "unknown" + + +def test_scalar_target_cannot_own_a_descendant_field(tmp_path): + path = tmp_path / "mapping.toml" + path.write_text( + RULE_PATH.read_text().replace( + 'target_path = "/identity/name"', 'target_path = "/identity"', 1 + ) + ) + with pytest.raises(SourceReadError, match="SOURCE_TARGET_CONFLICT"): + load_mapping_rules(path) + + +def test_missing_rule_file_is_an_explicit_mapping_error(tmp_path): + with pytest.raises(SourceReadError, match="SOURCE_MAPPING_INVALID"): + load_mapping_rules(tmp_path / "missing.toml")