From dbb3a4d95b258890dd97478daf52b09717e148ed Mon Sep 17 00:00:00 2001 From: theQuert Date: Wed, 7 Oct 2026 08:18:36 +0800 Subject: [PATCH] feat(chat): add parallel_tool_calls and max_completion_tokens Both are top-level OpenAI chat-completions fields. Until now callers had to pass them through extra_body, which this SDK sends as a nested extra_body object rather than merging it into the request. Closes #4 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uy5vXsRpMhug7v3XwTMZHv --- README.md | 4 ++ apertis/resources/chat/completions.py | 29 ++++++++++++ tests/test_chat_advanced.py | 68 +++++++++++++++++++++++++++ 3 files changed, 101 insertions(+) diff --git a/README.md b/README.md index ecc0e66..6fd323c 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,7 @@ response = client.chat.completions.create( } }], tool_choice="auto", + parallel_tool_calls=True, # let the model call several tools in one turn ) if response.choices[0].message.tool_calls: @@ -550,6 +551,9 @@ the `publish` confirmation value. ## Changelog +### Unreleased +- Add `parallel_tool_calls` and `max_completion_tokens` to `chat.completions.create()`. + ### v0.3.0 - Add the current Apertis Web Search request fields and typed top-level sources while retaining legacy Web Search options for compatibility. diff --git a/apertis/resources/chat/completions.py b/apertis/resources/chat/completions.py index 18e1e9e..721ce14 100644 --- a/apertis/resources/chat/completions.py +++ b/apertis/resources/chat/completions.py @@ -43,6 +43,7 @@ def create( stream: Literal[True], temperature: float | None = None, max_tokens: int | None = None, + max_completion_tokens: int | None = None, top_p: float | None = None, frequency_penalty: float | None = None, presence_penalty: float | None = None, @@ -50,6 +51,7 @@ def create( seed: int | None = None, tools: Sequence[ChatCompletionToolParam] | None = None, tool_choice: str | dict[str, Any] | None = None, + parallel_tool_calls: bool | None = None, response_format: dict[str, Any] | None = None, user: str | None = None, logprobs: bool | None = None, @@ -85,6 +87,7 @@ def create( stream: Literal[False] = False, temperature: float | None = None, max_tokens: int | None = None, + max_completion_tokens: int | None = None, top_p: float | None = None, frequency_penalty: float | None = None, presence_penalty: float | None = None, @@ -92,6 +95,7 @@ def create( seed: int | None = None, tools: Sequence[ChatCompletionToolParam] | None = None, tool_choice: str | dict[str, Any] | None = None, + parallel_tool_calls: bool | None = None, response_format: dict[str, Any] | None = None, user: str | None = None, logprobs: bool | None = None, @@ -127,6 +131,7 @@ def create( stream: bool = False, temperature: float | None = None, max_tokens: int | None = None, + max_completion_tokens: int | None = None, top_p: float | None = None, frequency_penalty: float | None = None, presence_penalty: float | None = None, @@ -134,6 +139,7 @@ def create( seed: int | None = None, tools: Sequence[ChatCompletionToolParam] | None = None, tool_choice: str | dict[str, Any] | None = None, + parallel_tool_calls: bool | None = None, response_format: dict[str, Any] | None = None, user: str | None = None, logprobs: bool | None = None, @@ -168,6 +174,7 @@ def create( stream: bool = False, temperature: float | None = None, max_tokens: int | None = None, + max_completion_tokens: int | None = None, top_p: float | None = None, frequency_penalty: float | None = None, presence_penalty: float | None = None, @@ -175,6 +182,7 @@ def create( seed: int | None = None, tools: Sequence[ChatCompletionToolParam] | None = None, tool_choice: str | dict[str, Any] | None = None, + parallel_tool_calls: bool | None = None, response_format: dict[str, Any] | None = None, user: str | None = None, logprobs: bool | None = None, @@ -208,6 +216,8 @@ def create( stream: If True, returns a streaming response. temperature: Sampling temperature between 0 and 2. max_tokens: Maximum number of tokens to generate. + max_completion_tokens: Upper bound on generated tokens, including + reasoning tokens (newer OpenAI models use this instead of max_tokens). top_p: Nucleus sampling parameter. frequency_penalty: Penalty for token frequency. presence_penalty: Penalty for token presence. @@ -215,6 +225,7 @@ def create( seed: Random seed for deterministic results. tools: A list of tools the model may call. tool_choice: Controls which tool is called. + parallel_tool_calls: Whether the model may call several tools in one turn. response_format: Format specification for the response. user: A unique identifier for the end-user. logprobs: Whether to return log probabilities. @@ -239,6 +250,7 @@ def create( stream=stream, temperature=temperature, max_tokens=max_tokens, + max_completion_tokens=max_completion_tokens, top_p=top_p, frequency_penalty=frequency_penalty, presence_penalty=presence_penalty, @@ -246,6 +258,7 @@ def create( seed=seed, tools=tools, tool_choice=tool_choice, + parallel_tool_calls=parallel_tool_calls, response_format=response_format, user=user, logprobs=logprobs, @@ -456,6 +469,7 @@ async def create( stream: Literal[True], temperature: float | None = None, max_tokens: int | None = None, + max_completion_tokens: int | None = None, top_p: float | None = None, frequency_penalty: float | None = None, presence_penalty: float | None = None, @@ -463,6 +477,7 @@ async def create( seed: int | None = None, tools: Sequence[ChatCompletionToolParam] | None = None, tool_choice: str | dict[str, Any] | None = None, + parallel_tool_calls: bool | None = None, response_format: dict[str, Any] | None = None, user: str | None = None, logprobs: bool | None = None, @@ -491,6 +506,7 @@ async def create( stream: Literal[False] = False, temperature: float | None = None, max_tokens: int | None = None, + max_completion_tokens: int | None = None, top_p: float | None = None, frequency_penalty: float | None = None, presence_penalty: float | None = None, @@ -498,6 +514,7 @@ async def create( seed: int | None = None, tools: Sequence[ChatCompletionToolParam] | None = None, tool_choice: str | dict[str, Any] | None = None, + parallel_tool_calls: bool | None = None, response_format: dict[str, Any] | None = None, user: str | None = None, logprobs: bool | None = None, @@ -526,6 +543,7 @@ async def create( stream: bool = False, temperature: float | None = None, max_tokens: int | None = None, + max_completion_tokens: int | None = None, top_p: float | None = None, frequency_penalty: float | None = None, presence_penalty: float | None = None, @@ -533,6 +551,7 @@ async def create( seed: int | None = None, tools: Sequence[ChatCompletionToolParam] | None = None, tool_choice: str | dict[str, Any] | None = None, + parallel_tool_calls: bool | None = None, response_format: dict[str, Any] | None = None, user: str | None = None, logprobs: bool | None = None, @@ -560,6 +579,7 @@ async def create( stream: bool = False, temperature: float | None = None, max_tokens: int | None = None, + max_completion_tokens: int | None = None, top_p: float | None = None, frequency_penalty: float | None = None, presence_penalty: float | None = None, @@ -567,6 +587,7 @@ async def create( seed: int | None = None, tools: Sequence[ChatCompletionToolParam] | None = None, tool_choice: str | dict[str, Any] | None = None, + parallel_tool_calls: bool | None = None, response_format: dict[str, Any] | None = None, user: str | None = None, logprobs: bool | None = None, @@ -595,6 +616,7 @@ async def create( stream=stream, temperature=temperature, max_tokens=max_tokens, + max_completion_tokens=max_completion_tokens, top_p=top_p, frequency_penalty=frequency_penalty, presence_penalty=presence_penalty, @@ -602,6 +624,7 @@ async def create( seed=seed, tools=tools, tool_choice=tool_choice, + parallel_tool_calls=parallel_tool_calls, response_format=response_format, user=user, logprobs=logprobs, @@ -762,6 +785,7 @@ def _build_request_body( stream: bool, temperature: float | None, max_tokens: int | None, + max_completion_tokens: int | None, top_p: float | None, frequency_penalty: float | None, presence_penalty: float | None, @@ -769,6 +793,7 @@ def _build_request_body( seed: int | None, tools: Sequence[ChatCompletionToolParam] | None, tool_choice: str | dict[str, Any] | None, + parallel_tool_calls: bool | None, response_format: dict[str, Any] | None, user: str | None, logprobs: bool | None, @@ -799,6 +824,8 @@ def _build_request_body( body["temperature"] = temperature if max_tokens is not None: body["max_tokens"] = max_tokens + if max_completion_tokens is not None: + body["max_completion_tokens"] = max_completion_tokens if top_p is not None: body["top_p"] = top_p if frequency_penalty is not None: @@ -813,6 +840,8 @@ def _build_request_body( body["tools"] = list(tools) if tool_choice is not None: body["tool_choice"] = tool_choice + if parallel_tool_calls is not None: + body["parallel_tool_calls"] = parallel_tool_calls if response_format is not None: body["response_format"] = response_format if user is not None: diff --git a/tests/test_chat_advanced.py b/tests/test_chat_advanced.py index 8f180b1..c8aa26e 100644 --- a/tests/test_chat_advanced.py +++ b/tests/test_chat_advanced.py @@ -422,3 +422,71 @@ def test_stream_options_include_usage(self, client: Apertis) -> None: assert response.usage is not None assert response.usage.total_tokens == 15 + + +_TOOL_CALL_COMPLETION = { + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1234567890, + "model": "gpt-5.4", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Done."}, + "finish_reason": "stop", + } + ], +} + + +class TestToolCallingParams: + """parallel_tool_calls and max_completion_tokens are top-level request fields.""" + + @respx.mock + def test_sync_sends_top_level_fields(self, client: Apertis) -> None: + route = respx.post("https://api.apertis.ai/v1/chat/completions").mock( + return_value=httpx.Response(200, json=_TOOL_CALL_COMPLETION) + ) + + client.chat.completions.create( + model="gpt-5.4", + messages=[{"role": "user", "content": "Weather in Paris and Tokyo?"}], + parallel_tool_calls=False, + max_completion_tokens=256, + ) + + body = json.loads(route.calls[0].request.content) + assert body["parallel_tool_calls"] is False + assert body["max_completion_tokens"] == 256 + assert "extra_body" not in body + + @respx.mock + async def test_async_sends_top_level_fields(self, async_client: AsyncApertis) -> None: + route = respx.post("https://api.apertis.ai/v1/chat/completions").mock( + return_value=httpx.Response(200, json=_TOOL_CALL_COMPLETION) + ) + + await async_client.chat.completions.create( + model="gpt-5.4", + messages=[{"role": "user", "content": "Weather in Paris and Tokyo?"}], + parallel_tool_calls=True, + max_completion_tokens=256, + ) + + body = json.loads(route.calls[0].request.content) + assert body["parallel_tool_calls"] is True + assert body["max_completion_tokens"] == 256 + + @respx.mock + def test_omitted_when_unset(self, client: Apertis) -> None: + route = respx.post("https://api.apertis.ai/v1/chat/completions").mock( + return_value=httpx.Response(200, json=_TOOL_CALL_COMPLETION) + ) + + client.chat.completions.create( + model="gpt-5.4", messages=[{"role": "user", "content": "Hi"}] + ) + + body = json.loads(route.calls[0].request.content) + assert "parallel_tool_calls" not in body + assert "max_completion_tokens" not in body