From 4c27bcaca2467c1c5e4ef7c8d7f179fbe6accaa8 Mon Sep 17 00:00:00 2001 From: Anish Mehta Date: Thu, 24 Sep 2026 23:05:55 +0530 Subject: [PATCH] Python: fix(providers): send a single stop string as a list in Gemini, Bedrock, Anthropic and Ollama --- .../agent_framework_anthropic/_chat_client.py | 3 +++ .../anthropic/tests/test_anthropic_client.py | 13 +++++++++++++ .../agent_framework_bedrock/_chat_client.py | 3 ++- .../packages/bedrock/tests/test_bedrock_client.py | 10 ++++++++++ .../gemini/agent_framework_gemini/_chat_client.py | 3 +++ python/packages/gemini/tests/test_gemini_client.py | 14 ++++++++++++++ .../ollama/agent_framework_ollama/_chat_client.py | 3 +++ .../ollama/tests/test_ollama_chat_client.py | 10 ++++++++++ 8 files changed, 58 insertions(+), 1 deletion(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index b3b0458457c..78c8ece70ca 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -223,6 +223,9 @@ def _apply_option_translations(options: dict[str, Any]) -> None: if old_key not in options or old_key == new_key: continue old_value = options.pop(old_key) + if old_key == "stop" and isinstance(old_value, str): + # ChatOptions allows a single stop string; stop_sequences takes a list. + old_value = [old_value] options.setdefault(new_key, old_value) diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 24597877291..34c2c31ffa4 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -4192,3 +4192,16 @@ def get_test_image() -> Content: assert len(response.text) > 0 # sample_image.jpg contains a photo of a house; the model should mention it. assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}" + + +def test_option_translations_single_stop_string_becomes_list() -> None: + """stop_sequences is an array in the Anthropic API, so a single stop string is wrapped.""" + from agent_framework_anthropic._chat_client import _apply_option_translations + + options: dict[str, Any] = {"stop": "END"} + _apply_option_translations(options) + assert options == {"stop_sequences": ["END"]} + + options = {"stop": ["END", "STOP"]} + _apply_option_translations(options) + assert options == {"stop_sequences": ["END", "STOP"]} diff --git a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py index 5f5a5beb92f..75362c019ce 100644 --- a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py +++ b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py @@ -425,7 +425,8 @@ def _prepare_options( if (top_p := options.get("top_p")) is not None: run_options["inferenceConfig"]["topP"] = top_p if (stop := options.get("stop")) is not None: - run_options["inferenceConfig"]["stopSequences"] = stop + # ChatOptions allows a single stop string; stopSequences takes a list. + run_options["inferenceConfig"]["stopSequences"] = [stop] if isinstance(stop, str) else stop tool_config = self._prepare_tools(options.get("tools")) if tool_mode := validate_tool_mode(options.get("tool_choice")): diff --git a/python/packages/bedrock/tests/test_bedrock_client.py b/python/packages/bedrock/tests/test_bedrock_client.py index a2bc39c2984..c1667075104 100644 --- a/python/packages/bedrock/tests/test_bedrock_client.py +++ b/python/packages/bedrock/tests/test_bedrock_client.py @@ -455,6 +455,16 @@ def test_prepare_options_adds_instructions_and_sampling_settings() -> None: } +def test_prepare_options_single_stop_string_becomes_list() -> None: + """stopSequences only accepts a list, so a single stop string is wrapped.""" + client = _make_client() + messages = [Message(role="user", contents=[Content.from_text(text="hello")])] + + request = client._prepare_options(messages, {"stop": "DONE"}) + + assert request["inferenceConfig"]["stopSequences"] == ["DONE"] + + def test_prepare_options_unsupported_tool_mode_raises(monkeypatch: pytest.MonkeyPatch) -> None: """Unexpected tool modes should raise a clear error.""" from agent_framework_bedrock import _chat_client as chat_client_module diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index a6c6796b418..f12f17650a0 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -948,6 +948,9 @@ def _prepare_config( for key, value in options.items(): if key in _OPTION_EXCLUDE_KEYS or value is None: continue + if key == "stop" and isinstance(value, str): + # ChatOptions allows a single stop string; stop_sequences takes a list. + value = [value] kwargs[_OPTION_TRANSLATIONS.get(key, key)] = value response_format = options.get("response_format") diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index a2b4908d7ad..95a633430bc 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -1468,6 +1468,20 @@ async def test_prepare_config_stop_sequences() -> None: assert config.stop_sequences == ["END", "STOP"] +async def test_prepare_config_stop_single_string() -> None: + """A single stop string is sent as a one-item stop_sequences list.""" + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={"stop": "END"}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert config.stop_sequences == ["END"] + + async def test_prepare_config_seed() -> None: """Forwards the seed option to GenerateContentConfig.seed for reproducible outputs.""" client, mock = _make_gemini_client() diff --git a/python/packages/ollama/agent_framework_ollama/_chat_client.py b/python/packages/ollama/agent_framework_ollama/_chat_client.py index f8c7a2232ec..60c6a314b97 100644 --- a/python/packages/ollama/agent_framework_ollama/_chat_client.py +++ b/python/packages/ollama/agent_framework_ollama/_chat_client.py @@ -419,6 +419,9 @@ def _prepare_options(self, messages: Sequence[Message], options: Mapping[str, An if key in OLLAMA_MODEL_OPTIONS: # Apply model option translations (e.g., max_tokens -> num_predict) translated_key = OLLAMA_MODEL_OPTION_TRANSLATIONS.get(key, key) + if key == "stop" and isinstance(value, str): + # ChatOptions allows a single stop string; Ollama takes a list. + value = [value] model_options[translated_key] = value else: # Apply top-level translations (e.g., response_format -> format) diff --git a/python/packages/ollama/tests/test_ollama_chat_client.py b/python/packages/ollama/tests/test_ollama_chat_client.py index e393e808980..9f00c73b608 100644 --- a/python/packages/ollama/tests/test_ollama_chat_client.py +++ b/python/packages/ollama/tests/test_ollama_chat_client.py @@ -828,3 +828,13 @@ def test_mixed_policy_approval_roles_preserve_tool_result(self) -> None: assert [message.role for message in prepared] == ["tool", "assistant"] assert prepared[0].content == "safe result" + + +def test_prepare_options_single_stop_string_becomes_list(ollama_unit_test_env: dict[str, str]) -> None: + """Ollama expects options.stop to be a list, so a single stop string is wrapped.""" + client = OllamaChatClient() + messages = [Message(role="user", contents=[Content.from_text(text="hello")])] + + request = client._prepare_options(messages, {"stop": "END"}) + + assert request["options"]["stop"] == ["END"]