Skip to content

Commit 1b53aac

Browse files
authored
fix: stop max_retries leaking to bare SDK client in _run_tool_loop (#1850)
1 parent 5a19e54 commit 1b53aac

2 files changed

Lines changed: 61 additions & 0 deletions

File tree

lumen/ai/llm.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,10 @@ async def _run_tool_loop(
431431
max_tool_rounds: int = 16,
432432
**kwargs
433433
) -> BaseModel | str:
434+
# ``max_retries`` is consumed by the instructor wrapper; on bare-client
435+
# paths it leaks to the SDK and raises ``TypeError``. Pop once and
436+
# re-attach only to the instructor (response_model) round-trip.
437+
max_retries = kwargs.pop("max_retries", None)
434438
requested_stream = bool(kwargs.get("stream", False))
435439
if requested_stream and structured_model is None:
436440
# In stream mode we can't know upfront whether a tool will be called.
@@ -442,6 +446,8 @@ async def _run_tool_loop(
442446
stream = False
443447
if structured_model is not None and not tool_instances:
444448
kwargs["response_model"] = structured_model
449+
if max_retries is not None:
450+
kwargs["max_retries"] = max_retries
445451
else:
446452
if tool_instances:
447453
stream = kwargs.pop("stream", False)
@@ -470,6 +476,8 @@ async def _run_tool_loop(
470476

471477
if structured_model:
472478
kwargs["response_model"] = structured_model
479+
if max_retries is not None:
480+
kwargs["max_retries"] = max_retries
473481
output = await self.run_client(model_spec, messages_curr, stream=stream, **kwargs)
474482
return output
475483

@@ -1290,6 +1298,7 @@ async def _run_tool_loop(
12901298
messages, structured_model, tool_instances, tool_contexts, model_spec, max_tool_rounds, **kwargs
12911299
)
12921300

1301+
max_retries = kwargs.pop("max_retries", None)
12931302
requested_stream = bool(kwargs.get("stream", False))
12941303
if requested_stream and structured_model is None:
12951304
kwargs.pop("response_model", None)
@@ -1304,6 +1313,8 @@ async def _run_tool_loop(
13041313
# can ask for the structured response in a single round-trip.
13051314
if structured_model is not None and not tool_instances and not has_inbuilt:
13061315
kwargs["response_model"] = structured_model
1316+
if max_retries is not None:
1317+
kwargs["max_retries"] = max_retries
13071318
else:
13081319
kwargs.pop("response_model", None)
13091320

@@ -1332,6 +1343,8 @@ async def _run_tool_loop(
13321343
if structured_model:
13331344
final_kwargs = dict(kwargs)
13341345
final_kwargs["response_model"] = structured_model
1346+
if max_retries is not None:
1347+
final_kwargs["max_retries"] = max_retries
13351348
response_id = getattr(output, "id", None)
13361349
if response_id:
13371350
final_kwargs["previous_response_id"] = response_id

lumen/tests/ai/test_llm.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
pytest.skip("lumen.ai could not be imported, skipping tests.", allow_module_level=True)
2121

2222
from instructor.processing.multimodal import Image
23+
from pydantic import BaseModel
2324

2425
# ---------------------------------------------------------------------------
2526
# Helpers
@@ -331,6 +332,53 @@ def test_openai_responses_stream_tool_call_id_preserved_from_added_event():
331332
OpenAI._accumulate_tool_calls(accum, order, OpenAI._extract_stream_tool_calls(done_event))
332333
tool_calls = OpenAI._tool_calls_from_accum(accum, order)
333334
assert tool_calls[0]["id"] == "call_abc"
335+
336+
337+
async def test_run_tool_loop_drops_max_retries_on_bare_client(monkeypatch):
338+
"""Regression: ``max_retries`` is an instructor-only kwarg.
339+
340+
When both ``response_model`` and ``tools`` are supplied (the planner's
341+
path), ``_run_tool_loop`` drops ``response_model`` and routes to the bare
342+
SDK client. Before the fix, ``max_retries`` was left in ``kwargs`` and
343+
leaked through to ``AsyncMessages.create`` / ``AsyncCompletions.create``,
344+
raising ``TypeError: got an unexpected keyword argument 'max_retries'``.
345+
"""
346+
347+
class _Plan(BaseModel):
348+
next_step: str
349+
350+
llm = OpenAI(model_kwargs={"default": {"model": "gpt-4.1-mini"}})
351+
calls: list[dict] = []
352+
353+
async def fake_run_client(_model_spec, _messages, **kwargs):
354+
calls.append(dict(kwargs))
355+
return SimpleNamespace(content="done", tool_calls=None)
356+
357+
monkeypatch.setattr(llm, "run_client", fake_run_client)
358+
monkeypatch.setattr(llm, "_extract_tool_calls", lambda _output: [])
359+
360+
await llm._run_tool_loop(
361+
messages=[{"role": "user", "content": "hi"}],
362+
structured_model=_Plan,
363+
tool_instances={"lookup": object()},
364+
tool_contexts={},
365+
max_retries=3,
366+
)
367+
# The first call uses the bare client (no response_model). It must not
368+
# carry max_retries through, since the bare SDK rejects it.
369+
bare_call = calls[0]
370+
assert "response_model" not in bare_call
371+
assert "max_retries" not in bare_call, (
372+
"max_retries must be popped from kwargs on the bare-client path; "
373+
"it is consumed by the instructor wrapper, not the underlying SDK."
374+
)
375+
# Final structured-output call (instructor) should still carry the
376+
# user-passed max_retries so retry semantics are preserved end to end.
377+
final_call = calls[-1]
378+
assert final_call.get("response_model") is _Plan
379+
assert final_call.get("max_retries") == 3
380+
381+
334382
# ---------------------------------------------------------------------------
335383
# _normalize_multimodal_messages tests
336384
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)