Files
arc53--docsgpt/tests/llm/handlers/test_cross_provider_parse_routing.py
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

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