diff --git a/ouroboros/llm_local.py b/ouroboros/llm_local.py index f70b5cead..e64285063 100644 --- a/ouroboros/llm_local.py +++ b/ouroboros/llm_local.py @@ -267,8 +267,19 @@ class _LocalLaneMixin: """ from ouroboros.local_model import get_manager - target["local_input_measurement"] = get_manager().measure_prepared_input(payload) - return _finalized_physical_candidate(target, payload, "chat.completions") + manager = get_manager() + measure = getattr(manager, "measure_prepared_input", None) + # Bind the provider-clean physical payload before measuring. Host-only + # metadata must not affect the measured input or candidate hash. + candidate = _finalized_physical_candidate(target, payload, "chat.completions") + if callable(measure): + evidence = measure(candidate) + if isinstance(evidence, dict) and evidence.get("supported"): + target["local_input_measurement"] = evidence + # Rebuild from the original prepared source so the exact + # measured input and output cap are sealed together. + candidate = _finalized_physical_candidate(target, payload, "chat.completions") + return candidate def _chat_local( self, messages: List[Dict[str, Any]], tools: Optional[List[Dict[str, Any]]], diff --git a/tests/test_local_input_measurement.py b/tests/test_local_input_measurement.py index f5bb9248b..be9caf3ac 100644 --- a/tests/test_local_input_measurement.py +++ b/tests/test_local_input_measurement.py @@ -1,5 +1,6 @@ """The owned server counts with its actual formatter and never invokes inference.""" import asyncio +import copy import os from types import SimpleNamespace @@ -40,6 +41,42 @@ def test_selected_formatter_tokenizer_and_serving_capacity_are_used(native_model assert "actual rendered template" not in str(result) +def test_measurement_fingerprint_is_after_host_metadata_scrubbing(native_model): + payload = {"model": "local-model", "messages": [ + {"role": "user", "content": "exact", "_context_capsule": "host-only"}, + ], "tools": []} + clean = {**payload, "messages": [{"role": "user", "content": "exact"}], "tools": []} + result = measure_chat_input(native_model, clean) + assert result["native_input_sha256"] == input_fingerprint(clean) + assert result["native_input_sha256"] != input_fingerprint(payload) + + +def test_finalize_measures_the_provider_clean_candidate_not_the_caller_payload(monkeypatch): + from ouroboros import llm_local + + client = object.__new__(llm_local._LocalLaneMixin) + payload = {"model": "local-model", "messages": [ + {"role": "user", "content": "exact", "_context_capsule": "host-only"}, + ], "max_tokens": 256} + clean = {"model": "local-model", "messages": [{"role": "user", "content": "exact"}], "max_tokens": 256} + seen = [] + + monkeypatch.setattr(llm_local, "_finalized_physical_candidate", lambda target, value, surface: clean) + monkeypatch.setattr("ouroboros.local_model.get_manager", lambda: SimpleNamespace( + measure_prepared_input=lambda value: seen.append(copy.deepcopy(value)) or { + "supported": True, "input_is_exact": True, "input_tokens": 8, + "context_window": 81920, "process_id": 1, + "native_input_sha256": input_fingerprint(value), + "output_limit_enforced": True, + "reasoning_included_in_limit": True, + "tokenizer_template_provenance": {"source": "fixture"}, + })) + target = {"provider": "local", "resolved_model": "local-model", "usage_model": "local-model"} + assert client._finalize_local_candidate(target, payload) == clean + assert seen == [clean] + assert target["local_input_measurement"]["native_input_sha256"] == input_fingerprint(clean) + + def test_unrecognized_handler_is_not_called_to_guess_its_behavior(native_model): native_model.chat_handler = lambda **kwargs: pytest.fail("Opaque handler was invoked") result = measure_chat_input(native_model, {"messages": []})