Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@ dependencies = [
# Nemotron-Personas locale support: the ``NATIVE`` mode is what lets
# expression columns use the standard Jinja ``select`` / ``join``
# filters (the older ``SECURE`` mode ships a narrow filter allowlist
# that excludes both). See ``cli/_pipeline.py`` for the opt-in. 0.9.4 is
# the first release that supports pandas 3 and allows cryptography 50.
"data-designer>=0.9.4,<0.10",
# that excludes both). See ``cli/_pipeline.py`` for the opt-in. 0.9.5 is
# the first release that forwards ``tool_choice`` and merges a call's
# ``extra_body`` over the model's configured one, which
# ``engine/core/llm.py`` relies on.
"data-designer>=0.9.5,<0.10",
# Language detection for the language_compliance scorer. Pure Python,
# MIT, accurate on short conversational text. Pinned to a major version
# to keep CI deterministic -- bump deliberately.
Expand Down Expand Up @@ -126,7 +128,7 @@ trajectory-evaluator = "usersim.engine.plugin:trajectory_evaluator"
[tool.uv]
override-dependencies = [
# Keep in sync with the ``dependencies`` pin above.
"data-designer>=0.9.4,<0.10",
"data-designer>=0.9.5,<0.10",
]

[build-system]
Expand Down
13 changes: 7 additions & 6 deletions src/usersim/engine/core/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,11 +544,12 @@ def _configured_extra_body(facade: Any) -> dict[str, Any]:
def _request_kwargs(facade: Any, alias: str, kwargs: dict[str, Any]) -> dict[str, Any]:
"""``kwargs`` in the form Data Designer's facade passes on to the provider.

The facade drops arguments outside its request fields, and an ``extra_body``
given with a call replaces the model's configured one instead of merging
with it. So the reasoning settings travel inside ``extra_body``, on top of
the model's own, and each only where the model's config already sets it: a
model whose provider rejects one of them is never sent it.
The facade drops arguments outside its request fields, so the reasoning
settings travel inside ``extra_body``, which the facade merges key by key
over the model's configured one. Each is sent only where the model's config
already sets it: a model whose provider rejects one of them is never sent
it. The facade replaces a nested value rather than merging it, so a
dict-valued setting is merged with the configured value here.
``max_completion_tokens`` only appears on the retry its provider asked for,
so it is always sent. Any other facade, such as a hosted run's, gets
``kwargs`` unchanged.
Expand All @@ -559,7 +560,7 @@ def _request_kwargs(facade: Any, alias: str, kwargs: dict[str, Any]) -> dict[str
if not moved:
return kwargs
configured = _configured_extra_body(facade)
body = {**configured, **(kwargs.get("extra_body") or {})}
body = dict(kwargs.get("extra_body") or {})
for key in _EXTRA_BODY_SETTINGS:
if key not in kwargs:
continue
Expand Down
58 changes: 51 additions & 7 deletions tests/engine/core/test_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@
import ast
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from data_designer.config.models import ChatCompletionInferenceParams, ModelConfig, ModelProvider
from data_designer.engine.model_provider import resolve_model_provider_registry
from data_designer.engine.models.clients.types import TransportKwargs
from data_designer.engine.models.facade import _COMPLETION_REQUEST_FIELDS, ModelFacade
from data_designer.engine.models.factory import create_model_registry
from data_designer.engine.secret_resolver import PlaintextResolver

import usersim
from usersim.engine.core import llm, realized_audit
Expand Down Expand Up @@ -676,11 +681,13 @@ class TestSettingsDataDesignerWouldDrop:
``extra_body``."""

async def test_a_setting_the_model_already_uses_is_sent_over_its_config(self) -> None:
"""The call carries only the setting; the facade merges it over the rest
of the configured ``extra_body``."""
facade = _data_designer_facade({"reasoning_effort": "high", "top_k": 20})
await acall_llm({"judge_model": facade}, "judge_model", _HI, reasoning_effort="low")
(call,) = facade.calls
assert "reasoning_effort" not in call
assert call["extra_body"] == {"reasoning_effort": "low", "top_k": 20}
assert call["extra_body"] == {"reasoning_effort": "low"}

async def test_a_setting_the_model_does_not_use_is_not_sent(self) -> None:
"""A provider can reject a setting outright, and the model config is the
Expand Down Expand Up @@ -719,10 +726,14 @@ async def acompletion(self, messages, **kwargs):

def test_data_designer_builds_the_settings_into_the_request_body(self) -> None:
"""Through Data Designer's own request building, so an upstream change
to either step fails here instead of silently dropping the setting."""
configured = {"reasoning_effort": "high"}
to either step fails here instead of silently dropping the setting, the
rest of the configured ``extra_body``, or ``tool_choice``."""
configured = {"reasoning_effort": "high", "top_k": 20}
tools = [{"type": "function", "function": {"name": "commit_move", "parameters": {"type": "object"}}}]
kwargs = llm._request_kwargs(
_data_designer_facade(configured), "judge_model", {"reasoning_effort": "low", "max_tokens": 100}
_data_designer_facade(configured),
"judge_model",
{"reasoning_effort": "low", "max_tokens": 100, "tools": tools, "tool_choice": "auto"},
)
owner = SimpleNamespace(
_model_config=SimpleNamespace(
Expand All @@ -735,7 +746,9 @@ def test_data_designer_builds_the_settings_into_the_request_body(self) -> None:
request = ModelFacade._build_chat_completion_request(owner, _HI, consolidated)
body = TransportKwargs.from_request(request).body
assert body["reasoning_effort"] == "low"
assert body["top_k"] == 20
assert body["max_tokens"] == 100
assert body["tool_choice"] == "auto"

def test_data_designer_still_drops_the_settings_routed_around_it(self) -> None:
"""If Data Designer starts forwarding one of these itself, it can be
Expand All @@ -744,6 +757,40 @@ def test_data_designer_still_drops_the_settings_routed_around_it(self) -> None:
assert not forwarded, f"Data Designer now forwards {sorted(forwarded)}; stop routing them through extra_body."


def _facade_built_by_data_designer(**inference_parameters: Any) -> ModelFacade:
"""A facade from Data Designer's own registry. Nothing is sent until a call."""
model = ModelConfig(
alias="judge_model",
model="vendor/model",
provider="local",
inference_parameters=ChatCompletionInferenceParams(**inference_parameters),
)
registry = create_model_registry(
model_configs=[model],
secret_resolver=PlaintextResolver(),
model_provider_registry=resolve_model_provider_registry(
[ModelProvider(name="local", endpoint="http://127.0.0.1:9/v1")]
),
)
return registry.get_model(model_alias="judge_model")


class TestSettingsReadFromAFacadeDataDesignerBuilds:
"""``_model_config`` is private to Data Designer, and a failed read of it
sends nothing rather than raising. These use a facade Data Designer builds,
not a stand-in, so a change to that attribute fails here."""

def test_a_reasoning_setting_the_model_config_sets_is_sent(self) -> None:
facade = _facade_built_by_data_designer(extra_body={"reasoning_effort": "high"})
assert llm._request_kwargs(facade, "judge_model", {"reasoning_effort": "low"}) == {
"extra_body": {"reasoning_effort": "low"}
}

def test_the_configured_token_budget_is_scaled(self) -> None:
facade = _facade_built_by_data_designer(max_tokens=1000)
assert llm.scaled_max_tokens(facade, 2.0) == {"max_tokens": 2000}


def test_every_argument_passed_to_a_model_can_reach_it() -> None:
"""Data Designer drops keyword arguments outside its request fields without
an error, so an argument added at a call site has to be one it forwards or
Expand All @@ -760,9 +807,6 @@ def test_every_argument_passed_to_a_model_can_reach_it() -> None:
for keyword in node.keywords:
if keyword.arg is None or keyword.arg in reaches:
continue
# The default whenever tools are passed, so dropping it changes nothing.
if keyword.arg == "tool_choice" and getattr(keyword.value, "value", None) == "auto":
continue
offenders.append(f"{path.relative_to(root)}:{node.lineno} {keyword.arg}")
assert not offenders, f"these arguments never reach the model: {offenders}"

Expand Down
Loading
Loading