Files
darth-veitcher-comfydv/tests/test_llamacpp.py
T
James VeitchandClaude Sonnet 5 09a173e552 fix: retry blank/failed LLM responses with a new seed and backoff
Reported live on a fresh runpod: structured_output extraction on
ChatCompletion would fail with

  RuntimeError: chat_structured: response failed validation against
  schema after 3 attempt(s) ... Last error: Exceeded maximum output
  retries (0)

...but succeed if a plain (non-structured) chat call with the same
prompt was run first. Root cause: pydantic-ai's Agent is built with
retries=0 (comfydv drives its own outer retry loop, not pydantic-ai's
internal one), so a single bad/empty tool-call response instantly
exhausts one attempt. comfydv's own retry loop then retried with the
*exact same request* every time — if the model is genuinely stuck
(e.g. still warming up on a freshly-started backend) or produces a
degenerate response for a given seed, every attempt fails identically.
Confirmed the seed was unset in the reporting user's workflow (Ollama
picks its own per call) and it still failed 3/3 in a row, which rules
out "stuck on one bad seed" and points at request-level determinism
plus zero backoff between attempts as the real gap.

Plain chat() had zero retry/validation logic at all before this change
— it silently returned whatever content came back, even blank. Given
the underlying failure mode (a fresh model's first response sometimes
being blank) is identical for both chat modes, this fix applies to
both, not just structured output.

Adds src/comfydv/_llm/retry.py: next_seed() (deterministic, starting
from any user-pinned options["seed"], incrementing per retry — attempt
1 is always the caller's original, untouched request) and
RETRY_BACKOFF_SECS (a flat delay between retries, giving a still-
loading model time to finish rather than hammering it with identical
requests back-to-back).

Both OllamaProvider.chat() and LlamaCppProvider.chat() now retry (per
the node's existing max_retries widget) when a response comes back
blank, injecting the incremented seed each retry — Ollama via the
native options.seed field, llama.cpp via the OpenAI spec's top-level
"seed" field (nesting it under "options" like the rest of the
Ollama-native passthrough would silently not work — llama-server's
OpenAI-compatible endpoint doesn't read seed from there, a pre-existing,
documented non-goal for general options passthrough that doesn't apply
to this internal retry mechanism). Neither raises if every retry stays
blank — matches chat()'s existing never-validates contract.

chat_structured() in _llm/chat.py now injects the same incrementing
seed via pydantic-ai's ModelSettings["seed"] (which correctly maps to
the OpenAI API's top-level seed param for both backends) and adds the
same backoff between attempts, on top of its existing validation-retry
loop.

Live-verified against real Ollama: the happy path (model responds
normally first try) still makes exactly one network call for chat()
and completes chat_structured() normally — no regression from the new
retry scaffolding. The specific cold/stuck-model failure itself is
runpod-cold-start-dependent and wasn't independently reproducible in
this session's environment (a second live attempt succeeded on the
very first try) — unit tests directly exercise the retry/seed/backoff
logic instead (test_llm_retry.py, and new cases in
test_ollama_provider.py, test_llamacpp_provider.py, and
test_llm_chat_structured.py), verified against both providers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132ojafeazQ3ephcBejEWFj
2026-07-20 08:49:35 +01:00

140 lines
5.2 KiB
Python

"""
Tests for comfydv.llamacpp.LlamaCppClient — the one new ComfyUI node this
feature introduces. Also proves the adapter pattern end-to-end (US4): the
same generic nodes work unmodified against either provider.
BDD coverage:
../specs/008-llamacpp-integration/features/us1_connect_and_chat.feature
../specs/008-llamacpp-integration/features/us4_swap_backends.feature
"""
from comfydv._llm.llamacpp_provider import LlamaCppProvider
from comfydv._llm.ollama_provider import OllamaProvider
from comfydv.llamacpp import LlamaCppClient
from comfydv.ollama import (
ChatCompletion,
LLMLoadModel,
LLMModelSelector,
LLMUnloadModel,
)
class _FakeProvider:
"""Mirrors tests/test_ollama.py's _FakeProvider — reused here for US4's
swap-backends proof rather than duplicated, since the whole point is
that node behavior doesn't depend on which concrete provider it gets."""
def __init__(self, chat_response="ok"):
self.chat_response = chat_response
self.models = [{"name": "m", "status": "loaded"}]
self.calls: list[tuple] = []
async def list_models(self):
self.calls.append(("list_models",))
return self.models
async def load_model(self, model):
self.calls.append(("load_model", model))
async def unload_model(self, model):
self.calls.append(("unload_model", model))
async def chat(
self, model, messages, options=None, timeout_secs=300.0, max_retries=2
):
self.calls.append(("chat", model))
return self.chat_response
def test_client_outputs_llamacpp_provider():
(client,) = LlamaCppClient().create_client("http://localhost:8080")
assert isinstance(client, LlamaCppProvider)
assert client.host == "http://localhost:8080"
def test_client_default_host_matches_llama_server_default_port():
input_types = LlamaCppClient.INPUT_TYPES()
assert input_types["required"]["host"][1]["default"] == "http://localhost:8080"
def test_client_output_type_is_generic_llm_client():
assert LlamaCppClient.RETURN_TYPES == ("LLM_CLIENT",)
def test_client_carries_headers():
(client,) = LlamaCppClient().create_client(
"http://localhost:8080", headers={"Authorization": "Bearer abc"}
)
assert client.headers == {"Authorization": "Bearer abc"}
def test_node_contract():
assert hasattr(LlamaCppClient, "INPUT_TYPES")
assert hasattr(LlamaCppClient, "RETURN_TYPES")
assert hasattr(LlamaCppClient, "FUNCTION")
assert hasattr(LlamaCppClient, "CATEGORY")
assert hasattr(LlamaCppClient, LlamaCppClient.FUNCTION)
def test_registered_in_node_class_mappings():
from comfydv import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
assert NODE_CLASS_MAPPINGS["LlamaCppClient"] is LlamaCppClient
assert "LlamaCppClient" in NODE_DISPLAY_NAME_MAPPINGS
# ---------------------------------------------------------------------------
# US4 — swap backends without touching downstream nodes
# ---------------------------------------------------------------------------
def _run_workflow(client) -> None:
"""The same node sequence a workflow author would wire up, regardless
of which provider `client` is."""
ChatCompletion().chat(client=client, model="m", prompt="hi")
LLMModelSelector().select_model(client=client, model="m")
LLMLoadModel().load_model(client=client, model="m")
LLMUnloadModel().unload_model(client=client, model="m")
def test_same_workflow_runs_against_either_fake_provider():
"""No node branches on provider type — the same call sequence succeeds
whether client looks like an Ollama-shaped or llama.cpp-shaped provider."""
ollama_like = _FakeProvider(chat_response="ollama says hi")
llamacpp_like = _FakeProvider(chat_response="llamacpp says hi")
# Neither call raises — that's the actual assertion. If ChatCompletion/
# LLMModelSelector/LLMLoadModel/LLMUnloadModel secretly special-cased a
# concrete provider type (isinstance checks, attribute probing beyond
# the protocol), one of these would fail.
_run_workflow(ollama_like)
_run_workflow(llamacpp_like)
# LLMModelSelector is pure passthrough (client is accepted only for
# wiring/typing, never dereferenced), so it makes no provider call.
expected = ["chat", "load_model", "unload_model"]
assert [c[0] for c in ollama_like.calls] == expected
assert [c[0] for c in llamacpp_like.calls] == expected
def test_real_providers_are_interchangeable_client_output():
"""OllamaClient and LlamaCppClient both emit LLM_CLIENT — a workflow
author can wire either one into the same downstream nodes."""
from comfydv.ollama import OllamaClient
(ollama_client,) = OllamaClient().create_client("http://localhost:11434")
(llamacpp_client,) = LlamaCppClient().create_client("http://localhost:8080")
assert isinstance(ollama_client, OllamaProvider)
assert isinstance(llamacpp_client, LlamaCppProvider)
# Both satisfy the same protocol shape — same method names available.
for method in (
"list_models",
"load_model",
"unload_model",
"chat",
"chat_structured",
):
assert callable(getattr(ollama_client, method))
assert callable(getattr(llamacpp_client, method))