fix(workflows): dispatch agent nodes by provider, not display label

/api/models reports `display_provider` when a catalog YAML sets one
(`foundry`, `azure_foundry`, `cloudflare`), and the builder persists that
string as a node's `llm_name`. The engine passed it straight to
LLMCreator, which only knows the names in PROVIDERS_BY_NAME and raises
`No LLM class found for type <label>`.

That fails the node before any LLM call, so the turn ends in ~60ms with
an empty answer and no tokens generated. It hits the *default* path: the
platform-default model's label is stamped into every newly dragged agent
node and validateWorkflow requires an agent node, so a new user's
untouched workflow could not produce a token regardless of what they
typed. Ordinary chat was unaffected because it resolves the provider from
the model registry and never reads the stored name.

Adds `resolve_dispatch_provider`, which prefers a stored name that is a
real dispatch provider, then the registry lookup, then the parent agent.
Nodes already saved with a label are repaired at run time, so no
migration is needed. The api_key now resolves from the normalized name
too — `get_api_key_for_provider` falls back to settings.API_KEY for names
it does not recognize, which would have sent the deployment key to
whatever endpoint the label happened to select.
This commit is contained in:
Alex committed 2026-08-05 11:24:59 +01:00
1 parent fcef3e68e5
commit f5129a5656
3 files changed
+155 -7

No files matched your search

+93
View File
@@ -507,3 +507,96 @@ class TestWorkflowEngineAdditionalCoverage:
# A non-dict schema triggers JsonSchemaValidationError
with pytest.raises(ValueError, match="Invalid JSON schema"):
engine._normalize_node_json_schema("not_a_dict", "TestNode")
class TestAgentNodeProviderResolution:
"""``llm_name`` stored on a node is a *display* label, not a dispatch name.
``/api/models`` reports ``display_provider`` (e.g. ``foundry``,
``azure_foundry``, ``cloudflare``) and the builder stores that string on
the node. Handing it to ``LLMCreator`` raises ``No LLM class found for
type <label>``, which fails the node before any LLM call and returns a
blank answer to the user. The engine must resolve the real dispatch
provider from the model registry instead.
"""
@staticmethod
def _run(monkeypatch, node, *, registry_provider="openai_compatible"):
"""Execute one agent node, returning the kwargs the factory saw."""
engine = create_engine()
engine.state["query"] = "test"
captured = {}
def _capture(**kwargs):
captured.update(kwargs)
return StubNodeAgent([{"answer": "ok"}])
monkeypatch.setattr(
WorkflowNodeAgentFactory, "create", staticmethod(_capture)
)
monkeypatch.setattr(
"application.core.model_utils.get_api_key_for_provider",
lambda name: f"key-for-{name}",
)
monkeypatch.setattr(
"application.core.model_utils.get_provider_from_model_id",
lambda _, **_kwargs: registry_provider,
)
monkeypatch.setattr(
"application.core.model_utils.get_model_capabilities",
lambda _, **_kwargs: None,
)
list(engine._execute_agent_node(node))
return captured
@pytest.mark.parametrize(
"display_label", ["azure_foundry", "cloudflare", "foundry"]
)
def test_display_provider_label_resolves_to_dispatch_provider(
self, monkeypatch, display_label
):
"""A display label must not reach LLMCreator (err#33)."""
node = create_agent_node(node_id="n1")
node.config["model_id"] = "Kimi-K2.6"
node.config["llm_name"] = display_label
captured = self._run(monkeypatch, node)
assert captured["llm_name"] == "openai_compatible"
# The api_key must follow the *normalized* name: resolving against the
# display label silently falls through to settings.API_KEY.
assert captured["api_key"] == "key-for-openai_compatible"
def test_real_provider_name_is_preserved(self, monkeypatch):
"""A node storing a genuine dispatch name keeps it."""
node = create_agent_node(node_id="n2")
node.config["model_id"] = "gpt-4o"
node.config["llm_name"] = "openai"
captured = self._run(monkeypatch, node, registry_provider="openai")
assert captured["llm_name"] == "openai"
def test_unresolvable_label_falls_back_to_parent_agent(self, monkeypatch):
"""No registry hit: inherit the parent agent rather than dispatching junk."""
node = create_agent_node(node_id="n3")
node.config["model_id"] = "mystery-model"
node.config["llm_name"] = "some_unknown_label"
captured = self._run(monkeypatch, node, registry_provider=None)
# create_engine()'s parent agent is llm_name="openai"
assert captured["llm_name"] == "openai"
def test_retriever_config_uses_normalized_provider(self, monkeypatch):
"""The agentic retriever path builds its own kwargs — normalize there too."""
node = create_agent_node(node_id="n4")
node.config["model_id"] = "Kimi-K2.6"
node.config["llm_name"] = "azure_foundry"
node.config["agent_type"] = "agentic"
node.config["sources"] = ["src-1"]
captured = self._run(monkeypatch, node)
assert captured["retriever_config"]["llm_name"] == "openai_compatible"
assert captured["retriever_config"]["api_key"] == "key-for-openai_compatible"