Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,24 @@ The `ChatCompletionInferenceParams` class controls how models generate text comp
| `temperature` | `float` or `Distribution` | No | Controls randomness in generation (0.0 to 2.0). Higher values = more creative/random |
| `top_p` | `float` or `Distribution` | No | Nucleus sampling parameter (0.0 to 1.0). Controls diversity by filtering low-probability tokens |
| `max_tokens` | `int` | No | Maximum number of tokens to generate in the response (β‰₯ 1) |
| `presence_penalty` | `float` | No | Penalizes tokens that already appeared in the output (-2.0 to 2.0) |
| `top_k` | `int` | No | Samples only from the k most likely tokens (β‰₯ 1) |
| `min_p` | `float` | No | Drops tokens below this probability, relative to the most likely token (0.0 to 1.0) |
| `repetition_penalty` | `float` | No | Multiplicative penalty for repeated tokens (> 0; 1.0 means no penalty) |
| `max_parallel_requests` | `int` | No | Maximum concurrent API requests to this model (default: 4, β‰₯ 1). See [Concurrency Control](#concurrency-control) below. |
| `timeout` | `int` | No | API request timeout in seconds (β‰₯ 1) |
| `extra_body` | `dict[str, Any]` | No | Additional parameters to include in the API request body |

<Note>
Default Values
If `temperature`, `top_p`, or `max_tokens` are not provided, the model provider's default values will be used. Different providers and models may have different defaults.
If `temperature`, `top_p`, `max_tokens`, or any of the sampling parameters above are not provided, the model provider's default values will be used. Different providers and models may have different defaults.
</Note>

<Note>
Provider Support for Sampling Parameters
`top_k`, `min_p`, and `repetition_penalty` are not [OpenAI Chat Completions](https://platform.openai.com/docs/api-reference/chat/create) parameters. They are sent as top-level request fields, so the endpoint must accept them. [vLLM](https://github.com/vllm-project/vllm/blob/main/vllm/sampling_params.py) and [SGLang](https://github.com/sgl-project/sglang/blob/main/python/sglang/srt/sampling/sampling_params.py) accept all three; SGLang also caps `repetition_penalty` at 2.0. A key with the same name in `extra_body` overrides the field.

For Anthropic providers, only `top_k` is sent. The [Anthropic Messages API](https://docs.anthropic.com/en/api/messages) has no `presence_penalty`, `min_p`, or `repetition_penalty`, so the Anthropic adapter drops them.
</Note>

<Tip>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@
)
_TOOL_CONFIG_OPTIONAL_COLLECTIONS: frozenset[str] = frozenset({"allow_tools"})

# Optional sampling params added without a hash version bump. Dropped while unset,
# so configs that don't use them keep their stored hash and can still resume.
_INFERENCE_OPTIONAL_SCALARS: frozenset[str] = frozenset({"presence_penalty", "top_k", "min_p", "repetition_penalty"})


# ---------------------------------------------------------------------------
# Public API
Expand All @@ -82,7 +86,8 @@ def fingerprint_config(config: DataDesignerConfig) -> dict[str, str | int]:
* `columns` - names, types, generator params, processors, validators,
skip/drop flags. Column order is part of identity (DAG ordering).
* `model_configs` - alias, model, provider, sampling-relevant inference
params (temperature, top_p, max_tokens, extra_body). Sorted by alias.
params (temperature, top_p, max_tokens, presence_penalty, top_k, min_p,
repetition_penalty, extra_body). Sorted by alias.
* `tool_configs` - alias, providers, allow_tools, max_tool_call_turns
(the set of MCP tools shapes generation). Sorted by tool_alias.
* `seed_config` - source path, sampling strategy, selection strategy.
Expand Down Expand Up @@ -133,7 +138,8 @@ def _drop_empty_optional(source: dict[str, Any], keys: Iterable[str]) -> dict[st
"""Drop keys whose value is `None` or an empty list.

`None` and `[]` are user-equivalent for optional collection fields; this
collapses both to "absent" before hashing.
collapses both to "absent" before hashing. For optional scalars only the
`None` case applies.
"""
keyset = set(keys)
return {k: v for k, v in source.items() if not (k in keyset and (v is None or v == []))}
Expand All @@ -143,7 +149,9 @@ def _normalize_model_config(model_config: dict[str, Any]) -> dict[str, Any]:
normalized = _drop_keys(model_config, _EXCLUDED_MODEL_KEYS)
inference_params = normalized.get("inference_parameters")
if isinstance(inference_params, dict):
normalized["inference_parameters"] = _drop_keys(inference_params, _EXCLUDED_INFERENCE_KEYS)
normalized["inference_parameters"] = _drop_empty_optional(
_drop_keys(inference_params, _EXCLUDED_INFERENCE_KEYS), _INFERENCE_OPTIONAL_SCALARS
)
return normalized


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -497,12 +497,20 @@ class ChatCompletionInferenceParams(BaseInferenceParams):
temperature: Sampling temperature (0.0-2.0). Can be a fixed value or a distribution for dynamic sampling.
top_p: Nucleus sampling probability (0.0-1.0). Can be a fixed value or a distribution for dynamic sampling.
max_tokens: Maximum number of tokens to generate in the response.
presence_penalty: Penalty (-2.0 to 2.0) for tokens that already appeared in the output.
top_k: Sample only from the k most likely tokens.
min_p: Minimum token probability (0.0-1.0), relative to the most likely token.
repetition_penalty: Multiplicative penalty (> 0) for repeated tokens; 1.0 means none.
"""

generation_type: Literal[GenerationType.CHAT_COMPLETION] = GenerationType.CHAT_COMPLETION
temperature: float | DistributionT | None = None
top_p: float | DistributionT | None = None
max_tokens: int | None = Field(default=None, ge=1)
presence_penalty: float | None = Field(default=None, ge=-2.0, le=2.0)
top_k: int | None = Field(default=None, ge=1)
min_p: float | None = Field(default=None, ge=0.0, le=1.0)
repetition_penalty: float | None = Field(default=None, gt=0.0)

@property
def generate_kwargs(self) -> dict[str, Any]:
Expand All @@ -515,6 +523,9 @@ def generate_kwargs(self) -> dict[str, Any]:
result["top_p"] = self.top_p.sample() if hasattr(self.top_p, "sample") else self.top_p
if self.max_tokens is not None:
result["max_tokens"] = self.max_tokens
for key in ("presence_penalty", "top_k", "min_p", "repetition_penalty"):
if (value := getattr(self, key)) is not None:
result[key] = value
return result

@model_validator(mode="after")
Expand Down
31 changes: 31 additions & 0 deletions packages/data-designer-config/tests/config/test_fingerprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,37 @@ def test_changing_temperature_changes_hash() -> None:
assert _compute_hash(a) != _compute_hash(b)


@pytest.mark.parametrize(
"sampling_param",
[{"presence_penalty": 0.5}, {"top_k": 40}, {"min_p": 0.05}, {"repetition_penalty": 1.1}],
ids=["presence_penalty", "top_k", "min_p", "repetition_penalty"],
)
def test_changing_sampling_param_changes_hash(sampling_param: dict[str, float]) -> None:
a = _make_minimal_config()
b = _make_minimal_config(
model_configs=[
ModelConfig(
alias="m",
model="some-model",
provider="some-provider",
inference_parameters=ChatCompletionInferenceParams(
temperature=0.5, top_p=0.9, max_tokens=128, **sampling_param
),
)
],
)
assert _compute_hash(a) != _compute_hash(b)


def test_unset_sampling_params_keep_existing_hash() -> None:
"""Configs that leave the optional sampling params unset must keep their stored hash so resume still works.

The literal was computed from this same config before those fields existed; only a literal catches drift.
"""
expected = "sha256:b21a98e06ea3c84c8fac9fe5231e68b0bfcffe03283952b03e807baba17139dc"
assert _compute_hash(_make_minimal_config()) == expected


def test_changing_column_order_changes_hash() -> None:
"""Column order is part of identity (DAG ordering)."""
cols_a = [
Expand Down
50 changes: 50 additions & 0 deletions packages/data-designer-config/tests/config/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,54 @@ def test_inference_parameters_generate_kwargs():
assert inference_parameters_kwargs["top_p"] is not None


@pytest.mark.parametrize(
("params", "expected"),
[
({"presence_penalty": 0.5}, {"presence_penalty": 0.5}),
({"presence_penalty": -2.0}, {"presence_penalty": -2.0}),
(
{"top_k": 50, "min_p": 0.1, "repetition_penalty": 1.1},
{"top_k": 50, "min_p": 0.1, "repetition_penalty": 1.1},
),
({"top_k": 1, "min_p": 0.0}, {"top_k": 1, "min_p": 0.0}),
(
{"top_k": 50, "extra_body": {"reasoning_effort": "high"}},
{"top_k": 50, "extra_body": {"reasoning_effort": "high"}},
),
],
ids=[
"presence-penalty",
"presence-penalty-lower-bound",
"non-openai-params",
"falsy-but-set-values-sent",
"extra-body-kept-separate",
],
)
def test_inference_parameters_routes_sampling_params(params: dict, expected: dict) -> None:
caller_extra_body = params.get("extra_body")
caller_extra_body_before = dict(caller_extra_body) if caller_extra_body is not None else None

assert ChatCompletionInferenceParams(**params).generate_kwargs == expected
assert caller_extra_body == caller_extra_body_before


@pytest.mark.parametrize(
"params",
[
{"presence_penalty": 2.1},
{"presence_penalty": -2.1},
{"top_k": 0},
{"min_p": -0.1},
{"min_p": 1.1},
{"repetition_penalty": 0},
{"repetition_penalty": -1},
],
)
def test_inference_parameters_rejects_out_of_range_sampling_params(params: dict) -> None:
with pytest.raises(ValidationError):
ChatCompletionInferenceParams(**params)


def test_uniform_distribution_low_lt_high_validation():
with pytest.raises(ValueError, match="`low` must be less than `high`"):
UniformDistribution(params=UniformDistributionParams(low=0.8, high=0.8))
Expand Down Expand Up @@ -729,6 +777,7 @@ def test_chat_completion_params_format_for_display_all_params():
max_tokens=2048,
max_parallel_requests=4,
timeout=60,
top_k=40,
)
result = params.format_for_display()
assert "generation_type=chat-completion" in result
Expand All @@ -737,6 +786,7 @@ def test_chat_completion_params_format_for_display_all_params():
assert "max_tokens=2048" in result
assert "max_parallel_requests=4" in result
assert "timeout=60" in result
assert "top_k=40" in result


def test_chat_completion_params_format_for_display_partial_params():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ class AnthropicClient(HttpModelClient):
"response_format",
"frequency_penalty",
"presence_penalty",
"min_p",
"repetition_penalty",
"seed",
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ class ChatCompletionRequest:
response_format: dict[str, Any] | None = None
frequency_penalty: float | None = None
presence_penalty: float | None = None
top_k: int | None = None
min_p: float | None = None
repetition_penalty: float | None = None
timeout: float | None = None
extra_body: dict[str, Any] | None = None
extra_headers: dict[str, str] | None = None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ def _build_generation_validation_error(
"response_format",
"frequency_penalty",
"presence_penalty",
"top_k",
"min_p",
"repetition_penalty",
"timeout",
"tools",
"extra_body",
Expand Down
133 changes: 133 additions & 0 deletions packages/data-designer-engine/tests/engine/models/test_facade.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@

import pytest

from data_designer.config.models import ChatCompletionInferenceParams
from data_designer.engine.mcp.errors import MCPConfigurationError, MCPToolError
from data_designer.engine.models.clients.adapters.anthropic import AnthropicClient
from data_designer.engine.models.clients.adapters.http_model_client import ClientConcurrencyMode
from data_designer.engine.models.clients.adapters.openai_compatible import OpenAICompatibleClient
from data_designer.engine.models.clients.errors import ProviderError, ProviderErrorKind
from data_designer.engine.models.clients.types import (
AssistantMessage,
Expand All @@ -32,6 +36,7 @@
from data_designer.engine.models.usage_events import TokenUsageEvent, subscribe_token_usage
from data_designer.engine.models.utils import ChatMessage
from data_designer.engine.testing import StubMCPFacade, StubMCPRegistry, make_stub_completion_response
from tests.engine.models.clients.conftest import make_mock_async_client, make_mock_sync_client


def _make_response(
Expand Down Expand Up @@ -223,6 +228,134 @@ def test_generate_drops_configured_extra_body_n_from_single_result_request(
)


_OPENAI_TEXT_RESPONSE = {
"choices": [{"index": 0, "message": {"role": "assistant", "content": "Hello!"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
}
_ANTHROPIC_TEXT_RESPONSE = {
"content": [{"type": "text", "text": "Hello!"}],
"usage": {"input_tokens": 10, "output_tokens": 5},
"stop_reason": "end_turn",
}


_SAMPLING_PARAM_FIELDS = ("client_cls", "response_json", "provider_extra_body", "call_extra_body", "expected", "absent")
_ALL_SAMPLING_PARAMS = {"presence_penalty": 0.5, "top_k": 40, "min_p": 0.05, "repetition_penalty": 1.1}
_SAMPLING_PARAM_CASES = [
(OpenAICompatibleClient, _OPENAI_TEXT_RESPONSE, None, None, _ALL_SAMPLING_PARAMS, {"extra_body"}),
(
OpenAICompatibleClient,
_OPENAI_TEXT_RESPONSE,
{"top_k": 20, "presence_penalty": 0.1},
None,
{"presence_penalty": 0.1, "top_k": 20, "min_p": 0.05, "repetition_penalty": 1.1},
{"extra_body"},
),
(
# A per-call extra_body replaces the configured one; the typed params must survive it.
OpenAICompatibleClient,
_OPENAI_TEXT_RESPONSE,
None,
{"reasoning_effort": "high"},
{**_ALL_SAMPLING_PARAMS, "reasoning_effort": "high"},
{"extra_body"},
),
(
# Messages API has top_k but no presence_penalty, min_p or repetition_penalty.
AnthropicClient,
_ANTHROPIC_TEXT_RESPONSE,
None,
None,
{"top_k": 40},
{"presence_penalty", "min_p", "repetition_penalty", "extra_body"},
),
]
_SAMPLING_PARAM_IDS = [
"openai-compatible",
"provider-extra-body-wins",
"per-call-extra-body-keeps-params",
"anthropic-sends-only-top-k",
]


def _make_sampling_params_facade(
stub_model_configs: list[Any],
stub_model_provider_registry: Any,
client_cls: type[OpenAICompatibleClient] | type[AnthropicClient],
http_client: MagicMock,
provider_extra_body: dict[str, Any] | None,
*,
is_async: bool = False,
) -> ModelFacade:
"""Facade over a mocked HTTP transport, with all four sampling params set."""
model_config = stub_model_configs[0]
model_config.inference_parameters = ChatCompletionInferenceParams(**_ALL_SAMPLING_PARAMS)
client = client_cls(
provider_name="stub-model-provider",
endpoint="https://api.example.com/v1",
api_key="sk-test",
concurrency_mode=ClientConcurrencyMode.ASYNC if is_async else ClientConcurrencyMode.SYNC,
sync_client=None if is_async else http_client,
async_client=http_client if is_async else None,
)
facade = ModelFacade(model_config, stub_model_provider_registry, client=client)
facade.model_provider.extra_body = provider_extra_body
return facade


def _call_kwargs(call_extra_body: dict[str, Any] | None) -> dict[str, Any]:
return {} if call_extra_body is None else {"extra_body": call_extra_body}


def _assert_sampling_params_payload(http_client: MagicMock, expected: dict[str, Any], absent: set[str]) -> None:
payload = http_client.post.call_args.kwargs["json"]
assert {key: payload.get(key) for key in expected} == expected
assert absent.isdisjoint(payload)


@pytest.mark.parametrize(_SAMPLING_PARAM_FIELDS, _SAMPLING_PARAM_CASES, ids=_SAMPLING_PARAM_IDS)
def test_generate_sends_sampling_params_in_body(
stub_model_configs: list[Any],
stub_model_provider_registry: Any,
client_cls: type[OpenAICompatibleClient] | type[AnthropicClient],
response_json: dict[str, Any],
provider_extra_body: dict[str, Any] | None,
call_extra_body: dict[str, Any] | None,
expected: dict[str, Any],
absent: set[str],
) -> None:
http_client = make_mock_sync_client(response_json)
facade = _make_sampling_params_facade(
stub_model_configs, stub_model_provider_registry, client_cls, http_client, provider_extra_body
)

facade.generate(prompt="does not matter", parser=lambda x: x, **_call_kwargs(call_extra_body))

_assert_sampling_params_payload(http_client, expected, absent)


@pytest.mark.asyncio
@pytest.mark.parametrize(_SAMPLING_PARAM_FIELDS, _SAMPLING_PARAM_CASES, ids=_SAMPLING_PARAM_IDS)
async def test_agenerate_sends_sampling_params_in_body(
stub_model_configs: list[Any],
stub_model_provider_registry: Any,
client_cls: type[OpenAICompatibleClient] | type[AnthropicClient],
response_json: dict[str, Any],
provider_extra_body: dict[str, Any] | None,
call_extra_body: dict[str, Any] | None,
expected: dict[str, Any],
absent: set[str],
) -> None:
http_client = make_mock_async_client(response_json)
facade = _make_sampling_params_facade(
stub_model_configs, stub_model_provider_registry, client_cls, http_client, provider_extra_body, is_async=True
)

await facade.agenerate(prompt="does not matter", parser=lambda x: x, **_call_kwargs(call_extra_body))

_assert_sampling_params_payload(http_client, expected, absent)


@pytest.mark.asyncio
async def test_agenerate_drops_n_from_single_result_request(
stub_model_facade: ModelFacade,
Expand Down
Loading