fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
144 lines
6.1 KiB
Python
144 lines
6.1 KiB
Python
"""Regression tests for cross-provider fallback tool-call parsing.
|
|
|
|
``BaseLLM`` performs model fallback *below* the agent: when a primary
|
|
model (e.g. Google) fails, ``BaseLLM._stream_with_fallback`` transparently
|
|
fails over to a backup model (e.g. an OpenAI-compatible deployment) inside
|
|
the same ``gen_stream`` call. The agent, however, picked its
|
|
``LLMHandler`` from the *primary* provider and never switches it.
|
|
|
|
Before the fix the Google handler tried to ``parse_response`` the
|
|
OpenAI-format chunks the backup emits; an OpenAI streaming ``choice`` has
|
|
neither ``candidates`` nor ``function_call``, so the Google parser returned
|
|
an empty ``finish_reason="stop"`` response — silently dropping the tool
|
|
call. The visible symptom: the agent streams its first text and then stops
|
|
instead of running the tool loop.
|
|
|
|
These tests pin down that ``parse_response`` follows the model that
|
|
actually responded (tracked via ``BaseLLM._responding_provider``).
|
|
"""
|
|
|
|
from types import SimpleNamespace
|
|
|
|
from application.llm.handlers.google import GoogleLLMHandler
|
|
from application.llm.handlers.openai import OpenAILLMHandler
|
|
|
|
|
|
def _openai_tool_chunk(name, arguments, call_id="call_1", finish_reason="tool_calls"):
|
|
"""Build an OpenAI-style streaming ``choice`` carrying a tool call.
|
|
|
|
Mirrors what ``OpenAILLM._raw_gen_stream`` yields for a tool call: a
|
|
``choice`` object with a ``.delta.tool_calls`` list and a
|
|
``finish_reason``.
|
|
"""
|
|
fn = SimpleNamespace(name=name, arguments=arguments)
|
|
tc = SimpleNamespace(id=call_id, function=fn, index=0, type="function")
|
|
delta = SimpleNamespace(content=None, tool_calls=[tc])
|
|
return SimpleNamespace(delta=delta, finish_reason=finish_reason)
|
|
|
|
|
|
def _agent_with_responder(provider):
|
|
return SimpleNamespace(llm=SimpleNamespace(_responding_provider=provider))
|
|
|
|
|
|
class TestParseRouting:
|
|
"""``_parse_for_response`` resolves the handler from the responding
|
|
provider, not the one the handler was constructed for."""
|
|
|
|
def test_documents_the_bug_google_parser_drops_openai_tool_call(self):
|
|
# Baseline: the Google parser cannot read an OpenAI choice. This is
|
|
# exactly why a naive (un-routed) parse loses the tool call.
|
|
chunk = _openai_tool_chunk("search", '{"q": "x"}')
|
|
parsed = GoogleLLMHandler().parse_response(chunk)
|
|
assert parsed.tool_calls == []
|
|
assert parsed.finish_reason == "stop"
|
|
|
|
def test_google_handler_routes_openai_chunk_to_openai_parser(self):
|
|
handler = GoogleLLMHandler()
|
|
agent = _agent_with_responder("openai")
|
|
chunk = _openai_tool_chunk("search", '{"q": "x"}')
|
|
|
|
parsed = handler._parse_for_response(agent, chunk)
|
|
|
|
assert parsed.finish_reason == "tool_calls"
|
|
assert [tc.name for tc in parsed.tool_calls] == ["search"]
|
|
assert parsed.tool_calls[0].arguments == '{"q": "x"}'
|
|
|
|
def test_handler_for_provider_reuses_self_when_provider_matches(self):
|
|
handler = GoogleLLMHandler()
|
|
# Same provider family → reuse self (no behaviour change on the
|
|
# common no-fallback path).
|
|
assert handler._handler_for_provider("google") is handler
|
|
|
|
def test_handler_for_provider_returns_openai_for_openai(self):
|
|
handler = GoogleLLMHandler()
|
|
resolved = handler._handler_for_provider("openai")
|
|
assert isinstance(resolved, OpenAILLMHandler)
|
|
assert resolved is not handler
|
|
# Cached: same instance on the second lookup.
|
|
assert handler._handler_for_provider("openai") is resolved
|
|
|
|
def test_non_string_provider_falls_back_to_self_parse(self):
|
|
# A Mock/None responder (or any code path that never set the
|
|
# attribute) must not trigger routing — parse with self.
|
|
handler = OpenAILLMHandler()
|
|
agent = SimpleNamespace(llm=SimpleNamespace()) # no _responding_provider
|
|
chunk = _openai_tool_chunk("search", "{}")
|
|
parsed = handler._parse_for_response(agent, chunk)
|
|
assert [tc.name for tc in parsed.tool_calls] == ["search"]
|
|
|
|
|
|
class TestStreamingLoopContinuesUnderFallback:
|
|
"""End-to-end: ``handle_streaming`` must run the tool loop when the
|
|
responding model is a different provider than the handler's own."""
|
|
|
|
def _make_agent(self, follow_up_chunks):
|
|
agent = SimpleNamespace()
|
|
agent.model_id = "primary-model"
|
|
agent.tools = []
|
|
agent.context_limit_reached = False
|
|
agent._check_context_limit = lambda *a, **k: False
|
|
agent._pending_continuation = None
|
|
|
|
llm = SimpleNamespace()
|
|
llm.model_id = "backup-model"
|
|
# Responding model is OpenAI-compatible even though the handler is
|
|
# Google — this is the cross-provider fallback condition.
|
|
llm._responding_provider = "openai"
|
|
llm.gen_stream = lambda **kwargs: iter(follow_up_chunks)
|
|
agent.llm = llm
|
|
|
|
tool_executor = SimpleNamespace()
|
|
tool_executor.check_pause = lambda *a, **k: None
|
|
tool_executor._name_to_tool = {}
|
|
agent.tool_executor = tool_executor
|
|
|
|
executed = []
|
|
|
|
def fake_execute(tools_dict, call):
|
|
executed.append(call)
|
|
yield {"type": "tool_call", "data": {"status": "pending"}}
|
|
return ("tool result", call.id)
|
|
|
|
agent._execute_tool_action = fake_execute
|
|
agent._executed_calls = executed
|
|
return agent
|
|
|
|
def test_tool_call_executed_not_dropped(self):
|
|
handler = GoogleLLMHandler()
|
|
# The backup (OpenAI) streams a bit of text, then a tool call.
|
|
stream = [
|
|
"Let me search for relevant provisions",
|
|
_openai_tool_chunk("search", '{"q": "hipoteka"}'),
|
|
]
|
|
# After the tool runs, the follow-up turn just finishes with text.
|
|
agent = self._make_agent(follow_up_chunks=["Here is the answer."])
|
|
|
|
results = list(handler.handle_streaming(agent, iter(stream), {"1": {"name": "t"}}, []))
|
|
|
|
# The tool call survived the cross-provider boundary and executed.
|
|
assert len(agent._executed_calls) == 1
|
|
assert agent._executed_calls[0].name == "search"
|
|
# The loop continued: first text + follow-up text both streamed.
|
|
assert "Let me search for relevant provisions" in results
|
|
assert "Here is the answer." in results
|