Skip to content

Commit 46696da

Browse files
committed
Enable streaming when response_format is set for gemini, openai, and anthropic completion endpoints.
1 parent b4e00c1 commit 46696da

6 files changed

Lines changed: 135 additions & 45 deletions

File tree

src/any_llm/providers/anthropic/utils.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -335,13 +335,14 @@ def _convert_tool_spec(openai_tools: list[dict[str, Any]]) -> list[dict[str, Any
335335

336336
anthropic_tools = []
337337
for tool in generic_tools:
338+
params: dict[str, Any] = tool["parameters"] or {}
338339
anthropic_tool = {
339340
"name": tool["name"],
340341
"description": tool["description"],
341342
"input_schema": {
342343
"type": "object",
343-
"properties": tool["parameters"].get("properties") or {},
344-
"required": tool["parameters"].get("required", []),
344+
"properties": params.get("properties") or {},
345+
"required": params.get("required", []),
345346
},
346347
}
347348
anthropic_tools.append(anthropic_tool)
@@ -395,9 +396,6 @@ def _convert_params(params: CompletionParams, **kwargs: Any) -> dict[str, Any]:
395396
result_kwargs: dict[str, Any] = kwargs.copy()
396397

397398
if params.response_format:
398-
if params.stream:
399-
msg = "stream and response_format"
400-
raise UnsupportedParameterError(msg, provider_name)
401399
result_kwargs["output_config"] = _convert_response_format(params.response_format, provider_name)
402400
if params.max_tokens is None:
403401
logger.warning(f"max_tokens is required for Anthropic, setting to {DEFAULT_MAX_TOKENS}")

src/any_llm/providers/gemini/base.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,6 @@ def _convert_completion_params(params: CompletionParams, **kwargs: Any) -> dict[
124124
if params.parallel_tool_calls is not None:
125125
error_message = "parallel_tool_calls"
126126
raise UnsupportedParameterError(error_message, provider_name)
127-
if params.stream and params.response_format is not None:
128-
error_message = "stream and response_format"
129-
raise UnsupportedParameterError(error_message, provider_name)
130127

131128
if params.frequency_penalty is not None:
132129
kwargs["frequency_penalty"] = params.frequency_penalty

src/any_llm/providers/openai/base.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,9 @@ def _convert_completion_params(params: CompletionParams, **kwargs: Any) -> dict[
8282
Plain dataclasses are converted to JSON schema dicts since the
8383
OpenAI SDK's ``.parse()`` only supports Pydantic BaseModel types.
8484
"""
85-
if is_structured_output_type(params.response_format) and not issubclass(params.response_format, BaseModel):
85+
if is_structured_output_type(params.response_format) and (
86+
params.stream or not issubclass(params.response_format, BaseModel)
87+
):
8688
params.response_format = {
8789
"type": "json_schema",
8890
"json_schema": {
@@ -202,10 +204,7 @@ async def _acompletion(
202204
response_format = completion_kwargs.get("response_format")
203205
use_parse = is_structured_output_type(response_format)
204206

205-
if response_format:
206-
if params.stream:
207-
msg = "stream is not supported for response_format"
208-
raise ValueError(msg)
207+
if response_format and not params.stream:
209208
completion_kwargs.pop("stream", None)
210209

211210
if use_parse:

tests/unit/providers/test_anthropic_provider.py

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import dataclasses
2-
from contextlib import contextmanager
2+
from collections.abc import AsyncIterator
3+
from contextlib import asynccontextmanager, contextmanager
34
from datetime import UTC
4-
from typing import Any, get_args
5+
from typing import Any, cast, get_args
56
from unittest.mock import AsyncMock, Mock, patch
67

78
import pytest
@@ -626,23 +627,50 @@ async def test_completion_with_response_format_dict_json_object_raises() -> None
626627

627628

628629
@pytest.mark.asyncio
629-
async def test_stream_with_response_format_raises() -> None:
630+
async def test_stream_with_response_format_passes_output_config() -> None:
630631
api_key = "test-api-key"
631632
model = "model-id"
632633
messages = [{"role": "user", "content": "Hello"}]
634+
schema = {"type": "object", "properties": {"city_name": {"type": "string"}}, "required": ["city_name"]}
635+
response_format: dict[str, Any] = {
636+
"type": "json_schema",
637+
"json_schema": {"name": "Foo", "schema": schema},
638+
}
633639

634-
provider = AnthropicProvider(api_key=api_key)
640+
async def empty_events() -> AsyncIterator[Any]:
641+
if False:
642+
yield None
635643

636-
with pytest.raises(UnsupportedParameterError, match="stream and response_format"):
637-
await provider._acompletion(
638-
CompletionParams(
639-
model_id=model,
640-
messages=messages,
641-
response_format={"type": "json_schema", "json_schema": {"name": "Foo", "schema": {}}},
642-
stream=True,
643-
)
644+
@asynccontextmanager
645+
async def empty_stream() -> AsyncIterator[AsyncIterator[Any]]:
646+
yield empty_events()
647+
648+
with mock_anthropic_provider() as mock_anthropic:
649+
mock_anthropic.return_value.messages.stream = Mock(return_value=empty_stream())
650+
provider = AnthropicProvider(api_key=api_key)
651+
stream = cast(
652+
"AsyncIterator[Any]",
653+
await provider._acompletion(
654+
CompletionParams(
655+
model_id=model,
656+
messages=messages,
657+
response_format=response_format,
658+
stream=True,
659+
)
660+
),
644661
)
645662

663+
async for _ in stream:
664+
pass
665+
666+
expected_output_config = {"format": {"type": "json_schema", "schema": transform_schema(schema)}}
667+
mock_anthropic.return_value.messages.stream.assert_called_once()
668+
call_kwargs = mock_anthropic.return_value.messages.stream.call_args.kwargs
669+
assert call_kwargs["model"] == model
670+
assert call_kwargs["messages"] == messages
671+
assert call_kwargs["max_tokens"] == DEFAULT_MAX_TOKENS
672+
assert call_kwargs["output_config"] == expected_output_config
673+
646674

647675
@pytest.mark.asyncio
648676
async def test_completion_with_response_format_dict_unknown_type_raises() -> None:

tests/unit/providers/test_gemini_provider.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -392,22 +392,44 @@ async def test_completion_with_unsupported_dict_response_format_raises() -> None
392392

393393

394394
@pytest.mark.asyncio
395-
async def test_completion_with_stream_and_response_format_raises() -> None:
395+
async def test_stream_with_response_format_passes_generation_config() -> None:
396396
api_key = "test-api-key"
397397
model = "gemini-pro"
398398
messages = [{"role": "user", "content": "Hello"}]
399+
expected_schema: dict[str, Any] = {
400+
"type": "object",
401+
"properties": {
402+
"name": {"type": "string"},
403+
"value": {"type": "integer"},
404+
},
405+
"required": ["name", "value"],
406+
}
407+
response_format: dict[str, Any] = {
408+
"type": "json_schema",
409+
"json_schema": {
410+
"name": "TestOutput",
411+
"schema": expected_schema,
412+
},
413+
}
399414

400-
with mock_gemini_provider():
415+
with mock_gemini_provider() as mock_genai:
416+
mock_genai.return_value.aio.models.generate_content_stream = AsyncMock()
401417
provider = GeminiProvider(api_key=api_key)
402-
with pytest.raises(UnsupportedParameterError):
403-
await provider._acompletion(
404-
CompletionParams(
405-
model_id=model,
406-
messages=messages,
407-
stream=True,
408-
response_format={"type": "json_object"},
409-
)
418+
await provider._acompletion(
419+
CompletionParams(
420+
model_id=model,
421+
messages=messages,
422+
stream=True,
423+
response_format=response_format,
410424
)
425+
)
426+
427+
_, call_kwargs = mock_genai.return_value.aio.models.generate_content_stream.call_args
428+
generation_config = call_kwargs["config"]
429+
430+
assert call_kwargs["model"] == model
431+
assert generation_config.response_mime_type == "application/json"
432+
assert generation_config.response_schema == expected_schema
411433

412434

413435
@pytest.mark.asyncio

tests/unit/providers/test_openai_base_provider.py

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -224,24 +224,70 @@ class ListModelsProvider(BaseOpenAIProvider):
224224

225225

226226
@pytest.mark.asyncio
227-
async def test_stream_with_response_format_raises() -> None:
227+
async def test_stream_with_dict_response_format_uses_create() -> None:
228228
class TestProvider(BaseOpenAIProvider):
229229
PROVIDER_NAME = "TestProvider"
230230
ENV_API_KEY_NAME = "TEST_API_KEY"
231231
PROVIDER_DOCUMENTATION_URL = "https://example.com"
232232

233-
with patch("any_llm.providers.openai.base.AsyncOpenAI"):
233+
response_format = {"type": "json_object"}
234+
235+
with patch("any_llm.providers.openai.base.AsyncOpenAI") as mock_openai_class:
236+
mock_client = AsyncMock()
237+
mock_openai_class.return_value = mock_client
238+
mock_client.chat.completions.create = AsyncMock(return_value=MagicMock())
239+
234240
provider = TestProvider(api_key="test-key")
235241

236-
with pytest.raises(ValueError, match="stream is not supported for response_format"):
237-
await provider._acompletion(
238-
CompletionParams(
239-
model_id="test-model",
240-
messages=[{"role": "user", "content": "Hello"}],
241-
stream=True,
242-
response_format={"type": "json_object"},
243-
)
242+
await provider._acompletion(
243+
CompletionParams(
244+
model_id="test-model",
245+
messages=[{"role": "user", "content": "Hello"}],
246+
stream=True,
247+
response_format=response_format,
244248
)
249+
)
250+
251+
mock_client.chat.completions.create.assert_awaited_once_with(
252+
model="test-model",
253+
messages=[{"role": "user", "content": "Hello"}],
254+
stream=True,
255+
response_format=response_format,
256+
)
257+
mock_client.chat.completions.parse.assert_not_called()
258+
259+
260+
@pytest.mark.asyncio
261+
async def test_stream_with_basemodel_response_format_uses_create_json_schema() -> None:
262+
class TestProvider(BaseOpenAIProvider):
263+
PROVIDER_NAME = "TestProvider"
264+
ENV_API_KEY_NAME = "TEST_API_KEY"
265+
PROVIDER_DOCUMENTATION_URL = "https://example.com"
266+
267+
with patch("any_llm.providers.openai.base.AsyncOpenAI") as mock_openai_class:
268+
mock_client = AsyncMock()
269+
mock_openai_class.return_value = mock_client
270+
mock_client.chat.completions.create = AsyncMock(return_value=MagicMock())
271+
272+
provider = TestProvider(api_key="test-key")
273+
274+
await provider._acompletion(
275+
CompletionParams(
276+
model_id="test-model",
277+
messages=[{"role": "user", "content": "Hello"}],
278+
stream=True,
279+
response_format=_City,
280+
)
281+
)
282+
283+
create_kwargs = mock_client.chat.completions.create.call_args.kwargs
284+
assert create_kwargs["stream"] is True
285+
assert create_kwargs["response_format"]["type"] == "json_schema"
286+
assert create_kwargs["response_format"]["json_schema"]["name"] == "_City"
287+
assert create_kwargs["response_format"]["json_schema"]["schema"]["properties"] == {
288+
"city_name": {"title": "City Name", "type": "string"}
289+
}
290+
mock_client.chat.completions.parse.assert_not_called()
245291

246292

247293
def test_base_provider_maps_max_tokens_to_max_completion_tokens() -> None:

0 commit comments

Comments
 (0)