fix(structured): harden structured output for local servers and thinking models

- Local servers (LM Studio, vLLM) reject the object-form tool_choice langchain
  sends for function calling. The generic openai_compatible provider now binds
  the schema as a tool without forcing tool_choice.
- A structured call can return no parsed result (a thinking model answering in
  plain text); fall back to free text with a clear reason instead of an opaque
  render error.
This commit is contained in:
Yijia-Xiao
2026-06-21 22:09:43 +00:00
parent 9ad98c55c5
commit 517eeaf4b9
4 changed files with 70 additions and 2 deletions

View File

@@ -36,7 +36,7 @@ def test_keyless_local_uses_placeholder_and_chat_completions(monkeypatch):
llm = create_llm_client(
provider="openai_compatible", model="qwen2.5", base_url="http://localhost:8000/v1"
).get_llm()
assert type(llm).__name__ == "NormalizedChatOpenAI"
assert type(llm).__name__ == "LocalCompatibleChatOpenAI"
assert str(llm.openai_api_base) == "http://localhost:8000/v1"
# keyless local servers: a placeholder key is sent
key = llm.openai_api_key.get_secret_value() if hasattr(llm.openai_api_key, "get_secret_value") else llm.openai_api_key
@@ -72,3 +72,29 @@ def test_env_backend_url_precedence():
assert resolve_backend_url("openai", "https://api.openai.com/v1", env_url="http://proxy/v1") == "http://proxy/v1"
assert resolve_backend_url("openai", "https://api.openai.com/v1", env_url=None) == "https://api.openai.com/v1"
assert resolve_backend_url("deepseek", None, None) == "https://api.deepseek.com"
@pytest.mark.unit
def test_structured_output_suppresses_object_tool_choice(monkeypatch):
# LM Studio / vLLM reject the object-form tool_choice langchain sends for
# function-calling structured output (#1057). The generic provider binds the
# schema as a tool but must not force tool_choice.
from langchain_openai import ChatOpenAI
from pydantic import BaseModel
class Schema(BaseModel):
x: int
captured = {}
monkeypatch.setattr(
ChatOpenAI,
"with_structured_output",
lambda self, schema, method=None, **kw: captured.update({"method": method, **kw}) or "BOUND",
)
llm = create_llm_client(
provider="openai_compatible", model="local-llm-30b", base_url="http://localhost:1234/v1"
).get_llm()
out = llm.with_structured_output(Schema)
assert out == "BOUND"
assert captured["method"] == "function_calling"
assert captured["tool_choice"] is None # not the object form

View File

@@ -121,6 +121,24 @@ def _structured_trader_llm(captured: dict, proposal: TraderProposal | None = Non
return llm
@pytest.mark.unit
def test_invoke_structured_falls_back_when_result_is_none():
# A thinking model can answer in plain text, leaving the parser with None.
# That must fall back to free text, not crash on render(None) (#1051).
from tradingagents.agents.utils.structured import invoke_structured_or_freetext
structured = MagicMock()
structured.invoke.return_value = None
plain = MagicMock()
plain.invoke.return_value = MagicMock(content="FREETEXT")
out = invoke_structured_or_freetext(
structured, plain, "prompt", render=lambda r: r.rating, agent_name="t"
)
assert out == "FREETEXT"
plain.invoke.assert_called_once()
@pytest.mark.unit
class TestTraderAgent:
def test_structured_path_produces_rendered_markdown(self):