diff --git a/README.md b/README.md index c69fdd1b..af229f6f 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ The Gradient SDK provides clients for: * DigitalOcean API * Gradient Serverless Inference * Gradient Agent Inference +* **Responses API** — `client.responses` for structured request/response with tools (e.g. GPT 5.2 Pro, 5.1 Codex Max). See `examples/responses_tool_calling.py`. The full API of this library can be found in [api.md](api.md). diff --git a/examples/responses_tool_calling.py b/examples/responses_tool_calling.py new file mode 100644 index 00000000..9b065d75 --- /dev/null +++ b/examples/responses_tool_calling.py @@ -0,0 +1,100 @@ +""" +Example: Responses API with tool (function) calling + +Demonstrates using client.responses.create() with a function tool: send a user +message, handle a function_call in the output, append the function result as +function_call_output, call create again, and print the final text. + +Requires GRADIENT_MODEL_ACCESS_KEY in the environment (e.g. from a .env file). +""" + +import os +from typing import cast, List + +from gradient import Gradient, ResponsesModels +from gradient.types.responses import ( + ResponseInputFunctionCall, + ResponseInputFunctionCallOutput, + ResponseInputItem, + ResponseOutputFunctionCall, + ResponseTool, +) + + +def _load_dotenv() -> None: + try: + from dotenv import load_dotenv # type: ignore[reportMissingImports] + + load_dotenv() + except ImportError: + pass + + +_load_dotenv() + +MODEL_ACCESS_KEY = os.environ.get("GRADIENT_MODEL_ACCESS_KEY") +if not MODEL_ACCESS_KEY: + raise SystemExit("Set GRADIENT_MODEL_ACCESS_KEY in the environment to run this example.") + +client = Gradient(model_access_key=MODEL_ACCESS_KEY) + +# One function tool: get_weather +get_weather_tool: ResponseTool = { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather for a city.", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string", "description": "City name"}, + "unit": {"type": "string", "enum": ["celsius", "fahrenheit"], "default": "celsius"}, + }, + "required": ["city"], + }, + }, +} + +# Initial conversation: single user message +input_messages: List[ResponseInputItem] = cast( + List[ResponseInputItem], + [{"type": "message", "role": "user", "content": "What's the weather in New York?"}], +) + +# First call: model may return a function_call +response = client.responses.create( + model=ResponsesModels.GPT_5_1_CODEX_MAX, + input=input_messages, + tools=[get_weather_tool], + tool_choice="auto", +) + +# If the model returned a function call, append it and the tool result, then call again +for item in response.output: + if isinstance(item, ResponseOutputFunctionCall): + input_messages.append( + cast( + ResponseInputFunctionCall, + {"type": "function_call", "id": item.id, "name": item.name, "arguments": item.arguments}, + ) + ) + # Simulated tool result (in a real app you would call your function here) + input_messages.append( + cast( + ResponseInputFunctionCallOutput, + { + "type": "function_call_output", + "call_id": item.id, + "output": '{"temperature": 22, "unit": "celsius", "conditions": "sunny"}', + }, + ) + ) + response = client.responses.create( + model=ResponsesModels.GPT_5_1_CODEX_MAX, + input=input_messages, + tools=[get_weather_tool], + tool_choice="auto", + ) + break + +print("Assistant:", response.output_text.strip() or "(no text)") diff --git a/src/gradient/__init__.py b/src/gradient/__init__.py index c5733e7e..a77e207c 100644 --- a/src/gradient/__init__.py +++ b/src/gradient/__init__.py @@ -42,6 +42,7 @@ ) from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient from ._utils._logs import setup_logging as _setup_logging +from .responses_models import ResponsesModels __all__ = [ "types", @@ -91,6 +92,7 @@ "DefaultHttpxClient", "DefaultAsyncHttpxClient", "DefaultAioHttpClient", + "ResponsesModels", ] if not _t.TYPE_CHECKING: diff --git a/src/gradient/_client.py b/src/gradient/_client.py index 847121b1..1e013ac9 100644 --- a/src/gradient/_client.py +++ b/src/gradient/_client.py @@ -42,6 +42,7 @@ retrieve, databases, inference, + responses, gpu_droplets, knowledge_bases, ) @@ -58,6 +59,10 @@ from .resources.models.models import ModelsResource, AsyncModelsResource from .resources.databases.databases import DatabasesResource, AsyncDatabasesResource from .resources.inference.inference import InferenceResource, AsyncInferenceResource + from .resources.responses.responses import ( + ResponsesResource, + AsyncResponsesResource, + ) from .resources.knowledge_bases.knowledge_bases import ( KnowledgeBasesResource, AsyncKnowledgeBasesResource, @@ -195,6 +200,12 @@ def chat(self) -> ChatResource: return ChatResource(self) + @cached_property + def responses(self) -> ResponsesResource: + from .resources.responses import ResponsesResource + + return ResponsesResource(self) + @cached_property def images(self) -> ImagesResource: from .resources.images import ImagesResource @@ -353,14 +364,10 @@ def copy( Create a new client instance re-using the same options given to the current client with optional overriding. """ if default_headers is not None and set_default_headers is not None: - raise ValueError( - "The `default_headers` and `set_default_headers` arguments are mutually exclusive" - ) + raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive") if default_query is not None and set_default_query is not None: - raise ValueError( - "The `default_query` and `set_default_query` arguments are mutually exclusive" - ) + raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive") headers = self._custom_headers if default_headers is not None: @@ -411,14 +418,10 @@ def _make_status_error( return _exceptions.BadRequestError(err_msg, response=response, body=body) if response.status_code == 401: - return _exceptions.AuthenticationError( - err_msg, response=response, body=body - ) + return _exceptions.AuthenticationError(err_msg, response=response, body=body) if response.status_code == 403: - return _exceptions.PermissionDeniedError( - err_msg, response=response, body=body - ) + return _exceptions.PermissionDeniedError(err_msg, response=response, body=body) if response.status_code == 404: return _exceptions.NotFoundError(err_msg, response=response, body=body) @@ -427,17 +430,13 @@ def _make_status_error( return _exceptions.ConflictError(err_msg, response=response, body=body) if response.status_code == 422: - return _exceptions.UnprocessableEntityError( - err_msg, response=response, body=body - ) + return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body) if response.status_code == 429: return _exceptions.RateLimitError(err_msg, response=response, body=body) if response.status_code >= 500: - return _exceptions.InternalServerError( - err_msg, response=response, body=body - ) + return _exceptions.InternalServerError(err_msg, response=response, body=body) return APIStatusError(err_msg, response=response, body=body) @@ -561,6 +560,12 @@ def chat(self) -> AsyncChatResource: return AsyncChatResource(self) + @cached_property + def responses(self) -> AsyncResponsesResource: + from .resources.responses import AsyncResponsesResource + + return AsyncResponsesResource(self) + @cached_property def images(self) -> AsyncImagesResource: from .resources.images import AsyncImagesResource @@ -719,14 +724,10 @@ def copy( Create a new client instance re-using the same options given to the current client with optional overriding. """ if default_headers is not None and set_default_headers is not None: - raise ValueError( - "The `default_headers` and `set_default_headers` arguments are mutually exclusive" - ) + raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive") if default_query is not None and set_default_query is not None: - raise ValueError( - "The `default_query` and `set_default_query` arguments are mutually exclusive" - ) + raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive") headers = self._custom_headers if default_headers is not None: @@ -777,14 +778,10 @@ def _make_status_error( return _exceptions.BadRequestError(err_msg, response=response, body=body) if response.status_code == 401: - return _exceptions.AuthenticationError( - err_msg, response=response, body=body - ) + return _exceptions.AuthenticationError(err_msg, response=response, body=body) if response.status_code == 403: - return _exceptions.PermissionDeniedError( - err_msg, response=response, body=body - ) + return _exceptions.PermissionDeniedError(err_msg, response=response, body=body) if response.status_code == 404: return _exceptions.NotFoundError(err_msg, response=response, body=body) @@ -793,17 +790,13 @@ def _make_status_error( return _exceptions.ConflictError(err_msg, response=response, body=body) if response.status_code == 422: - return _exceptions.UnprocessableEntityError( - err_msg, response=response, body=body - ) + return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body) if response.status_code == 429: return _exceptions.RateLimitError(err_msg, response=response, body=body) if response.status_code >= 500: - return _exceptions.InternalServerError( - err_msg, response=response, body=body - ) + return _exceptions.InternalServerError(err_msg, response=response, body=body) return APIStatusError(err_msg, response=response, body=body) @@ -825,6 +818,12 @@ def chat(self) -> chat.ChatResourceWithRawResponse: return ChatResourceWithRawResponse(self._client.chat) + @cached_property + def responses(self) -> responses.ResponsesResourceWithRawResponse: + from .resources.responses import ResponsesResourceWithRawResponse + + return ResponsesResourceWithRawResponse(self._client.responses) + @cached_property def images(self) -> images.ImagesResourceWithRawResponse: from .resources.images import ImagesResourceWithRawResponse @@ -898,6 +897,12 @@ def chat(self) -> chat.AsyncChatResourceWithRawResponse: return AsyncChatResourceWithRawResponse(self._client.chat) + @cached_property + def responses(self) -> responses.AsyncResponsesResourceWithRawResponse: + from .resources.responses import AsyncResponsesResourceWithRawResponse + + return AsyncResponsesResourceWithRawResponse(self._client.responses) + @cached_property def images(self) -> images.AsyncImagesResourceWithRawResponse: from .resources.images import AsyncImagesResourceWithRawResponse @@ -975,6 +980,12 @@ def chat(self) -> chat.ChatResourceWithStreamingResponse: return ChatResourceWithStreamingResponse(self._client.chat) + @cached_property + def responses(self) -> responses.ResponsesResourceWithStreamingResponse: + from .resources.responses import ResponsesResourceWithStreamingResponse + + return ResponsesResourceWithStreamingResponse(self._client.responses) + @cached_property def images(self) -> images.ImagesResourceWithStreamingResponse: from .resources.images import ImagesResourceWithStreamingResponse @@ -1052,6 +1063,12 @@ def chat(self) -> chat.AsyncChatResourceWithStreamingResponse: return AsyncChatResourceWithStreamingResponse(self._client.chat) + @cached_property + def responses(self) -> responses.AsyncResponsesResourceWithStreamingResponse: + from .resources.responses import AsyncResponsesResourceWithStreamingResponse + + return AsyncResponsesResourceWithStreamingResponse(self._client.responses) + @cached_property def images(self) -> images.AsyncImagesResourceWithStreamingResponse: from .resources.images import AsyncImagesResourceWithStreamingResponse @@ -1082,9 +1099,7 @@ def knowledge_bases( AsyncKnowledgeBasesResourceWithStreamingResponse, ) - return AsyncKnowledgeBasesResourceWithStreamingResponse( - self._client.knowledge_bases - ) + return AsyncKnowledgeBasesResourceWithStreamingResponse(self._client.knowledge_bases) @cached_property def models(self) -> models.AsyncModelsResourceWithStreamingResponse: diff --git a/src/gradient/resources/__init__.py b/src/gradient/resources/__init__.py index f668bb06..7977257d 100644 --- a/src/gradient/resources/__init__.py +++ b/src/gradient/resources/__init__.py @@ -72,6 +72,14 @@ InferenceResourceWithStreamingResponse, AsyncInferenceResourceWithStreamingResponse, ) +from .responses import ( + ResponsesResource, + AsyncResponsesResource, + ResponsesResourceWithRawResponse, + AsyncResponsesResourceWithRawResponse, + ResponsesResourceWithStreamingResponse, + AsyncResponsesResourceWithStreamingResponse, +) from .gpu_droplets import ( GPUDropletsResource, AsyncGPUDropletsResource, @@ -150,6 +158,12 @@ "AsyncNfsResourceWithRawResponse", "NfsResourceWithStreamingResponse", "AsyncNfsResourceWithStreamingResponse", + "ResponsesResource", + "AsyncResponsesResource", + "ResponsesResourceWithRawResponse", + "AsyncResponsesResourceWithRawResponse", + "ResponsesResourceWithStreamingResponse", + "AsyncResponsesResourceWithStreamingResponse", "RetrieveResource", "AsyncRetrieveResource", "RetrieveResourceWithRawResponse", diff --git a/src/gradient/resources/responses/__init__.py b/src/gradient/resources/responses/__init__.py new file mode 100644 index 00000000..cec6faa7 --- /dev/null +++ b/src/gradient/resources/responses/__init__.py @@ -0,0 +1,21 @@ +# Responses API. See docs/RESPONSES_API_PR_BREAKDOWN.md. + +from __future__ import annotations + +from .responses import ( + ResponsesResource, + AsyncResponsesResource, + ResponsesResourceWithRawResponse, + AsyncResponsesResourceWithRawResponse, + ResponsesResourceWithStreamingResponse, + AsyncResponsesResourceWithStreamingResponse, +) + +__all__ = [ + "AsyncResponsesResource", + "AsyncResponsesResourceWithRawResponse", + "AsyncResponsesResourceWithStreamingResponse", + "ResponsesResource", + "ResponsesResourceWithRawResponse", + "ResponsesResourceWithStreamingResponse", +] diff --git a/src/gradient/resources/responses/responses.py b/src/gradient/resources/responses/responses.py new file mode 100644 index 00000000..2d5eae88 --- /dev/null +++ b/src/gradient/resources/responses/responses.py @@ -0,0 +1,203 @@ +# Responses API (POST /v1/responses). See docs/RESPONSES_API_PR_BREAKDOWN.md. + +from __future__ import annotations + +from typing import Iterable, Optional + +import httpx + +from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from ..._utils import maybe_transform, async_maybe_transform +from ..._compat import cached_property +from ..._resource import SyncAPIResource, AsyncAPIResource +from ..._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ..._base_client import make_request_options +from ...types.responses import response_create_params +from ...types.responses.response_create_response import ResponseCreateResponse + +__all__ = ["ResponsesResource", "AsyncResponsesResource"] + + +class ResponsesResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> ResponsesResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + """ + return ResponsesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> ResponsesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + """ + return ResponsesResourceWithStreamingResponse(self) + + def create( + self, + *, + model: str, + input: Iterable[response_create_params.ResponseInputItem], + tools: Iterable[response_create_params.ResponseTool] | Omit = omit, + max_output_tokens: Optional[int] | Omit = omit, + instructions: Optional[str] | Omit = omit, + temperature: Optional[float] | Omit = omit, + tool_choice: response_create_params.ResponseToolChoice | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ResponseCreateResponse: + """ + Create a response from the Responses API (POST /v1/responses). + + Args: + model: Model ID. Use ``ResponsesModels`` (e.g. ``ResponsesModels.GPT_5_2_PRO``) + for recommended model IDs. + input: List of input items: user messages, function_call, function_call_output. + tools: Optional list of tools the model may call. + max_output_tokens: Maximum tokens to generate. + instructions: System or developer instructions. + temperature: Sampling temperature. + tool_choice: Which tool (if any) the model must or may call. + """ + if not self._client.model_access_key: + raise TypeError( + "Could not resolve authentication method. Expected model_access_key to be set for the Responses API." + ) + headers = extra_headers or {} + headers = { + "Authorization": f"Bearer {self._client.model_access_key}", + **headers, + } + + return self._post( + "/v1/responses" if self._client._base_url_overridden else f"{self._client.inference_endpoint}/v1/responses", + body=maybe_transform( + { + "model": model, + "input": input, + "tools": tools, + "max_output_tokens": max_output_tokens, + "instructions": instructions, + "temperature": temperature, + "tool_choice": tool_choice, + }, + response_create_params.ResponseCreateParams, + ), + options=make_request_options( + extra_headers=headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ), + cast_to=ResponseCreateResponse, + ) + + +class AsyncResponsesResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncResponsesResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + """ + return AsyncResponsesResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncResponsesResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + """ + return AsyncResponsesResourceWithStreamingResponse(self) + + async def create( + self, + *, + model: str, + input: Iterable[response_create_params.ResponseInputItem], + tools: Iterable[response_create_params.ResponseTool] | Omit = omit, + max_output_tokens: Optional[int] | Omit = omit, + instructions: Optional[str] | Omit = omit, + temperature: Optional[float] | Omit = omit, + tool_choice: response_create_params.ResponseToolChoice | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ResponseCreateResponse: + """ + Create a response from the Responses API (POST /v1/responses). + + Args: + model: Model ID. Use ``ResponsesModels`` (e.g. ``ResponsesModels.GPT_5_2_PRO``) + for recommended model IDs. + input: List of input items: user messages, function_call, function_call_output. + tools: Optional list of tools the model may call. + max_output_tokens: Maximum tokens to generate. + instructions: System or developer instructions. + temperature: Sampling temperature. + tool_choice: Which tool (if any) the model must or may call. + """ + if not getattr(self._client, "model_access_key", None) or not self._client.model_access_key: + raise TypeError( + "Could not resolve authentication method. Expected model_access_key to be set for the Responses API." + ) + headers = extra_headers or {} + headers = { + "Authorization": f"Bearer {self._client.model_access_key}", + **headers, + } + + return await self._post( + "/v1/responses" if self._client._base_url_overridden else f"{self._client.inference_endpoint}/v1/responses", + body=await async_maybe_transform( + { + "model": model, + "input": input, + "tools": tools, + "max_output_tokens": max_output_tokens, + "instructions": instructions, + "temperature": temperature, + "tool_choice": tool_choice, + }, + response_create_params.ResponseCreateParams, + ), + options=make_request_options( + extra_headers=headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ), + cast_to=ResponseCreateResponse, + ) + + +class ResponsesResourceWithRawResponse: + def __init__(self, responses: ResponsesResource) -> None: + self._responses = responses + self.create = to_raw_response_wrapper(responses.create) + + +class AsyncResponsesResourceWithRawResponse: + def __init__(self, responses: AsyncResponsesResource) -> None: + self._responses = responses + self.create = async_to_raw_response_wrapper(responses.create) + + +class ResponsesResourceWithStreamingResponse: + def __init__(self, responses: ResponsesResource) -> None: + self._responses = responses + self.create = to_streamed_response_wrapper(responses.create) + + +class AsyncResponsesResourceWithStreamingResponse: + def __init__(self, responses: AsyncResponsesResource) -> None: + self._responses = responses + self.create = async_to_streamed_response_wrapper(responses.create) diff --git a/src/gradient/responses_models.py b/src/gradient/responses_models.py new file mode 100644 index 00000000..d2b8d7f5 --- /dev/null +++ b/src/gradient/responses_models.py @@ -0,0 +1,8 @@ +"""Recommended model IDs for the Responses API.""" + + +class ResponsesModels: + """Model ID constants for use with the Responses API.""" + + GPT_5_2_PRO = "openai-gpt-5.2-pro" + GPT_5_1_CODEX_MAX = "openai-gpt-5.1-codex-max" diff --git a/src/gradient/types/responses/__init__.py b/src/gradient/types/responses/__init__.py new file mode 100644 index 00000000..988033b0 --- /dev/null +++ b/src/gradient/types/responses/__init__.py @@ -0,0 +1,22 @@ +# Types for the Responses API. See docs/RESPONSES_API_PR_BREAKDOWN.md. + +from __future__ import annotations + +from .response_create_params import ( + ResponseTool as ResponseTool, + ResponseInputItem as ResponseInputItem, + ResponseToolChoice as ResponseToolChoice, + ResponseCreateParams as ResponseCreateParams, + ResponseToolFunction as ResponseToolFunction, + ResponseToolChoiceNamed as ResponseToolChoiceNamed, + ResponseInputUserMessage as ResponseInputUserMessage, + ResponseInputFunctionCall as ResponseInputFunctionCall, + ResponseToolChoiceFunction as ResponseToolChoiceFunction, + ResponseInputFunctionCallOutput as ResponseInputFunctionCallOutput, +) +from .response_create_response import ( + ResponseOutputItem as ResponseOutputItem, + ResponseOutputMessage as ResponseOutputMessage, + ResponseCreateResponse as ResponseCreateResponse, + ResponseOutputFunctionCall as ResponseOutputFunctionCall, +) diff --git a/src/gradient/types/responses/response_create_params.py b/src/gradient/types/responses/response_create_params.py new file mode 100644 index 00000000..037346de --- /dev/null +++ b/src/gradient/types/responses/response_create_params.py @@ -0,0 +1,105 @@ +# Types for the Responses API (POST /v1/responses). See docs/RESPONSES_API_PR_BREAKDOWN.md. + +from __future__ import annotations + +from typing import Dict, Union, Iterable, Optional +from typing_extensions import Literal, Required, TypeAlias, TypedDict + +__all__ = [ + "ResponseCreateParams", + "ResponseInputItem", + "ResponseInputUserMessage", + "ResponseInputFunctionCall", + "ResponseInputFunctionCallOutput", + "ResponseToolChoice", + "ResponseToolChoiceFunction", + "ResponseTool", + "ResponseToolFunction", +] + + +class ResponseInputUserMessage(TypedDict, total=False): + """User message in the request input list.""" + + type: Required[Literal["message"]] + role: Required[Literal["user"]] + content: Required[str] + + +class ResponseInputFunctionCall(TypedDict, total=False): + """Function call (assistant turn) in the request input list.""" + + type: Required[Literal["function_call"]] + id: Required[str] + name: Required[str] + arguments: Required[str] + + +class ResponseInputFunctionCallOutput(TypedDict, total=False): + """Function call result (tool output) in the request input list.""" + + type: Required[Literal["function_call_output"]] + call_id: Required[str] + output: Required[str] + + +ResponseInputItem: TypeAlias = Union[ + ResponseInputUserMessage, + ResponseInputFunctionCall, + ResponseInputFunctionCallOutput, +] + + +class ResponseToolFunction(TypedDict, total=False): + """Function definition for a tool.""" + + name: Required[str] + description: str + parameters: Dict[str, object] + + +class ResponseTool(TypedDict, total=False): + """Tool the model may call (e.g. a function).""" + + type: Required[Literal["function"]] + function: Required[ResponseToolFunction] + + +class ResponseToolChoiceFunction(TypedDict, total=False): + name: Required[str] + + +class ResponseToolChoiceNamed(TypedDict, total=False): + type: Required[Literal["function"]] + function: Required[ResponseToolChoiceFunction] + + +ResponseToolChoice: TypeAlias = Union[ + Literal["none", "auto", "required"], + ResponseToolChoiceNamed, +] + + +class ResponseCreateParams(TypedDict, total=False): + """Request body for POST /v1/responses.""" + + model: Required[str] + """Model ID (e.g. openai-gpt-5.2-pro).""" + + input: Required[Iterable[ResponseInputItem]] + """List of input items: user messages, function_call, function_call_output.""" + + tools: Iterable[ResponseTool] + """Optional list of tools the model may call.""" + + max_output_tokens: Optional[int] + """Maximum tokens to generate.""" + + instructions: Optional[str] + """System or developer instructions.""" + + temperature: Optional[float] + """Sampling temperature.""" + + tool_choice: ResponseToolChoice + """Which tool (if any) the model must or may call.""" diff --git a/src/gradient/types/responses/response_create_response.py b/src/gradient/types/responses/response_create_response.py new file mode 100644 index 00000000..d504fa63 --- /dev/null +++ b/src/gradient/types/responses/response_create_response.py @@ -0,0 +1,83 @@ +# Response type for the Responses API (POST /v1/responses). See docs/RESPONSES_API_PR_BREAKDOWN.md. + +from __future__ import annotations + +from typing import List, Union, Optional +from typing_extensions import Literal, Annotated, TypeAlias + +from ..._utils import PropertyInfo +from ..._models import BaseModel +from ..shared.completion_usage import CompletionUsage + +__all__ = [ + "ResponseCreateResponse", + "ResponseOutputItem", + "ResponseOutputMessage", + "ResponseOutputFunctionCall", +] + + +class ResponseOutputMessage(BaseModel): + """Message item in the response output list.""" + + type: Literal["message"] = "message" + role: Literal["assistant"] = "assistant" + content: Optional[str] = None + """Text content of the message.""" + output_text: Optional[str] = None + """Aggregated or final text for this item (when present).""" + + +class ResponseOutputFunctionCall(BaseModel): + """Function call item in the response output list.""" + + type: Literal["function_call"] = "function_call" + id: str + name: str + arguments: str + + +# Discriminated union so Pydantic parses each output item by "type". +ResponseOutputItem: TypeAlias = Annotated[ + Union[ResponseOutputMessage, ResponseOutputFunctionCall], + PropertyInfo(discriminator="type"), +] + + +class ResponseCreateResponse(BaseModel): + """ + Response from POST /v1/responses. + Use the `output_text` property to get aggregated text from message items in `output`. + """ + + id: str + """Unique identifier for the response.""" + + output: List[ResponseOutputItem] + """List of output items (messages, function calls).""" + + status: str + """Status of the response (e.g. completed, failed).""" + + error: Optional[str] = None + """Error message if status indicates failure.""" + + model: Optional[str] = None + """Model used for the response.""" + + usage: Optional[CompletionUsage] = None + """Token usage statistics.""" + + @property + def output_text(self) -> str: + """ + Aggregate text from all message items in `output`. + For each item with type "message", uses `output_text` if present, else `content`. + """ + parts: List[str] = [] + for item in self.output: + if isinstance(item, ResponseOutputMessage): + text: Optional[str] = item.output_text if item.output_text is not None else item.content + if text: + parts.append(text) + return "".join(parts) diff --git a/tests/api_resources/test_responses.py b/tests/api_resources/test_responses.py new file mode 100644 index 00000000..58fd3682 --- /dev/null +++ b/tests/api_resources/test_responses.py @@ -0,0 +1,122 @@ +# Tests for Responses API (client.responses.create). Use respx only; no real API. + +from __future__ import annotations + +import os +import json +from typing import Any + +import httpx +import pytest +from respx import MockRouter + +from gradient import Gradient, AsyncGradient +from tests.utils import assert_matches_type +from gradient.types.responses import ResponseCreateResponse, ResponseInputUserMessage + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + +# Minimal valid response body for POST /v1/responses +MINIMAL_RESPONSE_BODY: dict[str, Any] = { + "id": "resp_123", + "output": [], + "status": "completed", + "model": "openai-gpt-5.2-pro", +} + +# Minimal input for create() (typed so pyright accepts as Iterable[ResponseInputItem]) +MINIMAL_INPUT: list[ResponseInputUserMessage] = [ + {"type": "message", "role": "user", "content": "Hello"}, +] + + +class TestResponsesCreateSync: + @pytest.mark.respx(base_url=base_url) + def test_create_minimal(self, respx_mock: MockRouter, client: Gradient) -> None: + respx_mock.post("/v1/responses").mock(return_value=httpx.Response(200, json=MINIMAL_RESPONSE_BODY)) + resp = client.responses.create( + model="openai-gpt-5.2-pro", + input=MINIMAL_INPUT, + ) + assert_matches_type(ResponseCreateResponse, resp, path=["response"]) + assert resp.id == "resp_123" + assert resp.status == "completed" + assert resp.output_text == "" + + @pytest.mark.respx(base_url=base_url) + def test_create_with_tools_and_max_output_tokens(self, respx_mock: MockRouter, client: Gradient) -> None: + respx_mock.post("/v1/responses").mock(return_value=httpx.Response(200, json=MINIMAL_RESPONSE_BODY)) + resp = client.responses.create( + model="openai-gpt-5.2-pro", + input=MINIMAL_INPUT, + tools=[ + { + "type": "function", + "function": {"name": "get_weather", "description": "Get weather"}, + } + ], + max_output_tokens=512, + ) + assert resp.id == "resp_123" + request = respx_mock.calls.last.request + assert request is not None + body = json.loads(request.content) + assert body.get("tools") is not None + assert body.get("max_output_tokens") == 512 + + @pytest.mark.respx(base_url=base_url) + def test_with_raw_response_create(self, respx_mock: MockRouter, client: Gradient) -> None: + respx_mock.post("/v1/responses").mock(return_value=httpx.Response(200, json=MINIMAL_RESPONSE_BODY)) + raw = client.responses.with_raw_response.create( + model="openai-gpt-5.2-pro", + input=MINIMAL_INPUT, + ) + assert raw.is_closed is True + parsed = raw.parse() + assert_matches_type(ResponseCreateResponse, parsed, path=["response"]) + assert parsed.id == "resp_123" + + def test_missing_model_access_key_raises(self) -> None: + with Gradient( + base_url=base_url, + access_token="token", + model_access_key=None, + agent_access_key="agent", + ) as c: + with pytest.raises(TypeError) as exc_info: + c.responses.create(model="m", input=MINIMAL_INPUT) + assert "model_access_key" in str(exc_info.value) + + +class TestResponsesCreateAsync: + @pytest.mark.respx(base_url=base_url) + async def test_create_minimal(self, respx_mock: MockRouter, async_client: AsyncGradient) -> None: + respx_mock.post("/v1/responses").mock(return_value=httpx.Response(200, json=MINIMAL_RESPONSE_BODY)) + resp = await async_client.responses.create( + model="openai-gpt-5.2-pro", + input=MINIMAL_INPUT, + ) + assert_matches_type(ResponseCreateResponse, resp, path=["response"]) + assert resp.id == "resp_123" + + @pytest.mark.respx(base_url=base_url) + async def test_with_raw_response_create(self, respx_mock: MockRouter, async_client: AsyncGradient) -> None: + respx_mock.post("/v1/responses").mock(return_value=httpx.Response(200, json=MINIMAL_RESPONSE_BODY)) + raw = await async_client.responses.with_raw_response.create( + model="openai-gpt-5.2-pro", + input=MINIMAL_INPUT, + ) + assert raw.is_closed is True + parsed = await raw.parse() + assert parsed.id == "resp_123" + + async def test_missing_model_access_key_raises(self) -> None: + async with AsyncGradient( + base_url=base_url, + access_token="token", + model_access_key=None, + agent_access_key="agent", + ) as c: + with pytest.raises(TypeError) as exc_info: + await c.responses.create(model="m", input=MINIMAL_INPUT) + assert "model_access_key" in str(exc_info.value) diff --git a/tests/test_responses_models.py b/tests/test_responses_models.py new file mode 100644 index 00000000..bdfe185e --- /dev/null +++ b/tests/test_responses_models.py @@ -0,0 +1,16 @@ +"""Tests for Responses API model constants.""" + +from gradient import ResponsesModels + + +def test_responses_models_gpt_5_2_pro() -> None: + assert ResponsesModels.GPT_5_2_PRO == "openai-gpt-5.2-pro" + + +def test_responses_models_gpt_5_1_codex_max() -> None: + assert ResponsesModels.GPT_5_1_CODEX_MAX == "openai-gpt-5.1-codex-max" + + +def test_responses_models_constants_are_strings() -> None: + assert isinstance(ResponsesModels.GPT_5_2_PRO, str) + assert isinstance(ResponsesModels.GPT_5_1_CODEX_MAX, str) diff --git a/tests/types/__init__.py b/tests/types/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/types/responses/__init__.py b/tests/types/responses/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/types/responses/test_response_create_response.py b/tests/types/responses/test_response_create_response.py new file mode 100644 index 00000000..a3ee0f19 --- /dev/null +++ b/tests/types/responses/test_response_create_response.py @@ -0,0 +1,130 @@ +# Tests for Responses API response types. No network; static payloads only. + +from __future__ import annotations + +from typing import Any + +from gradient._compat import parse_obj +from gradient.types.responses import ( + ResponseOutputMessage, + ResponseCreateResponse, + ResponseOutputFunctionCall, +) + +# Minimal valid response payload (static, no network). +MINIMAL_RESPONSE: dict[str, Any] = { + "id": "resp_123", + "output": [], + "status": "completed", + "model": "openai-gpt-5.2-pro", +} + + +class TestResponseCreateResponseParse: + """Test that ResponseCreateResponse parses minimal and extended JSON.""" + + def test_parse_minimal_response(self) -> None: + parsed = parse_obj(ResponseCreateResponse, MINIMAL_RESPONSE) + assert parsed.id == "resp_123" + assert parsed.output == [] + assert parsed.status == "completed" + assert parsed.model == "openai-gpt-5.2-pro" + assert parsed.output_text == "" + + def test_parse_response_with_usage(self) -> None: + payload: dict[str, Any] = { + **MINIMAL_RESPONSE, + "usage": { + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + }, + } + parsed = parse_obj(ResponseCreateResponse, payload) + assert parsed.usage is not None + assert parsed.usage.prompt_tokens == 10 + assert parsed.usage.completion_tokens == 5 + assert parsed.usage.total_tokens == 15 + + +class TestResponseCreateResponseOutputText: + """Test that output_text aggregates text from message items in output.""" + + def test_output_text_aggregates_content(self) -> None: + payload: dict[str, Any] = { + **MINIMAL_RESPONSE, + "output": [ + {"type": "message", "role": "assistant", "content": "Hello "}, + {"type": "message", "role": "assistant", "content": "world."}, + ], + } + parsed = parse_obj(ResponseCreateResponse, payload) + assert parsed.output_text == "Hello world." + + def test_output_text_prefers_output_text_field(self) -> None: + payload: dict[str, Any] = { + **MINIMAL_RESPONSE, + "output": [ + { + "type": "message", + "role": "assistant", + "content": "raw", + "output_text": "aggregated", + }, + ], + } + parsed = parse_obj(ResponseCreateResponse, payload) + assert parsed.output_text == "aggregated" + + def test_output_text_skips_function_call_items(self) -> None: + payload: dict[str, Any] = { + **MINIMAL_RESPONSE, + "output": [ + {"type": "message", "role": "assistant", "content": "Here is "}, + { + "type": "function_call", + "id": "call_1", + "name": "get_weather", + "arguments": "{}", + }, + {"type": "message", "role": "assistant", "content": "the result."}, + ], + } + parsed = parse_obj(ResponseCreateResponse, payload) + assert parsed.output_text == "Here is the result." + + def test_output_text_empty_message_content_treated_as_empty(self) -> None: + payload: dict[str, Any] = { + **MINIMAL_RESPONSE, + "output": [ + {"type": "message", "role": "assistant", "content": None}, + {"type": "message", "role": "assistant", "output_text": "only this"}, + ], + } + parsed = parse_obj(ResponseCreateResponse, payload) + assert parsed.output_text == "only this" + + +class TestResponseOutputItemTypes: + """Test that output item types parse correctly.""" + + def test_message_item_parses(self) -> None: + msg = parse_obj(ResponseOutputMessage, {"type": "message", "role": "assistant", "content": "Hi"}) + assert msg.type == "message" + assert msg.role == "assistant" + assert msg.content == "Hi" + + def test_function_call_item_parses(self) -> None: + fc = parse_obj( + ResponseOutputFunctionCall, + { + "type": "function_call", + "id": "call_1", + "name": "foo", + "arguments": '{"x": 1}', + }, + ) + assert fc.type == "function_call" + assert fc.id == "call_1" + assert fc.name == "foo" + assert fc.arguments == '{"x": 1}'