diff --git a/fern/versions/latest/pages/concepts/models/model-configs.mdx b/fern/versions/latest/pages/concepts/models/model-configs.mdx index 937e36efd..e4859830d 100644 --- a/fern/versions/latest/pages/concepts/models/model-configs.mdx +++ b/fern/versions/latest/pages/concepts/models/model-configs.mdx @@ -21,6 +21,7 @@ The `ModelConfig` class has the following fields: | `model` | `str` | Yes | Model identifier as recognized by the provider (e.g., `"nvidia/nemotron-3.5-lightning-30b-a3b"`, `"gpt-4"`) | | `inference_parameters` | `InferenceParamsT` | No | Controls model behavior during generation. Use `ChatCompletionInferenceParams` for text/code/structured generation or `EmbeddingInferenceParams` for embeddings. Defaults to `ChatCompletionInferenceParams()` if not provided. The generation type is automatically determined by the inference parameters type. See [Inference Parameters](/concepts/models/inference-parameters) for details. | | `provider` | `str` | Yes | Reference to the name of the Provider to use (e.g., `"nvidia"`, `"openai"`, `"openrouter"`). | +| `api` | `Literal["chat-completions", "responses"]` | No | API used by generation and MCP loops. Defaults to `"chat-completions"`. See [Responses API](/concepts/models/model-providers#openai-responses-api). | | `skip_health_check` | `bool` | No | Whether to skip the health check for this model. Defaults to `False`. Set to `True` to skip health checks when you know the model is accessible or want to defer validation. | diff --git a/fern/versions/latest/pages/concepts/models/model-providers.mdx b/fern/versions/latest/pages/concepts/models/model-providers.mdx index 7cfecabfd..6e6a56e12 100644 --- a/fern/versions/latest/pages/concepts/models/model-providers.mdx +++ b/fern/versions/latest/pages/concepts/models/model-providers.mdx @@ -35,6 +35,75 @@ Most self-hosted and third-party endpoints expose an OpenAI-compatible API, so ` > **Note:** Previous versions of Data Designer supported additional provider types (e.g., `"azure"`, `"bedrock"`, `"vertex_ai"`) via a LiteLLM bridge. These are no longer supported. If you were using one of these types, switch to `provider_type="openai"` and point the `endpoint` to an OpenAI-compatible proxy or gateway for that service. +## OpenAI Responses API + +Keep `provider_type="openai"` and set `ModelConfig.api="responses"` to route +normal generation and MCP calls to `/responses`. The default is `"chat-completions"`. +Models can share the same provider while selecting different APIs. + +```python +from data_designer.config.models import ChatCompletionInferenceParams, ModelConfig, ModelProvider + +provider = ModelProvider( + name="openai", + provider_type="openai", + endpoint="https://api.openai.com/v1", + api_key="OPENAI_API_KEY", +) +model = ModelConfig( + alias="reasoning", + model="gpt-5.6-luna", + provider="openai", + api="responses", + inference_parameters=ChatCompletionInferenceParams( + max_tokens=4096, + extra_body={"reasoning": {"effort": "medium"}}, + ), +) +``` + +Use this provider and model in your `DataDesigner` and `DataDesignerConfigBuilder` +configuration. Text, code, judge, image-context, and MCP columns use the selected +route in both sync and async generation. The `api` option applies only to +chat-completion inference parameters. + +`max_tokens` maps to `max_output_tokens`. `reasoning_effort` in `extra_body` also +maps to `reasoning.effort`; specify only one form. Structured outputs map to +`text.format`. Model-specific sampling restrictions still apply. + +Requests use `store=false` and include `reasoning.encrypted_content`. DD replays +ordered output items, including encrypted reasoning and function calls, on every +MCP round. Direct `ModelFacade.responses()` / `aresponses()` callers can carry +`response.message.replay_data` into the next assistant `ChatMessage` using its +`replay_data` field. Keep the original conversation and tool results in order, +following [OpenAI conversation-state guidance](https://developers.openai.com/api/docs/guides/conversation-state). + +`responses()` / `aresponses()` explicitly select the provider's conversation API, +regardless of the model's `api` setting. `completion()` / `acompletion()` retain +the existing API. For Anthropic, the conversation API is its native Messages API +and replay data preserves thinking blocks and signatures. + +```python +from data_designer.engine.models.utils import ChatMessage + +# model is a ModelFacade returned by DataDesigner.get_models(). +history = [ChatMessage.as_user("First question")] +response = await model.aresponses(history) +history.append(ChatMessage.as_assistant( + content=response.message.content or "", + replay_data=response.message.replay_data, +)) +history.append(ChatMessage.as_user("Follow-up question")) +response = await model.aresponses(history) +``` + +The adapter rejects streaming, background execution, server-stored conversations, +multiple choices, stop sequences, seed, frequency/presence penalties, and hosted +tools. It supports DD-managed function tools. Configure an Azure OpenAI v1 base +endpoint and its required authentication headers explicitly; legacy deployment +URLs, API-version query parameters, and automatic Azure credential refresh are +not handled by this adapter. + ## API Key Configuration The `api_key` field can be specified in two ways: diff --git a/packages/data-designer-config/src/data_designer/config/fingerprint.py b/packages/data-designer-config/src/data_designer/config/fingerprint.py index 6a6a641cb..b73672270 100644 --- a/packages/data-designer-config/src/data_designer/config/fingerprint.py +++ b/packages/data-designer-config/src/data_designer/config/fingerprint.py @@ -143,6 +143,8 @@ def _drop_empty_optional(source: dict[str, Any], keys: Iterable[str]) -> dict[st def _normalize_model_config(model_config: dict[str, Any]) -> dict[str, Any]: normalized = _drop_keys(model_config, _EXCLUDED_MODEL_KEYS) + if normalized.get("api") == "chat-completions": + normalized.pop("api") # Preserve fingerprints from before API selection was added. inference_params = normalized.get("inference_parameters") if isinstance(inference_params, dict): normalized["inference_parameters"] = _drop_keys(inference_params, _EXCLUDED_INFERENCE_KEYS) diff --git a/packages/data-designer-config/src/data_designer/config/models.py b/packages/data-designer-config/src/data_designer/config/models.py index eb3d2f1ec..457cb50ef 100644 --- a/packages/data-designer-config/src/data_designer/config/models.py +++ b/packages/data-designer-config/src/data_designer/config/models.py @@ -645,6 +645,7 @@ class ModelConfig(ConfigBase): The generation_type is determined by the type of inference_parameters. provider: Name of the model provider. Must match the ``name`` field of a ``ModelProvider`` registered with the surrounding ``DataDesigner`` instance. + api: API used by generation: "chat-completions" (default) or "responses". skip_health_check: Whether to skip the health check for this model. Defaults to False. """ @@ -653,6 +654,13 @@ class ModelConfig(ConfigBase): inference_parameters: InferenceParamsT = Field(default_factory=ChatCompletionInferenceParams) provider: str skip_health_check: bool = False + api: Literal["chat-completions", "responses"] = "chat-completions" + + @model_validator(mode="after") + def validate_api_generation_type(self) -> Self: + if self.api == "responses" and self.generation_type != GenerationType.CHAT_COMPLETION: + raise ValueError("api=responses requires chat-completion inference parameters.") + return self @property def generation_type(self) -> GenerationType: diff --git a/packages/data-designer-config/tests/config/test_fingerprint.py b/packages/data-designer-config/tests/config/test_fingerprint.py index 91ce78023..1121d08e2 100644 --- a/packages/data-designer-config/tests/config/test_fingerprint.py +++ b/packages/data-designer-config/tests/config/test_fingerprint.py @@ -151,6 +151,20 @@ def test_changing_model_identity_changes_hash() -> None: assert _compute_hash(a) != _compute_hash(b) +def test_default_model_api_preserves_historical_hash() -> None: + assert _compute_hash(_make_minimal_config()) == ( + "sha256:b21a98e06ea3c84c8fac9fe5231e68b0bfcffe03283952b03e807baba17139dc" + ) + + +def test_changing_model_api_changes_hash() -> None: + model = _make_model() + responses = model.model_copy(update={"api": "responses"}) + assert _compute_hash(_make_minimal_config(model_configs=[model])) != _compute_hash( + _make_minimal_config(model_configs=[responses]) + ) + + def test_changing_temperature_changes_hash() -> None: a = _make_minimal_config() b = _make_minimal_config( diff --git a/packages/data-designer-config/tests/config/test_models.py b/packages/data-designer-config/tests/config/test_models.py index 2083229b4..aa727d97c 100644 --- a/packages/data-designer-config/tests/config/test_models.py +++ b/packages/data-designer-config/tests/config/test_models.py @@ -837,3 +837,9 @@ def test_get_formatted_params_minimal(): # Should have generation_type and max_parallel_requests (defaults) assert any("generation_type=chat-completion" in part for part in result) assert any("max_parallel_requests=4" in part for part in result) + + +@pytest.mark.parametrize("params", [{"generation_type": "embedding"}, {"generation_type": "image"}]) +def test_responses_requires_chat_inference_parameters(params: dict) -> None: + with pytest.raises(ValueError, match="requires chat-completion"): + ModelConfig(alias="test", model="test", provider="openai", api="responses", inference_parameters=params) diff --git a/packages/data-designer-engine/src/data_designer/engine/models/clients/adapters/openai_responses.py b/packages/data-designer-engine/src/data_designer/engine/models/clients/adapters/openai_responses.py new file mode 100644 index 000000000..8e927ad39 --- /dev/null +++ b/packages/data-designer-engine/src/data_designer/engine/models/clients/adapters/openai_responses.py @@ -0,0 +1,234 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +from data_designer.engine.models.clients.adapters.openai_compatible import ( + OpenAICompatibleClient, + translate_openai_compatible_content_blocks, +) +from data_designer.engine.models.clients.errors import ProviderError, ProviderErrorKind +from data_designer.engine.models.clients.parsing import extract_usage +from data_designer.engine.models.clients.types import ( + AssistantMessage, + ChatCompletionChoice, + ChatCompletionRequest, + ChatCompletionResponse, + ProviderReplayData, + ToolCall, + TransportKwargs, +) + + +class OpenAIResponsesClient(OpenAICompatibleClient): + """OpenAI-compatible client with explicit Responses routing.""" + + def completion(self, request: ChatCompletionRequest) -> ChatCompletionResponse: + if request.api == "chat-completions": + return super().completion(request) + payload = self._build_payload_or_raise(request) + response = self._post_sync("/responses", payload, request.extra_headers or {}, request.model, request.timeout) + return self._parse_response_or_raise(response, request.model) + + async def acompletion(self, request: ChatCompletionRequest) -> ChatCompletionResponse: + if request.api == "chat-completions": + return await super().acompletion(request) + payload = self._build_payload_or_raise(request) + response = await self._apost("/responses", payload, request.extra_headers or {}, request.model, request.timeout) + return self._parse_response_or_raise(response, request.model) + + def _build_payload_or_raise(self, request: ChatCompletionRequest) -> dict[str, Any]: + try: + return build_responses_payload(request) + except (ValueError, TypeError, KeyError, AttributeError) as exc: + raise ProviderError( + kind=ProviderErrorKind.BAD_REQUEST, + message=str(exc), + provider_name=self.provider_name, + model_name=request.model, + cause=exc, + ) from exc + + def _parse_response_or_raise(self, response: dict[str, Any], model: str) -> ChatCompletionResponse: + try: + return parse_responses_response(response) + except (ValueError, TypeError, KeyError, AttributeError) as exc: + raise ProviderError( + kind=ProviderErrorKind.API_ERROR, + message=f"Invalid Responses output: {exc}", + provider_name=self.provider_name, + model_name=model, + cause=exc, + ) from exc + + +def build_responses_payload(request: ChatCompletionRequest) -> dict[str, Any]: + """Translate canonical requests to stateless Responses requests.""" + body = deepcopy(TransportKwargs.from_request(request).body) + for key in ("stop", "seed", "frequency_penalty", "presence_penalty", "logprobs", "logit_bias"): + if key in body: + raise ValueError(f"Responses does not support {key!r}.") + if body.pop("n", 1) != 1: + raise ValueError("Responses supports only n=1.") + for key in ("input", "messages", "model", "previous_response_id", "conversation", "instructions"): + if key in body: + raise ValueError(f"Responses does not support overriding {key!r}.") + for key in ("stream", "background"): + if body.pop(key, False) is not False: + raise ValueError(f"Responses does not support {key}=true.") + if body.setdefault("store", False) is not False: + raise ValueError("Responses requires store=false; conversation state is replayed locally.") + include = body.get("include", []) + if not isinstance(include, list) or not all(isinstance(item, str) for item in include): + raise ValueError("Responses include must be a list of strings.") + body["include"] = list(dict.fromkeys([*include, "reasoning.encrypted_content"])) + limits = [key for key in ("max_tokens", "max_completion_tokens", "max_output_tokens") if key in body] + if len(limits) > 1: + raise ValueError("Specify only one token limit for Responses.") + if limits: + body["max_output_tokens"] = body.pop(limits[0]) + if "reasoning_effort" in body: + reasoning = dict(body.get("reasoning") or {}) + if "effort" in reasoning: + raise ValueError("Specify reasoning.effort or reasoning_effort, not both.") + reasoning["effort"] = body.pop("reasoning_effort") + body["reasoning"] = reasoning + if "response_format" in body: + response_format = body.pop("response_format") + text = dict(body.get("text") or {}) + if "format" in text: + raise ValueError("Specify response_format or text.format, not both.") + if response_format.get("type") == "json_schema": + text["format"] = {"type": "json_schema", **response_format["json_schema"]} + elif response_format.get("type") in {"text", "json_object"}: + text["format"] = response_format + else: + raise ValueError("Unsupported Responses response_format.") + body["text"] = text + if "tools" in body: + tools = [] + for tool in body["tools"]: + if tool.get("type") != "function": + raise ValueError("Responses adapter supports only function tools.") + function = tool["function"] + tools.append({"type": "function", "strict": False, **function}) + body["tools"] = tools + choice = body.get("tool_choice") + if isinstance(choice, dict): + if choice.get("type") != "function": + raise ValueError("Responses adapter supports only function tool choices.") + body["tool_choice"] = {"type": "function", "name": choice["function"]["name"]} + return {"model": request.model, "input": translate_responses_messages(request), **body} + + +def translate_responses_messages(request: ChatCompletionRequest) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for index, message in enumerate(request.messages): + role = message.get("role") + replay = request.message_replay_data[index] if request.message_replay_data else None + if replay is not None: + if role != "assistant" or replay.provider_type != "openai-responses": + raise ValueError("Responses replay data requires an assistant turn from openai-responses.") + if not isinstance(replay.payload, list) or not all(isinstance(item, dict) for item in replay.payload): + raise ValueError("Responses replay payload must be a list of output items.") + items.extend(deepcopy(replay.payload)) + continue + if role == "tool": + call_id = message.get("tool_call_id") + if not call_id: + raise ValueError("Responses tool results require tool_call_id.") + content = message.get("content") + items.append( + { + "type": "function_call_output", + "call_id": call_id, + "output": content if isinstance(content, str) else translate_responses_content(content), + } + ) + elif role in {"user", "system", "developer", "assistant"}: + content = message.get("content") + if content is not None: + items.append( + {"role": role, "content": translate_responses_content(content, assistant=role == "assistant")} + ) + for call in message.get("tool_calls") or []: + items.append( + { + "type": "function_call", + "call_id": call["id"], + "name": call["function"]["name"], + "arguments": call["function"]["arguments"], + } + ) + else: + raise ValueError(f"Unsupported Responses message role: {role!r}.") + return items + + +def translate_responses_content(content: Any, *, assistant: bool = False) -> str | list[dict[str, Any]]: + if isinstance(content, str): + content = [{"type": "text", "text": content}] + if not isinstance(content, list): + raise ValueError("Responses content must be text or a list of content blocks.") + translated = [] + for block in translate_openai_compatible_content_blocks(content): + if not isinstance(block, dict): + raise ValueError("Responses content blocks must be objects.") + if block.get("type") == "text": + translated.append({"type": "input_text", "text": block["text"]}) + elif block.get("type") == "image_url" and not assistant: + image = block["image_url"] + translated.append({"type": "input_image", "image_url": image["url"], "detail": image.get("detail", "auto")}) + else: + raise ValueError(f"Unsupported Responses content block: {block.get('type')!r}.") + return "\n".join(block["text"] for block in translated) if assistant else translated + + +def parse_responses_response(response: dict[str, Any]) -> ChatCompletionResponse: + if not isinstance(response, dict): + raise ValueError("Responses body must be an object.") + if response.get("error") or response.get("status") not in {"completed", "incomplete"}: + raise ValueError(f"Responses request failed: {response.get('error') or response.get('status')}") + output = response.get("output") or [] + if not isinstance(output, list) or not all(isinstance(item, dict) for item in output): + raise ValueError("Responses output must be a list of items.") + texts: list[str] = [] + reasoning: list[str] = [] + calls: list[ToolCall] = [] + refused = False + for item in output: + if item.get("type") == "message": + for block in item.get("content", []): + if block.get("type") == "output_text": + texts.append(block["text"]) + elif block.get("type") == "refusal": + refused = True + elif item.get("type") == "reasoning": + reasoning.extend(block["text"] for block in item.get("summary", []) if block.get("type") == "summary_text") + elif item.get("type") == "function_call": + if response.get("status") == "incomplete" or item.get("status", "completed") != "completed": + raise ValueError( + "Incomplete Responses function call; no tools were executed. Check the output token limit." + ) + calls.append(ToolCall(id=item["call_id"], name=item["name"], arguments_json=item["arguments"])) + message = AssistantMessage( + content="".join(texts) if texts else None, + reasoning_content="\n".join(reasoning) if reasoning else None, + tool_calls=calls, + replay_data=ProviderReplayData(provider_type="openai-responses", payload=deepcopy(output)), + ) + finish_reason = "tool_calls" if calls else "stop" + if response.get("status") == "incomplete": + reason = (response.get("incomplete_details") or {}).get("reason") + finish_reason = "length" if reason == "max_output_tokens" else reason + elif refused: + finish_reason = "content_filter" + return ChatCompletionResponse( + message=message, + usage=extract_usage(response.get("usage")), + raw=response, + choices=[ChatCompletionChoice(message=message, index=0, finish_reason=finish_reason)], + ) diff --git a/packages/data-designer-engine/src/data_designer/engine/models/clients/factory.py b/packages/data-designer-engine/src/data_designer/engine/models/clients/factory.py index bfff11313..d26f77b3b 100644 --- a/packages/data-designer-engine/src/data_designer/engine/models/clients/factory.py +++ b/packages/data-designer-engine/src/data_designer/engine/models/clients/factory.py @@ -8,7 +8,7 @@ from data_designer.engine.model_provider import ModelProviderRegistry 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.adapters.openai_responses import OpenAIResponsesClient from data_designer.engine.models.clients.base import ModelClient from data_designer.engine.models.clients.model_request_executor import ModelRequestExecutor from data_designer.engine.models.clients.retry import RetryConfig @@ -64,7 +64,7 @@ def create_model_client( types (``"openai"``, ``"anthropic"``). Routing logic: - 1. If ``provider_type == "openai"`` → ``OpenAICompatibleClient``. + 1. If ``provider_type == "openai"`` → ``OpenAIResponsesClient`` (both APIs). 2. If ``provider_type == "anthropic"`` → ``AnthropicClient``. 3. Otherwise → ``DataDesignerError``. """ @@ -76,7 +76,7 @@ def create_model_client( adapter_retry_config = _NO_TRANSPORT_RETRY_CONFIG if request_admission is not None else retry_config if provider.provider_type == "openai": - client: ModelClient = OpenAICompatibleClient( + client: ModelClient = OpenAIResponsesClient( provider_name=provider.name, endpoint=provider.endpoint, api_key=api_key, diff --git a/packages/data-designer-engine/src/data_designer/engine/models/clients/types.py b/packages/data-designer-engine/src/data_designer/engine/models/clients/types.py index 45a049a29..13f02f577 100644 --- a/packages/data-designer-engine/src/data_designer/engine/models/clients/types.py +++ b/packages/data-designer-engine/src/data_designer/engine/models/clients/types.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass, field, fields -from typing import Any, ClassVar, Protocol +from typing import Any, ClassVar, Literal, Protocol from data_designer.engine.models.usage import TokenCountSource @@ -69,6 +69,7 @@ class AssistantMessage: class ChatCompletionRequest: model: str messages: list[dict[str, Any]] + api: Literal["chat-completions", "responses"] = field(default="chat-completions", kw_only=True) tools: list[dict[str, Any]] | None = None tool_choice: str | dict[str, Any] | None = None n: int | None = None @@ -87,6 +88,8 @@ class ChatCompletionRequest: message_replay_data: list[ProviderReplayData | None] = field(default_factory=list) def __post_init__(self) -> None: + if self.api not in {"chat-completions", "responses"}: + raise ValueError(f"Unsupported completion API: {self.api!r}") if self.message_replay_data and len(self.message_replay_data) != len(self.messages): raise ValueError("message_replay_data must match the number of messages") @@ -167,7 +170,7 @@ class TransportKwargs: """ _META_FIELDS: ClassVar[frozenset[str]] = frozenset( - {"extra_body", "extra_headers", "timeout", "message_replay_data"} + {"extra_body", "extra_headers", "timeout", "message_replay_data", "api"} ) body: dict[str, Any] diff --git a/packages/data-designer-engine/src/data_designer/engine/models/facade.py b/packages/data-designer-engine/src/data_designer/engine/models/facade.py index be6640e67..406dddeb0 100644 --- a/packages/data-designer-engine/src/data_designer/engine/models/facade.py +++ b/packages/data-designer-engine/src/data_designer/engine/models/facade.py @@ -107,6 +107,7 @@ def _build_generation_validation_error( "stop", "seed", "response_format", + "api", "frequency_penalty", "presence_penalty", "timeout", @@ -307,6 +308,16 @@ async def acompletion( is_request_successful=response is not None, ) + def responses(self, messages: list[ChatMessage], **kwargs: Any) -> ChatCompletionResponse: + """Return an answer and replay data using the provider's conversation API.""" + kwargs["api"] = "responses" + return self.completion(messages, **kwargs) + + async def aresponses(self, messages: list[ChatMessage], **kwargs: Any) -> ChatCompletionResponse: + """Async counterpart of responses().""" + kwargs["api"] = "responses" + return await self.acompletion(messages, **kwargs) + # --- generate / agenerate --- @catch_llm_exceptions @@ -410,7 +421,8 @@ def generate( completion_kwargs.pop("tool_choice", None) try: - completion_response = self.completion( + completion = self.responses if self._model_config.api == "responses" else self.completion + completion_response = completion( messages, skip_usage_tracking=skip_usage_tracking, allow_multiple_choices=False, @@ -557,7 +569,8 @@ async def agenerate( completion_kwargs.pop("tool_choice", None) try: - completion_response = await self.acompletion( + completion = self.aresponses if self._model_config.api == "responses" else self.acompletion + completion_response = await completion( messages, skip_usage_tracking=skip_usage_tracking, allow_multiple_choices=False, diff --git a/packages/data-designer-engine/tests/engine/models/clients/test_openai_responses.py b/packages/data-designer-engine/tests/engine/models/clients/test_openai_responses.py new file mode 100644 index 000000000..b2eb35bb7 --- /dev/null +++ b/packages/data-designer-engine/tests/engine/models/clients/test_openai_responses.py @@ -0,0 +1,419 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +import json +from collections.abc import Iterator +from typing import Any +from unittest.mock import patch + +import pytest +from pytest_httpx import HTTPXMock + +from data_designer.config.mcp import MCPProvider, ToolConfig +from data_designer.config.models import ChatCompletionInferenceParams, ModelConfig, ModelProvider +from data_designer.engine.mcp.facade import MCPFacade +from data_designer.engine.mcp.registry import MCPToolDefinition, MCPToolResult +from data_designer.engine.model_provider import MCPProviderRegistry, ModelProviderRegistry +from data_designer.engine.models.clients.adapters.http_model_client import ClientConcurrencyMode +from data_designer.engine.models.clients.adapters.openai_responses import OpenAIResponsesClient +from data_designer.engine.models.clients.errors import ProviderError, ProviderErrorKind +from data_designer.engine.models.clients.factory import create_model_client +from data_designer.engine.models.clients.types import ChatCompletionRequest, ProviderReplayData +from data_designer.engine.models.facade import ModelFacade +from data_designer.engine.models.factory import create_model_registry +from data_designer.engine.models.utils import ChatMessage +from data_designer.engine.secret_resolver import PlaintextResolver +from data_designer.engine.testing import StubMCPRegistry + + +@pytest.fixture +def responses_registry() -> ModelProviderRegistry: + return ModelProviderRegistry( + providers=[ModelProvider(name="responses", provider_type="openai", endpoint="https://api.example.com/v1")] + ) + + +@pytest.fixture +def responses_config() -> ModelConfig: + return ModelConfig( + alias="responses", + api="responses", + provider="responses", + model="gpt-test", + inference_parameters=ChatCompletionInferenceParams(max_tokens=1024, extra_body={"reasoning_effort": "medium"}), + ) + + +@pytest.fixture +def responses_client() -> Iterator[OpenAIResponsesClient]: + client = OpenAIResponsesClient(provider_name="responses", endpoint="https://api.example.com/v1") + yield client + client.close() + + +def make_response(output: list[dict[str, Any]]) -> dict[str, Any]: + return { + "status": "completed", + "output": output, + "usage": { + "input_tokens": 10, + "output_tokens": 5, + "total_tokens": 15, + "output_tokens_details": {"reasoning_tokens": 3}, + }, + } + + +def make_text(text: str) -> dict[str, Any]: + return { + "type": "message", + "id": "msg_1", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": text, "annotations": []}], + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True]) +async def test_facade_replays_multiple_mcp_rounds( + httpx_mock: HTTPXMock, responses_registry: ModelProviderRegistry, responses_config: ModelConfig, asynchronous: bool +) -> None: + rounds = [] + for turn in range(2): + rounds.append( + [ + {"type": "reasoning", "id": f"rs_{turn}", "summary": [], "encrypted_content": f"encrypted-{turn}"}, + make_text(f"Checking {turn}"), + *[ + { + "type": "function_call", + "id": f"fc_{turn}_{i}", + "call_id": f"call_{turn}_{i}", + "name": "lookup", + "arguments": "{}", + } + for i in range(2) + ], + ] + ) + for output in [*rounds, [make_text("done")]]: + httpx_mock.add_response(url="https://api.example.com/v1/responses", json=make_response(output)) + mode = ClientConcurrencyMode.ASYNC if asynchronous else ClientConcurrencyMode.SYNC + client = create_model_client( + responses_config, PlaintextResolver(), responses_registry, client_concurrency_mode=mode + ) + assert isinstance(client, OpenAIResponsesClient) + mcp = MCPFacade( + ToolConfig(tool_alias="tools", providers=["tools"]), + PlaintextResolver(), + MCPProviderRegistry(providers=[MCPProvider(name="tools", endpoint="https://tools.example.com")]), + ) + facade = ModelFacade(responses_config, responses_registry, client=client, mcp_registry=StubMCPRegistry(mcp)) + tool = MCPToolDefinition(name="lookup", description="Lookup", input_schema={"type": "object", "properties": {}}) + with ( + patch("data_designer.engine.mcp.io.list_tools", return_value=[tool]), + patch( + "data_designer.engine.mcp.io.call_tools", + return_value=[MCPToolResult("result-a"), MCPToolResult("result-b")], + ), + ): + if asynchronous: + result, trace = await facade.agenerate(prompt="question", parser=str, tool_alias="tools") + await client.aclose() + else: + result, trace = facade.generate(prompt="question", parser=str, tool_alias="tools") + client.close() + assert result == "done" + assert len([message for message in trace if message.role == "tool"]) == 4 + bodies = [json.loads(request.content) for request in httpx_mock.get_requests()] + for body in bodies: + assert body["store"] is False + assert body["include"] == ["reasoning.encrypted_content"] + assert body["reasoning"] == {"effort": "medium"} + assert body["max_output_tokens"] == 1024 + assert body["tools"][0]["strict"] is False + previous = bodies[0]["input"] + for turn in range(2): + current = bodies[turn + 1]["input"] + assert current[: len(previous)] == previous + assert current[len(previous) : len(previous) + 4] == rounds[turn] + assert current[-2:] == [ + { + "type": "function_call_output", + "call_id": f"call_{turn}_{i}", + "output": [{"type": "input_text", "text": value}], + } + for i, value in enumerate(["result-a", "result-b"]) + ] + previous = current + + +def test_response_normalization_and_image_schema_request( + httpx_mock: HTTPXMock, responses_client: OpenAIResponsesClient +) -> None: + output = [ + {"type": "reasoning", "summary": [{"type": "summary_text", "text": "summary"}], "encrypted_content": "secret"}, + make_text('{"score":1}'), + ] + httpx_mock.add_response(json=make_response(output)) + request = ChatCompletionRequest( + api="responses", + model="gpt-test", + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "Score"}, + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "abc"}}, + ], + } + ], + response_format={ + "type": "json_schema", + "json_schema": {"name": "score", "strict": True, "schema": {"type": "object"}}, + }, + extra_body={"max_completion_tokens": 1024}, + ) + response = responses_client.completion(request) + body = json.loads(httpx_mock.get_request().content) + assert body["input"][0]["content"][1] == { + "type": "input_image", + "image_url": "data:image/png;base64,abc", + "detail": "auto", + } + assert body["text"]["format"] == { + "type": "json_schema", + "name": "score", + "strict": True, + "schema": {"type": "object"}, + } + assert response.message.content == '{"score":1}' + assert response.message.reasoning_content == "summary" + assert response.message.replay_data.payload == output + assert response.usage.input_tokens == 10 + assert response.usage.reasoning_tokens == 3 + assert response.choices[0].finish_reason == "stop" + + +@pytest.mark.parametrize( + "kwargs", + [ + {"n": 2}, + {"stop": ["stop"]}, + {"extra_body": {"stream": True}}, + {"extra_body": {"store": True}}, + {"extra_body": {"previous_response_id": "resp_1"}}, + {"max_tokens": 10, "extra_body": {"max_output_tokens": 20}}, + {"extra_body": {"reasoning_effort": "medium", "reasoning": {"effort": "low"}}}, + {"tools": [{"type": "web_search"}]}, + {"extra_body": {"include": "reasoning.encrypted_content"}}, + {"messages": [{"role": "user", "content": [{"type": "audio"}]}]}, + { + "messages": [{"role": "assistant", "content": "old"}], + "message_replay_data": [ProviderReplayData("anthropic", [])], + }, + ], +) +def test_rejects_unsupported_requests_before_http( + kwargs: dict[str, Any], httpx_mock: HTTPXMock, responses_client: OpenAIResponsesClient +) -> None: + request = ChatCompletionRequest( + api="responses", **{"model": "gpt-test", "messages": [{"role": "user", "content": "Hi"}], **kwargs} + ) + with pytest.raises(ProviderError) as exc: + responses_client.completion(request) + assert exc.value.kind == ProviderErrorKind.BAD_REQUEST + assert not httpx_mock.get_requests() + + +@pytest.mark.parametrize( + "body", + [ + [], + {"status": "failed", "error": {"code": "server_error"}}, + {"status": []}, + {"status": "completed", "output": ["invalid"]}, + ], +) +def test_normalizes_errors(httpx_mock: HTTPXMock, responses_client: OpenAIResponsesClient, body: Any) -> None: + httpx_mock.add_response(json=body) + with pytest.raises(ProviderError) as exc: + responses_client.completion(ChatCompletionRequest(api="responses", model="gpt-test", messages=[])) + assert exc.value.kind == ProviderErrorKind.API_ERROR + + +@pytest.mark.parametrize( + "body,finish_reason,content", + [ + ( + { + "status": "incomplete", + "incomplete_details": {"reason": "max_output_tokens"}, + "output": [make_text("partial")], + }, + "length", + "partial", + ), + ( + {"status": "incomplete", "incomplete_details": {"reason": "content_filter"}, "output": []}, + "content_filter", + None, + ), + ( + { + "status": "completed", + "output": [{"type": "message", "content": [{"type": "refusal", "refusal": "Cannot comply"}]}], + }, + "content_filter", + None, + ), + ({"status": "completed", "output": [make_text("one"), make_text("two")]}, "stop", "onetwo"), + ], +) +def test_normalizes_finish_reasons( + httpx_mock: HTTPXMock, + responses_client: OpenAIResponsesClient, + body: dict[str, Any], + finish_reason: str, + content: str | None, +) -> None: + httpx_mock.add_response(json=body) + response = responses_client.completion(ChatCompletionRequest(api="responses", model="gpt-test", messages=[])) + assert response.choices[0].finish_reason == finish_reason + assert response.message.content == content + assert response.usage is None + + +def test_responses_uses_anthropic_messages(httpx_mock: HTTPXMock, responses_config: ModelConfig) -> None: + output = [{"type": "thinking", "thinking": "reasoning", "signature": "signed"}, {"type": "text", "text": "answer"}] + httpx_mock.add_response( + url="https://api.example.com/v1/messages", json={"content": output, "stop_reason": "end_turn"} + ) + providers = ModelProviderRegistry( + providers=[ModelProvider(name="responses", provider_type="anthropic", endpoint="https://api.example.com/v1")] + ) + client = create_model_client(responses_config, PlaintextResolver(), providers) + facade = ModelFacade(responses_config, providers, client=client) + try: + response = facade.responses([ChatMessage.as_user("question")]) + assert response.message.content == "answer" + assert response.message.replay_data == ProviderReplayData("anthropic", output) + assert "api" not in json.loads(httpx_mock.get_request().content) + finally: + facade.close() + + +def test_translates_canonical_history_and_forced_tool_without_mutation( + httpx_mock: HTTPXMock, responses_client: OpenAIResponsesClient +) -> None: + httpx_mock.add_response(json=make_response([make_text("done")])) + tools = [{"type": "function", "function": {"name": "lookup", "parameters": {"type": "object"}, "strict": True}}] + request = ChatCompletionRequest( + api="responses", + model="gpt-test", + messages=[ + {"role": "system", "content": "Be helpful"}, + { + "role": "assistant", + "content": "Checking", + "tool_calls": [{"id": "call_1", "function": {"name": "lookup", "arguments": "{}"}}], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "result"}, + ], + tools=tools, + tool_choice={"type": "function", "function": {"name": "lookup"}}, + n=1, + extra_body={"include": ["reasoning.encrypted_content"], "reasoning": {"effort": "medium"}}, + extra_headers={"api-key": "test-key"}, + timeout=12, + ) + responses_client.completion(request) + sent = httpx_mock.get_request() + body = json.loads(sent.content) + assert body["tool_choice"] == {"type": "function", "name": "lookup"} + assert body["tools"] == [{"type": "function", **tools[0]["function"]}] + assert body["input"] == [ + {"role": "system", "content": [{"type": "input_text", "text": "Be helpful"}]}, + {"role": "assistant", "content": "Checking"}, + {"type": "function_call", "call_id": "call_1", "name": "lookup", "arguments": "{}"}, + {"type": "function_call_output", "call_id": "call_1", "output": "result"}, + ] + assert request.tools == tools + assert request.extra_body == {"include": ["reasoning.encrypted_content"], "reasoning": {"effort": "medium"}} + assert sent.headers["api-key"] == "test-key" + assert sent.extensions["timeout"]["read"] == 12 + assert "n" not in body + + +@pytest.mark.parametrize("status,call_status", [("incomplete", "completed"), ("completed", "incomplete")]) +def test_incomplete_function_calls_are_not_executed( + httpx_mock: HTTPXMock, responses_client: OpenAIResponsesClient, status: str, call_status: str +) -> None: + httpx_mock.add_response( + json={ + "status": status, + "output": [ + { + "type": "function_call", + "status": call_status, + "call_id": "call_1", + "name": "lookup", + "arguments": "{}", + } + ], + } + ) + with pytest.raises(ProviderError, match="Incomplete Responses function call"): + responses_client.completion(ChatCompletionRequest(api="responses", model="gpt-test", messages=[])) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("api", ["chat-completions", "responses"]) +async def test_explicit_methods_and_generation_use_one_provider( + httpx_mock: HTTPXMock, + responses_registry: ModelProviderRegistry, + responses_config: ModelConfig, + asynchronous: bool, + api: str, +) -> None: + httpx_mock.add_response( + url="https://api.example.com/v1/chat/completions", + json={"choices": [{"message": {"content": "chat"}}]}, + is_reusable=True, + ) + httpx_mock.add_response( + url="https://api.example.com/v1/responses", json=make_response([make_text("responses")]), is_reusable=True + ) + config = responses_config.model_copy(update={"api": api}) + registry = create_model_registry( + model_configs=[config], + secret_resolver=PlaintextResolver(), + model_provider_registry=responses_registry, + client_concurrency_mode=ClientConcurrencyMode.ASYNC if asynchronous else ClientConcurrencyMode.SYNC, + ) + model = registry.get_models(["responses"])["responses"] + messages = [ChatMessage.as_user("question")] + try: + if asynchronous: + chat = await model.acompletion(messages) + response = await model.aresponses(messages) + generated, _ = await model.agenerate("question") + else: + chat = await asyncio.to_thread(model.completion, messages) + response = await asyncio.to_thread(model.responses, messages) + generated, _ = await asyncio.to_thread(model.generate, "question") + assert chat.message.content == "chat" + assert response.message.content == "responses" + assert generated == ("responses" if api == "responses" else "chat") + assert model.usage_stats.request_usage.successful_requests == 3 + assert all("api" not in json.loads(request.content) for request in httpx_mock.get_requests()) + finally: + if asynchronous: + await model.aclose() + else: + model.close() diff --git a/packages/data-designer/src/data_designer/cli/forms/model_builder.py b/packages/data-designer/src/data_designer/cli/forms/model_builder.py index 321813a5c..41186ce77 100644 --- a/packages/data-designer/src/data_designer/cli/forms/model_builder.py +++ b/packages/data-designer/src/data_designer/cli/forms/model_builder.py @@ -276,6 +276,8 @@ def run(self, initial_data: dict[str, Any] | None = None) -> ModelConfig | None: # Merge results full_data = {**basic_result, "inference_parameters": inference_params} + if initial_data: + full_data["api"] = initial_data.get("api", "chat-completions") try: config = self.build_config(full_data) @@ -319,6 +321,7 @@ def build_config(self, form_data: dict[str, Any]) -> ModelConfig: return ModelConfig( alias=form_data["alias"], model=form_data["model"], + api=form_data.get("api", "chat-completions"), provider=provider, inference_parameters=inference_params, ) diff --git a/packages/data-designer/tests/cli/forms/test_model_builder.py b/packages/data-designer/tests/cli/forms/test_model_builder.py index f52ff44f8..4bbc60f48 100644 --- a/packages/data-designer/tests/cli/forms/test_model_builder.py +++ b/packages/data-designer/tests/cli/forms/test_model_builder.py @@ -1,6 +1,10 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +from __future__ import annotations + +from unittest.mock import patch + import pytest from data_designer.cli.forms.field import ValidationError @@ -524,3 +528,13 @@ def test_validate_encoding_format_accepts_clear_keywords() -> None: is_valid, error = builder.validate_encoding_format(keyword) assert is_valid is True, f"Failed for keyword: {keyword}" assert error is None + + +@pytest.mark.parametrize("api", ["chat-completions", "responses"]) +def test_edit_model_preserves_api(api: str) -> None: + initial = ModelConfig(alias="test", model="gpt-test", provider="openai", api=api) + answers = [{"alias": "test", "model": "updated", "generation_type": GenerationType.CHAT_COMPLETION}, {}] + with patch("data_designer.cli.forms.form.Form.prompt_all", side_effect=answers): + updated = ModelFormBuilder(available_providers=["openai"]).run(initial.model_dump()) + assert updated.model == "updated" + assert updated.api == api diff --git a/packages/data-designer/tests/interface/test_responses_generation.py b/packages/data-designer/tests/interface/test_responses_generation.py new file mode 100644 index 000000000..f6c3cd04e --- /dev/null +++ b/packages/data-designer/tests/interface/test_responses_generation.py @@ -0,0 +1,110 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +from pathlib import Path + +import httpx +import pandas as pd +import pytest +from pytest_httpx import HTTPXMock + +from data_designer.config.column_configs import LLMCodeColumnConfig, LLMJudgeColumnConfig, LLMTextColumnConfig, Score +from data_designer.config.config_builder import DataDesignerConfigBuilder +from data_designer.config.models import ChatCompletionInferenceParams, ImageContext, ModelConfig, ModelProvider +from data_designer.config.run_config import RunConfig +from data_designer.config.seed_source_dataframe import DataFrameSeedSource +from data_designer.engine.secret_resolver import PlaintextResolver +from data_designer.interface.data_designer import DataDesigner + + +def respond_to_generation(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + assert request.url.path == "/v1/responses" + assert body["store"] is False + assert body["max_output_tokens"] == 1024 + content = body["input"][-1]["content"] + prompt = "\n".join(block["text"] for block in content if block["type"] == "input_text") + if "Judge" in prompt: + assert "print(1)" in prompt + assert "quality" in str(body["input"]) + text = '{"quality":{"score":1,"reasoning":"Correct"}}' + elif "CODE" in prompt: + assert "A blue square" in prompt + text = "```python\nprint(1)\n```" + elif "Describe the image" in prompt: + assert next(block for block in content if block["type"] == "input_image") == { + "type": "input_image", + "image_url": "https://example.com/square.png", + "detail": "auto", + } + text = "A blue square" + else: + text = "Hello" + return httpx.Response( + 200, + json={ + "status": "completed", + "output": [{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": text}]}], + "usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}, + }, + ) + + +def test_responses_dependent_columns_judge_and_image( + httpx_mock: HTTPXMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("data_designer.engine.models.telemetry.TELEMETRY_ENABLED", False) + httpx_mock.add_callback(respond_to_generation, url="https://api.example.com/v1/responses", is_reusable=True) + score = Score(name="quality", description="Correctness", options={1: "Correct", 0: "Incorrect"}) + managed = tmp_path / "managed" + managed.mkdir() + designer = DataDesigner( + artifact_path=tmp_path / "artifacts", + managed_assets_path=managed, + secret_resolver=PlaintextResolver(), + model_providers=[ + ModelProvider(name="responses", provider_type="openai", endpoint="https://api.example.com/v1") + ], + ) + designer.set_run_config(RunConfig(otel_metrics_port=None)) + config = DataDesignerConfigBuilder( + model_configs=[ + ModelConfig( + alias="test", + api="responses", + model="gpt-test", + provider="responses", + inference_parameters=ChatCompletionInferenceParams( + max_tokens=1024, extra_body={"reasoning_effort": "medium"} + ), + ) + ] + ) + config.with_seed_dataset(DataFrameSeedSource(df=pd.DataFrame({"image": ["https://example.com/square.png"]}))) + config.add_column( + LLMTextColumnConfig( + name="description", + model_alias="test", + prompt="Describe the image", + multi_modal_context=[ImageContext(column_name="image")], + ) + ) + config.add_column( + LLMCodeColumnConfig(name="code", model_alias="test", prompt="CODE for {{ description }}", code_lang="python") + ) + config.add_column( + LLMJudgeColumnConfig( + name="judgment", + model_alias="test", + prompt="Judge {{ code }}", + scores=[score], + ) + ) + dataset = designer.create(config, num_records=1, dataset_name="responses").load_dataset() + assert dataset.iloc[0]["description"] == "A blue square" + assert dataset.iloc[0]["code"].strip() == "print(1)" + assert dataset.iloc[0]["judgment"]["quality"]["score"] == 1 + assert len(httpx_mock.get_requests()) == 4