Repository navigation
bug(ollama): Pydantic-model tool parameters reach the model without their fields #1696
Description
What users see
On the Ollama backend, which start_session() uses by default, a tool parameter typed as a Pydantic model reaches the model with none of its fields. The model is told only that the parameter is an object (or, for Model | None, nothing but its description), so it has to guess the field names and nesting.
When it guesses wrong, the call fails validation and lenient mode hands the guessed arguments to the tool anyway. The failure surfaces when the tool runs (a TypeError for unexpected keyword arguments, or a dict missing the fields the tool reads), so it looks like a bug in the user's code. Whether a given tool works depends on the model and the prompt, not on anything the user controls. The same tool works through OpenAIBackend on the same Ollama server. A reproducer and fix checks are at the end.
Measured impact
Local Ollama 0.34.4, ollama Python client 0.6.1, prompt "Book a double room for Alice for 3 nights.", 5 samples per cell at temperature 0.7, counting calls whose booking argument validates as a Booking.
Controlled A/B, raw /api/chat requests identical except for the tool schema:
| Model | Schema as the client sends it | Full schema |
|---|---|---|
granite4.1:3b |
0/5 | 5/5 |
granite4.2:3b |
0/5 | 5/5 |
The failed calls used guest and duration instead of guest_name and nights.
Through mellea (OllamaModelBackend vs OpenAIBackend on Ollama's /v1 endpoint, same models):
| Model | Parameter | Native backend | OpenAIBackend → /v1 |
|---|---|---|---|
granite4.1:3b |
Booking |
0/5 (arguments sent flat, without the booking wrapper) |
5/5 |
granite4.2:3b |
Booking |
5/5 | 5/5 |
granite4.1:3b |
Booking | None |
5/5 | 5/5 |
granite4.2:3b |
Booking | None |
5/5 | 5/5 |
So the native backend works only when the model happens to guess the field names, which these names make fairly easy. We'd expect names that can't be inferred from the request to fail more often, but that wasn't measured.
Cause
Mellea builds the full schema. The Ollama backend passes it to the ollama client as a dict, and the client re-validates it into its own Tool model. That model's Property declares only type, items, description and enum and ignores everything else, so properties, required and anyOf are dropped before the request (0.6.1, and unchanged in 0.6.3). The Ollama server itself accepts all three (api.ToolProperty in 0.34.4).
What survives, per parameter shape:
list[Model]: intact, because the client leavesitemsuntyped.Model:{"type": "object", "description": ...}, no fields.Model | None, unions and discriminated unions: onlydescription, since the type lives inanyOf.dict[str, int]value types andFieldconstraints (minimum,maxLength, ...): dropped, but the Ollama server doesn't support those either, so they can't be fixed on this path.
Upstream
- Add self-referencing properties field to Tool.Property ollama/ollama-python#724 (open) reports the missing
properties. - fix: preserve nested tool properties ollama/ollama-python#725 (open, no review since 2026-09-05) adds
propertiesonly.requiredandanyOfwould still be dropped, soModel | Noneand unions stay broken after it merges. A dependency bump alone won't fix this.
Mitigation
Today, with no code change: use OpenAIBackend against Ollama's OpenAI-compatible endpoint, which sends the schema untouched. That's 5/5 in every configuration above.
from mellea.backends.openai import OpenAIBackend
backend = OpenAIBackend(model_id="granite4.1:3b", base_url="http://localhost:11434/v1", api_key="ollama")Options for mellea, roughly in order of cost:
- Document it. Add a note to
docs/docs/integrations/ollama.mdand the tools docs saying that Pydantic-model tool parameters need the/v1route on Ollama. The page already documents that route. - Warn at runtime. Have
OllamaModelBackendlog a warning, once per tool, when a parameter carriesproperties,requiredoranyOfthat the client will drop, pointing at the workaround. - Fix upstream, then bump. Get
requiredandanyOfadded alongside feat: citation requirement #725'sproperties(a small change, three fields plus tests), then raise mellea'sollamafloor to the first release carrying it and add a regression test on the outgoing request. - Change the default. Route the default Ollama path through the OpenAI-compatible endpoint. This fixes it without upstream, but changes behaviour for every Ollama user and needs its own assessment of what the native backend does that
/v1doesn't.
A patch inside the native backend is possible: pre-building the client's Tool objects with model_construct gets the full schema onto the wire. It relies on Pydantic's fallback serialization and logs a serialization warning on every request, so it isn't recommended.
Reproducing and testing a fix
Offline check (no model needed). This is what the ollama client turns mellea's schema into, and it's the assertion a regression test should make:
from typing import Literal
from pydantic import BaseModel
from ollama import Tool
from mellea.backends.tools import MelleaTool
class Booking(BaseModel):
guest_name: str
nights: int
room_type: Literal["single", "double", "suite"]
def book(booking: Booking, backup: Booking | None = None, extras: list[Booking] | None = None) -> str:
"""Book a hotel room.
Args:
booking: the booking details
backup: a fallback booking
extras: additional bookings
"""
return "ok"
spec = MelleaTool.from_callable(book).as_json_tool
sent = Tool.model_validate(spec).model_dump(exclude_none=True)["function"]["parameters"]["properties"]
for name in ("booking", "backup", "extras"):
print(name, "->", sent[name])Today it prints:
booking -> {'type': 'object', 'description': 'the booking details'}
backup -> {'description': 'a fallback booking'}
extras -> {'type': 'array', 'items': {'properties': {'guest_name': ..., 'nights': ..., 'room_type': ...}, ...}}
A fix passes when booking keeps its properties and required, and backup keeps its anyOf branches, as extras already does.
Live check (needs ollama serve and ollama pull granite4.1:3b). This runs the same tool through both mellea backends against the same server:
from typing import Literal
from pydantic import BaseModel, ValidationError
from mellea import MelleaSession
from mellea.backends import ModelOption
from mellea.backends.ollama import OllamaModelBackend
from mellea.backends.openai import OpenAIBackend
from mellea.backends.tools import MelleaTool
from mellea.stdlib.components import Message
from mellea.stdlib.context import ChatContext
MODEL = "granite4.1:3b"
class Booking(BaseModel):
guest_name: str
nights: int
room_type: Literal["single", "double", "suite"]
def book(booking: Booking) -> str:
"""Book a hotel room.
Args:
booking: the booking details
"""
return "ok"
tool = MelleaTool.from_callable(book)
backends = {
"native": lambda: OllamaModelBackend(model_id=MODEL),
"openai /v1": lambda: OpenAIBackend(model_id=MODEL, base_url="http://localhost:11434/v1", api_key="ollama"),
}
for label, make in backends.items():
ok = 0
for seed in range(5):
m = MelleaSession(make(), ctx=ChatContext())
out = m.act(
Message("user", "Book a double room for Alice for 3 nights."),
model_options={ModelOption.TOOLS: [tool], ModelOption.SEED: seed, ModelOption.TEMPERATURE: 0.7},
tool_calls=True,
)
call = next((c for c in out.tool_calls or [] if c.name == "book"), None)
try:
Booking.model_validate(call.args["booking"])
ok += 1
except (AttributeError, KeyError, TypeError, ValidationError):
print(f" {label} seed {seed}: tool would receive {call.args if call else None}")
print(f"{label}: {ok}/5 valid")Today it prints native: 0/5 valid, with each failure showing the tool would receive {'guest_name': 'Alice', 'nights': 3, 'room_type': 'double'} (no booking wrapper), and openai /v1: 5/5 valid. A fix passes when native matches openai /v1. Sampling makes this a smoke test, so keep the offline check as the regression gate.
Related
Found while working on #1693 / #1694, which make list, dict and constraint information reach the model on every backend. On Ollama that fix works for list[...] element types, but this issue keeps model-typed parameters from benefiting.
Activity
planetf1 commentedon Oct 5, 2026
Status: same root cause as #1693 — and open PR #1694 does not close this case
Verified against
mainat4c53add4e, plus a read of PR #1694's diff. The wire loss comes from thesimple_proprebuild inconvert_function_to_ollama_tool(mellea/backends/tools.py, the same site #1693 targets): a top-levelbooking: Bookingparameter schema is{type: "object", properties: {...}, required: [...]}, and the rebuild keeps onlydescription,type,enum,default— so the model receives{type: "object", description}and the field names never reach it.PR #1694 (open) extends the rebuild with
_CARRIED_SCHEMA_KEYWORDS—items,additionalProperties, min/max, pattern, and friends — which fixes the list/dict/constraint cases (#1693).propertiesandrequiredare not in that list, so the object-parameter case in this issue is unchanged by #1694:Bookingstill goes out field-less, exactly as the 0/5 vs 5/5 table in this issue shows.Worth noting: #1694's new test suite parametrises a top-level model case (
owner_tool, idmodel_field), but that test only asserts the rendered property contains nooneOf/discriminator/$ref— a property with no fields passes it trivially. The gap is therefore untested even inside #1694's suite.Proposed direction: carry
properties/required(with nested$defsinlined, which #1694's final inlining pass already does) onto object-typed rebuilt props, and add the missing assertion —_prop(owner_tool, "owner")["properties"]should contain the declared fields — so the case that motivated this issue is actually covered. Cleanest as a follow-up commit on #1694's branch (same function, same test file); a separate PR stacking on it is fine too.planetf1 commentedon Oct 9, 2026
Status (2026-10-09): correction to my 2026-10-05 note. Mellea's schema already carries
propertiesandrequiredfor model parameters; the wire loss is entirely client-side.The 2026-10-05 note said the loss comes from the
simple_proprebuild inconvert_function_to_ollama_tool. That is wrong: object-typed parameters never reach the rebuild. The second pass preserves the full schema for complex types (the"properties" in vbranch,mellea/backends/tools.py:1586), and that branch predates #1694, so it was present at4c53add4etoo.Verified by building the schemas on
mainat887fcde1e:booking: Bookingrenders{type: object, properties: {guest_name, nights, room_type}, required: [...]};Booking | Nonekeeps fullanyOfbranches;Ownerwith a nested discriminated union renders with the union flattened.So the direction I proposed (add
properties/requiredto_CARRIED_SCHEMA_KEYWORDS) would be a no-op; those keywords are only consulted in the simple-prop rebuild, which object params don't take. The remaining loss is exactly what the issue body diagnosed: theollamaPython client'sPropertymodel dropsproperties,requiredandanyOfbefore the request (mellea pinsollama>=0.5.1; the strip was verified through 0.6.3).That leaves the four mitigation options in the issue body as the real work (docs note, runtime warning, upstream fix plus floor bump, or routing the default through
/v1), which need a decision rather than a code patch.