Skip to content

Commit de4e42e

Browse files
committed
update readme and add integration test for structured output
1 parent a221682 commit de4e42e

File tree

5 files changed

+151
-6
lines changed

5 files changed

+151
-6
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ A single interface to use and evaluate different llm providers.
2121

2222
## [Supported Providers](https://mozilla-ai.github.io/any-llm/providers)
2323

24-
## Why Does this exist?
24+
## Motivation
2525

2626
The landscape of LLM provider interfaces presents a fragmented ecosystem with several challenges that `any-llm` aims to address:
2727

@@ -34,13 +34,15 @@ While the OpenAI API has become the de facto standard for LLM provider interface
3434
- **[LiteLLM](https://github.com/BerriAI/litellm)**: While popular, it reimplements provider interfaces rather than leveraging official SDKs, which can lead to compatibility issues and unexpected behavior modifications
3535
- **[AISuite](https://github.com/andrewyng/aisuite/issues)**: Offers a clean, modular approach but lacks active maintenance, comprehensive testing, and modern Python typing standards.
3636
- **[Framework-specific solutions](https://github.com/agno-agi/agno/tree/main/libs/agno/agno/models)**: Some agent frameworks either depend on LiteLLM or implement their own provider integrations, creating fragmentation
37+
- **[Proxy Only Solutions](https://openrouter.ai/): solutions like OpenRouter require a hosted proxy to serve as the interface between your code and the LLM provider. `any-llm` allows you to communicate directly with the LLM provider without the need for a hosted proxy.
3738

3839
**Our Approach:**
3940

4041
`any-llm` fills the gap by providing a simple, well-maintained interface that:
4142
- **Leverages official provider SDKs** when available, reducing maintenance burden and ensuring compatibility
4243
- **Stays framework-agnostic** so it can be used across different projects and use cases
4344
- **Provides active maintenance** we support this in our product ([any-llm](https://github.com/mozilla-ai/any-llm)) so we're motivated to maintain it.
45+
- **No Proxy or Gateway server required** so you don't need to deal with setting up any other service to talk to whichever LLM provider you need.
4446

4547

4648

src/any_llm/providers/google/google.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
msg = "google-genai is not installed. Please install it with `pip install any-llm-sdk[google]`"
1010
raise ImportError(msg)
1111

12+
from pydantic import BaseModel
13+
1214
from openai.types.chat.chat_completion import ChatCompletion
1315
from any_llm.provider import Provider, ApiConfig
1416
from any_llm.exceptions import MissingApiKeyError
@@ -97,6 +99,41 @@ def _convert_messages(messages: list[dict[str, Any]]) -> list[types.Content]:
9799
return formatted_messages
98100

99101

102+
def _convert_pydantic_to_google_json(
103+
pydantic_model: type[BaseModel], messages: list[dict[str, Any]]
104+
) -> list[dict[str, Any]]:
105+
"""
106+
Convert Pydantic model to Google-compatible JSON instructions.
107+
108+
Following a similar pattern to the DeepSeek provider but adapted for Google.
109+
110+
Returns:
111+
modified_messages
112+
"""
113+
# Get the JSON schema from the Pydantic model
114+
schema = pydantic_model.model_json_schema()
115+
116+
# Add JSON instructions to the last user message
117+
modified_messages = messages.copy()
118+
if modified_messages and modified_messages[-1]["role"] == "user":
119+
original_content = modified_messages[-1]["content"]
120+
json_instruction = f"""
121+
Please respond with a JSON object that matches the following schema:
122+
123+
{json.dumps(schema, indent=2)}
124+
125+
Return the JSON object only, no other text, do not wrap it in ```json or ```.
126+
127+
{original_content}
128+
"""
129+
modified_messages[-1]["content"] = json_instruction
130+
else:
131+
msg = "Last message is not a user message"
132+
raise ValueError(msg)
133+
134+
return modified_messages
135+
136+
100137
class GoogleProvider(Provider):
101138
"""Google Provider using the new response conversion utilities."""
102139

@@ -133,8 +170,15 @@ def completion(
133170
**kwargs: Any,
134171
) -> ChatCompletion:
135172
"""Create a chat completion using Google GenAI."""
136-
# Remove unsupported parameters
137-
kwargs = remove_unsupported_params(kwargs, ["response_format", "parallel_tool_calls"])
173+
# Handle response_format for Pydantic models
174+
if "response_format" in kwargs:
175+
response_format = kwargs.pop("response_format")
176+
if isinstance(response_format, type) and issubclass(response_format, BaseModel):
177+
# Convert Pydantic model to Google JSON format
178+
messages = _convert_pydantic_to_google_json(response_format, messages)
179+
180+
# Remove other unsupported parameters
181+
kwargs = remove_unsupported_params(kwargs, ["parallel_tool_calls"])
138182

139183
# Convert tools if present
140184
tools = None

src/any_llm/providers/groq/groq.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import json
23
from typing import Any
34

45
try:
@@ -7,6 +8,8 @@
78
msg = "groq is not installed. Please install it with `pip install any-llm-sdk[groq]`"
89
raise ImportError(msg)
910

11+
from pydantic import BaseModel
12+
1013
from openai.types.chat.chat_completion import ChatCompletion
1114
from any_llm.provider import Provider, ApiConfig
1215
from any_llm.exceptions import MissingApiKeyError
@@ -32,6 +35,28 @@ def completion(
3235
**kwargs: Any,
3336
) -> ChatCompletion:
3437
"""Create a chat completion using Groq."""
38+
# Handle response_format for Pydantic models
39+
if "response_format" in kwargs:
40+
response_format = kwargs["response_format"]
41+
if isinstance(response_format, type) and issubclass(response_format, BaseModel):
42+
# Convert Pydantic model to JSON schema format for Groq
43+
schema = response_format.model_json_schema()
44+
kwargs["response_format"] = {"type": "json_object"}
45+
46+
# Add JSON instruction to the last user message (required by Groq)
47+
if messages and messages[-1]["role"] == "user":
48+
original_content = messages[-1]["content"]
49+
json_instruction = f"""
50+
Please respond with a JSON object that matches the following schema:
51+
52+
{json.dumps(schema, indent=2)}
53+
54+
Return the JSON object only, no other text.
55+
56+
{original_content}
57+
"""
58+
messages[-1]["content"] = json_instruction
59+
3560
# Clean messages (remove refusal field as per original implementation)
3661
cleaned_messages = []
3762
for message in messages:

src/any_llm/providers/huggingface/huggingface.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import json
23
from typing import Any
34

45
try:
@@ -7,6 +8,8 @@
78
msg = "huggingface-hub is not installed. Please install it with `pip install any-llm-sdk[huggingface]`"
89
raise ImportError(msg)
910

11+
from pydantic import BaseModel
12+
1013
from openai.types.chat.chat_completion import ChatCompletion
1114
from any_llm.provider import Provider, ApiConfig
1215
from any_llm.exceptions import MissingApiKeyError
@@ -16,6 +19,43 @@
1619
)
1720

1821

22+
def _convert_pydantic_to_huggingface_json(
23+
pydantic_model: type[BaseModel], messages: list[dict[str, Any]]
24+
) -> list[dict[str, Any]]:
25+
"""
26+
Convert Pydantic model to HuggingFace-compatible JSON instructions.
27+
28+
Following a similar pattern to the DeepSeek provider but adapted for HuggingFace.
29+
30+
Returns:
31+
modified_messages
32+
"""
33+
# Get the JSON schema from the Pydantic model
34+
schema = pydantic_model.model_json_schema()
35+
36+
# Add JSON instructions to the last user message
37+
modified_messages = messages.copy()
38+
if modified_messages and modified_messages[-1]["role"] == "user":
39+
original_content = modified_messages[-1]["content"]
40+
json_instruction = f"""Answer the following question and format your response as a JSON object matching this schema:
41+
42+
Schema: {json.dumps(schema, indent=2)}
43+
44+
DO NOT return the schema itself. Instead, answer the question and put your answer in the correct JSON format.
45+
46+
For example, if the question asks for a name and you want to answer "Paris", return: {{"name": "Paris"}}
47+
48+
Question: {original_content}
49+
50+
Answer (as JSON):"""
51+
modified_messages[-1]["content"] = json_instruction
52+
else:
53+
msg = "Last message is not a user message"
54+
raise ValueError(msg)
55+
56+
return modified_messages
57+
58+
1959
class HuggingfaceProvider(Provider):
2060
"""HuggingFace Provider using the new response conversion utilities."""
2161

@@ -39,8 +79,15 @@ def completion(
3979
if "max_tokens" in kwargs:
4080
kwargs["max_new_tokens"] = kwargs.pop("max_tokens")
4181

42-
# Remove unsupported parameters
43-
kwargs = remove_unsupported_params(kwargs, ["response_format", "parallel_tool_calls"])
82+
# Handle response_format for Pydantic models
83+
if "response_format" in kwargs:
84+
response_format = kwargs.pop("response_format")
85+
if isinstance(response_format, type) and issubclass(response_format, BaseModel):
86+
# Convert Pydantic model to HuggingFace JSON format
87+
messages = _convert_pydantic_to_huggingface_json(response_format, messages)
88+
89+
# Remove other unsupported parameters
90+
kwargs = remove_unsupported_params(kwargs, ["parallel_tool_calls"])
4491

4592
# Ensure message content is always a string and handle tool calls
4693
cleaned_messages = []

tests/integration/test_completion.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import httpx
2+
from pydantic import BaseModel
23
import pytest
34
from any_llm import completion
45
from any_llm.exceptions import MissingApiKeyError
@@ -17,7 +18,7 @@
1718
"xai": "xai-3-70b-instruct",
1819
"inception": "inception-3-70b-instruct",
1920
"nebius": "nebius-3-70b-instruct",
20-
"ollama": "llama3.1:8b",
21+
"ollama": "llama3.2:3b",
2122
"azure": "gpt-4o",
2223
"cohere": "command-r-20240215",
2324
"cerebras": "llama3.1-8b",
@@ -41,3 +42,29 @@ def test_providers(provider: str) -> None:
4142
pytest.skip("Ollama is not set up, skipping")
4243
raise
4344
assert result.choices[0].message.content is not None
45+
46+
47+
def test_response_format(provider: str) -> None:
48+
"""Test that all supported providers can be loaded successfully."""
49+
if provider == "anthropic":
50+
pytest.skip("Anthropic does not support response_format")
51+
return
52+
model_id = provider_model_map[provider]
53+
54+
class ResponseFormat(BaseModel):
55+
name: str
56+
57+
prompt = "What is the capital of France?"
58+
try:
59+
result = completion(
60+
f"{provider}/{model_id}", messages=[{"role": "user", "content": prompt}], response_format=ResponseFormat
61+
)
62+
assert result.choices[0].message.content is not None
63+
output = ResponseFormat.model_validate_json(result.choices[0].message.content)
64+
assert output.name == "Paris"
65+
except MissingApiKeyError:
66+
pytest.skip(f"{provider} API key not provided, skipping")
67+
except (httpx.HTTPStatusError, httpx.ConnectError):
68+
if provider == "ollama":
69+
pytest.skip("Ollama is not set up, skipping")
70+
raise

0 commit comments

Comments
 (0)