Author SHA1 Message Date
James VeitchandClaude Sonnet 5 f16da579bc feat(ui): add output-text previews and live retry status to comfydv nodes
Fixes a real gap discovered this session: ChatCompletion/FormatString's
"ui": {"text": [...]} return value was never rendered anywhere — no JS in
this repo ever implemented an onExecuted handler to read it, so the
retry-status banner from the previous commit was invisible in practice.
RandomChoice never returned a ui payload at all (OUTPUT_NODE was False).

- src/js/preview_text.js: read-only STRING preview widget for
  ChatCompletion/FormatString/RandomChoice, mirroring ComfyUI core's own
  PreviewAny node/JS extension (confirmed via the actual bundled frontend
  source, not guessed) rather than inventing a new convention.
- RandomChoice: OUTPUT_NODE=True + a ui.text preview of its arbitrary-typed
  output (str/number passthrough, else JSON, else str() — same fallback
  chain as core's PreviewAny). IS_CHANGED still returns the raw picked
  value, not the new ui-wrapped dict, so change-detection is unaffected.
- Live retry feedback: chat()/chat_structured() gain an on_status
  callback, invoked at each retry boundary with a human-readable status
  (_llm/retry.py's format_retry_status/format_recovered_status).
  ChatCompletion wires this to ComfyUI's own PromptServer.send_progress_text
  (the same first-party mechanism core's gaussian-splat/image nodes use)
  rather than a hand-rolled custom event — the frontend already renders a
  live "progressText" widget for it, so no additional JS was needed for
  the live half of this feature.

Verified live against a real Ollama backend (docker-compose.dev.yml +
Playwright): forced a genuine refusal/retry via custom_phrases, confirmed
both the final preview widget and the live progress widget render
correctly on ChatCompletion, and confirmed FormatString/RandomChoice's
preview widgets render too. 410 tests passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 23:43:13 +01:00
James VeitchandClaude Sonnet 5 7a40d22230 feat(llm): escalate retry timeouts, expose seed_used, add retry UI feedback
Three follow-ups to the refusal-retry feature:

- Each retry's request timeout now escalates (next_timeout_secs: base *
  attempt) across every chat()/chat_structured() retry loop, instead of
  reusing the same budget that just ran out.
- LLMProvider.chat()/chat_structured() gain an optional attempt_info
  out-param (record_attempt_info), populated with the seed/timeout
  actually used and the attempt/refusal counts — backward compatible,
  no return-type change.
- ChatCompletion exposes a new seed_used output and prepends a status
  line to its existing text preview when a retry/refusal happened, so
  both are visible without any frontend/JS changes.

seed_used is appended as the LAST output (after any dynamic
structured-output fields), not inserted after model_name — dynamic
fields must keep starting at their existing index (3) so already-wired
workflows (workflows/ltx-i2v-pipeline.json, and the still-open PR #35
canvas-format file) that link a FormatString input to a ChatCompletion
field by output index aren't silently repointed at the wrong socket.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 22:19:22 +01:00
James VeitchandClaude Sonnet 5 42c614d5f8 feat(llm): add user-configurable custom_phrases to refusal detection
Ship a comma-separated custom_phrases field on OllamaOptionRefusalRetry
so a new deflection phrasing a specific model uses can be added at
runtime, without waiting on a code change/release. Each phrase is
checked as a free, case-insensitive substring match (no embedding
model required) and, when embedding_model is set, folded in as extra
exemplars for the similarity check too.

Also ship two more default patterns spotted this session: bare
"cannot generate" (no leading "I") and "I am/I'm restricted from" —
both verified against the existing ordinary-content regression cases
to confirm no new false positives.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 17:44:32 +01:00
James VeitchandClaude Sonnet 5 a0f391f212 fix(llm): catch bare "cannot generate" phrasing without a leading "I"
The lexical refusal pattern required an "I" before cannot/can't/won't
so a refusal fragment like "Cannot generate this image due to the
content" (no leading pronoun — the shape it takes as a JSON string
value or a mid-sentence fragment) went undetected. Made the "I"
prefix optional; the cannot/can't/won't-plus-nearby-generate/create/
etc. shape is specific enough that dropping the pronoun requirement
doesn't pick up ordinary content (verified against the existing
"ordinary content" regression cases).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 17:34:41 +01:00
James VeitchandClaude Sonnet 5 3c5136af70 fix(llm): stop gating the embedding refusal check on is_ambiguous
An explicitly configured embedding_model is an opt-in to pay for the
similarity check, but is_refusal still gated it on is_ambiguous — a
length/hedge-keyword heuristic that skips anything long or plainly
worded. For chat_structured() this checks the full serialized JSON
output, whose braces/field-name overhead alone often pushed responses
past the 400-char cutoff, so the embedding call silently never fired
for exactly the subtle, on-topic-looking deflections this feature was
built to catch.

Now embed_fn, once supplied, always runs on any non-blank response that
didn't already match the lexical pass. Also widened is_ambiguous's own
threshold/keyword list, since it remains a standalone tested heuristic
for other callers even though is_refusal no longer wires it in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 17:30:19 +01:00
James VeitchandClaude Sonnet 5 97249ee281 fix(llm): catch "I am unable to" phrasing in refusal detection
The lexical pattern only matched the "I'm unable to" contraction, so
spelled-out refusals like "I am unable to comply with your instruction"
fell through undetected without an embedding model configured.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 17:16:38 +01:00
James VeitchandClaude Sonnet 5 1f138e5991 feat(llm): detect refusal/deflection responses and retry with a new seed (#36)
Some models (observed with an abliterated Qwen variant) answer a request
they judge "politically sensitive" with hedging refusal language instead of
erroring or returning blank — neither of which the existing blank-response
or schema-validation retry triggers catch, so the refusal just passed
through as a normal result.

Adds a hybrid detector to _llm/retry.py: a free lexical/regex pass catches
blatant refusals ("I cannot generate...") without any network call; a
response that's short and/or hedge-y enough to be ambiguous additionally
gets an embedding-similarity check against canonical refusal exemplars, via
a new optional LLMProvider.embed() (Ollama's /api/embed, llama-server's
/v1/embeddings) — deliberately model-agnostic to the backend, since this is
a model-behavior concern, not a backend one. A detected refusal is treated
exactly like a blank response or validation failure: retried with a bumped
seed (existing next_seed()/RETRY_BACKOFF_SECS machinery), never returned to
the caller.

Wired into all four retry loops: OllamaProvider.chat()/chat_structured(),
LlamaCppProvider.chat(), and the shared _llm/chat.py chat_structured() used
by LlamaCppProvider.chat_structured(). New OllamaOptionRefusalRetry node
(same composable OLLAMA_OPTIONS chain as OllamaOptionDisableThinking)
configures it: enabled toggle, optional embedding_model (blank = lexical-
only), similarity threshold.


Claude-Session: https://claude.ai/code/session_01YArD9ZjBWKsAvazmS48amA

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 17:11:08 +01:00
James VeitchandClaude Sonnet 5 2724f535f5 fix(workflows): disable thinking by default in LTX-2.3 pipeline (#34)
Every ChatCompletion node in ltx-i2v-pipeline.json now reads its options
through a new OllamaOptionDisableThinking node (id 18, disable_thinking=True)
appended to the end of the existing max-tokens/extra-body chain. Without it,
a thinking-capable model routinely burns its whole max_tokens budget on
chain-of-thought and never emits the closing JSON, so chat_structured fails
validation against an empty string — confirmed live against
saracen9/amoral-qwen3.5-9b. README's thinking-headroom section updated to
match: thinking off by default, headroom advice now framed as what to do if
you deliberately re-enable it via node 18.


Claude-Session: https://claude.ai/code/session_01YArD9ZjBWKsAvazmS48amA

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 08:15:49 +01:00
James VeitchandClaude Sonnet 5 a613006f5a fix(ollama): bypass stale combo validation for LLM model widgets (#33)
LLMModelSelector and LLMLoadModel validated the model COMBO against
_DEFAULT_MODELS, a list fetched once at ComfyUI startup. Models pulled
into Ollama afterward showed up in the live-refreshed dropdown but
failed prompt validation with "value ... is not available" until the
server was restarted. VALIDATE_INPUTS now accepts any model string,
deferring to Ollama itself to reject an actually-missing model.


Claude-Session: https://claude.ai/code/session_01YArD9ZjBWKsAvazmS48amA

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 23:45:02 +01:00
James Veitch 56c7d4ca56 Merge pull request #32 from darth-veitcher/feat/disable-thinking-toggle
feat(ollama): add disable-thinking toggle for both providers
2026-07-25 10:38:17 +01:00
James VeitchandClaude Sonnet 5 cf7cfeb6aa feat(ollama): add disable-thinking toggle for both providers
Adds OllamaOptionDisableThinking, a composable node chaining into the
same OLLAMA_OPTIONS socket every other OllamaOption* node uses. Unlike
those (Ollama-native sampling params passed through verbatim), the
"think" key it emits is a comfydv-level convention: every LLMProvider
implementation pops it out of options and translates it to its own wire
shape before building a request, since neither backend recognizes a
literal "think" key nested inside a generic options object.

Confirmed live: Ollama's native /api/chat and OpenAI-compatible
/v1/chat/completions both silently ignore "think" nested in options —
it must be a top-level request field, or the model burns its whole
token budget on chain-of-thought reasoning before ever responding
(eval_count: 223 vs 2 in a direct comparison). llama.cpp's translation
(chat_template_kwargs/reasoning_effort) is sourced from llama-server's
documented request-body fields, not live-verified against a running
instance.

This also fixes two existing live integration tests that already passed
options={"think": False} under the mistaken assumption it worked — it
was a silent no-op until now.

See ADR-010 for the full design discussion, including why this ended up
as a composable option node rather than a new ChatCompletion input.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YArD9ZjBWKsAvazmS48amA
2026-07-25 10:37:46 +01:00
James Veitch c9aefef915 Merge pull request #31 from darth-veitcher/fix/ollama-structured-output-native-format
fix(ollama): route structured output through native /api/chat, fix nullable field validation
2026-07-25 08:26:04 +01:00
James VeitchandClaude Sonnet 5 f060698e7f feat(workflows): add LTX-2.3 multi-agent I2V pipeline example
Wires the 6-agent prompt-compiler pipeline from
project-management/Work/planning/ltx.md onto comfydv's existing generic
LLM nodes (OllamaClient, ChatCompletion, FormatString) — no new node code,
purely a wiring exercise. Verified end-to-end against a live local Ollama
server through the actual ComfyUI node graph (all 6 agents plus the final
judge/refiner output), which is what surfaced and validated the fixes in
the preceding commit.

workflows/README.md documents the deliberate adaptations from the
reference design (single-round Judge/Refiner since ComfyUI graphs can't
express the retry loop, JSON-string list handling for FormatString's
STRING-only dynamic sockets, etc.) and how the wiring was verified against
the real node source before ever touching a live server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YArD9ZjBWKsAvazmS48amA
2026-07-25 08:25:12 +01:00
James VeitchandClaude Sonnet 5 0f4b2234db fix(ollama): route structured output through native /api/chat, fix nullable field validation
Ollama's OpenAI-compatible endpoint silently reloads the model at its
default context size on every call, discarding any options.num_ctx
override — confirmed live even when the same options are included in
that request. OllamaProvider.chat_structured() now hand-rolls a call to
Ollama's native /api/chat + "format" instead of routing through the
shared pydantic-ai path, since that endpoint correctly preserves and
applies context-size options. LlamaCppProvider keeps the shared path
(llama-server's context is fixed at process launch, not per-request),
switched to pydantic-ai's NativeOutput mode so a thinking-capable model's
reasoning no longer competes with the structured response for token
budget.

Also fixes _build_structured_model typing non-required schema fields as
bare `py_type` with a None default, which only tolerates the field being
omitted — an explicit `null` (which models routinely emit) failed
pydantic validation until the field was typed `py_type | None`.

See ADR-009 for the full investigation and rejected alternatives.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YArD9ZjBWKsAvazmS48amA
2026-07-25 08:25:01 +01:00
James Veitch ecba52999d Merge pull request #30 from darth-veitcher/feat/jinja2-fromjson-filter
feat(format-string): add fromjson Jinja2 filter
2026-07-24 22:40:32 +01:00
James VeitchandClaude Sonnet 5 24119e3ab6 feat(format-string): add fromjson Jinja2 filter
ComfyUI has no native list/array socket type — FormatString's dynamic
inputs are always STRING (see update_widget). Passing a list of values
(e.g. extraction hints) into a {% for %} loop therefore requires either
manual comma-splitting or a way to carry structured data through a
STRING socket as JSON text.

Jinja2 ships `tojson` but not its inverse. Register `fromjson` (a thin
wrapper over json.loads) on FormatString.jinja_env so a STRING input
containing a JSON array/object can be parsed back into real Python data:

    {% for hint in extraction_hints | fromjson %}
    - {{ hint }}
    {% endfor %}

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YArD9ZjBWKsAvazmS48amA
2026-07-24 22:40:08 +01:00
James Veitch 1f30c2ff2f Merge pull request #29 from darth-veitcher/fix/jinja2-template-variable-extraction
fix(format-string): correct Jinja2 template variable extraction
2026-07-24 22:00:15 +01:00
James VeitchandClaude Sonnet 5 ecfc7eca59 fix(tests): pin comfydv module resolution before pytest fixture setup
uv run pytest was failing every single test with ModuleNotFoundError:
No module named 'comfydv._llm' — reproduces identically on a clean
checkout, unrelated to any test content.

Root cause: the repo root's own __init__.py (ComfyUI's custom-node
entry point) is also a valid "comfydv" package from a sys.path state
pytest transiently constructs during fixture setup, and it doesn't
expose the _llm submodule. A bare `import comfydv` issued from inside
a fixture body (e.g. _clear_ollama_caches) could resolve to that root
package instead of src/comfydv.

Forcing an explicit `import comfydv` in pytest_configure — while our
sys.path.insert(0, src) is still the definitive answer — caches the
correct module in sys.modules before anything else gets a chance to
resolve it ambiguously.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YArD9ZjBWKsAvazmS48amA
2026-07-24 21:59:38 +01:00
James VeitchandClaude Sonnet 5 e534fac121 fix(format-string): extract Jinja2 template variables via AST, not regex
FormatString._extract_keys used hand-rolled regexes to detect variables
in Jinja2 templates, which broke on anything beyond bare {{ var }} and
{{ var | filter }}:

- {% for hint in extraction_hints %}{{ hint }}{% endfor %} incorrectly
  surfaced the loop-local `hint` as a required input and never detected
  `extraction_hints` itself, since the control-structure regex only
  matched filter syntax (word followed by `|`).
- {% if extraction_hints is defined %} never matched at all.
- Filters called with arguments, e.g. {{ x | tojson(indent=2) }}, broke
  the regex's anchor to the closing `}}` and silently extracted nothing.

Replaced with jinja2.meta.find_undeclared_variables() over the parsed
AST, which handles all of Jinja2's syntax correctly and excludes names
bound within the template (loop targets, {% set %}) by construction.
Since that call returns an unordered set, sort by first textual
occurrence to keep extraction order deterministic for callers that rely
on positional outputs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YArD9ZjBWKsAvazmS48amA
2026-07-24 21:59:23 +01:00
James Veitch 6c3c341913 Merge pull request #24 from darth-veitcher/claude/chatcompletion-image-input-7mm6q5
docs(spec): draft VLM image input for ChatCompletion (009)
2026-07-23 12:32:31 +01:00
James Veitch 8cb9a74462 Merge pull request #28 from darth-veitcher/fix/ollama-incomplete-response-race
fix(ollama): raise a clear error on incomplete done:false stub
2026-07-23 07:59:27 +01:00
James VeitchandClaude Sonnet 5 5e678c2700 fix(ollama): raise a clear error on Ollama's incomplete done:false stub
Fixes #27. Live-observed against a real Ollama server under model-swap
load: /api/chat can answer HTTP 200 with model/created_at/message all
blank and done: false, before the target model has actually finished
loading. chat()'s existing blank-response retry (PR #23) couldn't tell
this apart from a genuine blank generation, so once retries were
exhausted it silently returned "" — success, with a wrong answer.

chat() now tracks whether the last attempt's response was this explicit
done: false stub. If retries are exhausted and the last response was one,
it raises a clear RuntimeError instead of returning blank. A genuine
blank generation (done: true, or no `done` field at all) keeps the
pre-existing silent-return behavior unchanged — only the stub signal
escalates.

3 new tests cover: raises when every attempt is a stub, recovers cleanly
when a later attempt is a real response, and the regression guard that a
genuine done:true blank still returns silently as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YArD9ZjBWKsAvazmS48amA
2026-07-23 07:58:31 +01:00
James VeitchandClaude Sonnet 5 3854b27cba fix(009): surface a clear error for non-vision Ollama models given an image
FR-006/SC-005 require a clear, specific error when an image is wired to
a model that can't process it. Live testing against a real Ollama server
found this unimplemented for the Ollama backend: /api/chat silently
accepts an unsupported `images` field and answers HTTP 200 with a blank
message instead of erroring, which the existing blank-response retry
(PR #23) swallows as an ordinary blank generation rather than surfacing.

OllamaProvider.chat()/chat_structured() now check /api/show's
`capabilities` list before sending a request that carries an image, and
raise a clear ValueError if `vision` is absent. The check is skipped
entirely for image-less requests (FR-003) and fails open on any lookup
problem (older Ollama, network hiccup) so it never blocks a request that
would otherwise have worked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YArD9ZjBWKsAvazmS48amA
2026-07-23 07:42:57 +01:00
James Veitch e432054b47 Merge pull request #26 from darth-veitcher/fix/dockerfile-test-python-313
fix(tests): update Dockerfile test to expect python:3.13-slim
2026-07-22 21:46:27 +01:00
James VeitchandClaude Sonnet 5 09fe013ff9 fix(tests): update Dockerfile test to expect python:3.13-slim
docker/Dockerfile was bumped from 3.11 to 3.13 in 50076d7 without
updating the assertion, breaking the suite on main. Verified 3.13
compatibility by building the image and booting ComfyUI 0.27.0 in it
(custom node loads, DB migrations run, no errors) before updating the
pinned version the test enforces.

Fixes #20

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YArD9ZjBWKsAvazmS48amA
2026-07-22 20:20:33 +01:00
Claude 0f73f3c2cc chore(009): register build bullet, record quality-gate status (T009)
Start the vlm-image-input bullet for this branch (BEACON BUILD tracking);
mark T009 with an honest note on pre-existing gate exceptions (repo-wide ty
diagnostics, a baseline Docker test, 007's deferred tasks) — all out of scope
for spec 009, whose own code is ruff/format/pytest green and doctor-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvS9TFMCFYNHC4MMvzJaZS
2026-07-22 19:11:50 +00:00
Claude 24917be802 docs(009): document image input on Chat Completion (T008)
README gains a "Describing images (vision)" section (vision-model /
llama.cpp --mmproj prerequisite, batch-as-multi-image, text-only unchanged);
CHANGELOG Unreleased notes the optional image input and the ADR-008 carrier.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvS9TFMCFYNHC4MMvzJaZS
2026-07-22 19:10:10 +00:00
Claude 46c14a872d feat(009): structured-output image input via BinaryContent (T007-I)
_user_prompt_content() lifts a turn's images onto the pydantic-ai user
prompt as BinaryContent PNGs (current turn and history user turns); text-only
turns stay plain-string. One shared helper covers structured image output on
both backends through OpenAIChatModel. Makes T007-T pass. US3 done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvS9TFMCFYNHC4MMvzJaZS
2026-07-22 19:08:19 +00:00
Claude 0cfe0d22ff test(009): failing tests for structured-output image input (T007-T)
Images on the current turn ride on Agent.run user_prompt as BinaryContent;
images on a history user turn ride on its UserPromptPart; text-only calls
stay plain-string. Red: chat.py passes only .content today. Witnesses both
us3_structured_image.feature scenarios.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvS9TFMCFYNHC4MMvzJaZS
2026-07-22 19:07:39 +00:00
Claude bc7b33922b feat(009): map images to OpenAI content-parts for llama.cpp (T006-I)
_to_openai_message() renders a turn's images as image_url data-URI parts on
/v1/chat/completions; text-only turns keep a plain-string content,
byte-identical to today. Makes T006-T pass. US2: same node/wiring now
describes an image on llama.cpp too (parity with Ollama).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvS9TFMCFYNHC4MMvzJaZS
2026-07-22 19:06:40 +00:00
Claude 1028315d37 test(009): failing tests for llama.cpp image content-parts (T006-T)
llama.cpp's OpenAI endpoint takes images as image_url parts inside content;
text-only messages must keep a plain-string content (byte-identical). Red:
mapping absent and model_dump() leaks images:None. Witnesses both
us2_both_backends.feature scenarios.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvS9TFMCFYNHC4MMvzJaZS
2026-07-22 19:05:56 +00:00
Claude a2f2464fdb feat(009): forward images via Ollama /api/chat, byte-identical text path (T005-I)
model_dump(exclude_none=True) keeps a turn's native flat images array while
omitting the key entirely for text-only turns, so image-less requests are
byte-for-byte unchanged (FR-003/SC-004). Makes T005-T pass. Completes US1
(MVP): describe-an-image end-to-end on Ollama.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvS9TFMCFYNHC4MMvzJaZS
2026-07-22 19:05:20 +00:00
Claude 4a631cc8dc test(009): failing test for Ollama image passthrough (T005-T)
Ollama /api/chat carries images as a flat per-message base64 array; a
text-only request must omit the images key (byte-identical to today,
FR-003/SC-004). Red: model_dump() currently leaks images:None into the
payload. Witnesses us1_describe_image.feature "Describe a wired image".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvS9TFMCFYNHC4MMvzJaZS
2026-07-22 19:05:00 +00:00
Claude fd9054c03f feat(009): optional IMAGE input on ChatCompletion (T004-I)
Adds an optional image input (with a vision-model / --mmproj tooltip) and
attaches encoded base64 image(s) to the current user turn only; history
turns untouched (FR-007). Outputs unchanged (Constitution VI). Makes
T004-T pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvS9TFMCFYNHC4MMvzJaZS
2026-07-22 19:04:06 +00:00
Claude 23d96f1559 test(009): failing tests for ChatCompletion IMAGE input (T004-T)
Optional image input; wired image attaches to the last user turn only
(history untouched, FR-007); un-wired path stays text-only; output
positions unchanged. Red: chat() has no image parameter yet. Witnesses
both us1_describe_image.feature scenarios.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvS9TFMCFYNHC4MMvzJaZS
2026-07-22 19:03:24 +00:00
Claude 9ece5c0efa feat(009): _encode_image_tensor for ChatCompletion image input (T003-I)
Converts a ComfyUI IMAGE [B,H,W,C] float tensor to base64 PNG(s), one per
frame; None/empty -> []. Lazy numpy/PIL import keeps module load clean
outside ComfyUI (Constitution IV). Makes T003-T pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvS9TFMCFYNHC4MMvzJaZS
2026-07-22 19:02:36 +00:00
Claude e225dcaaa3 test(009): failing tests for _encode_image_tensor (T003-T)
ComfyUI IMAGE tensor [B,H,W,C] float 0..1 -> decodable base64 PNG(s);
batch -> one string per frame; None/empty -> []. Red: helper not yet
defined. Witnesses us1_describe_image.feature "Describe a wired image".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvS9TFMCFYNHC4MMvzJaZS
2026-07-22 19:02:06 +00:00
Claude 1c72e0b2ea feat(009): add optional images carrier to Message (T002-I)
Message.images: list[str] | None = None — base64 payloads per turn, the
provider-neutral carrier ADR-008 defines. Default None keeps text-only
turns text-only. Makes T002-T pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvS9TFMCFYNHC4MMvzJaZS
2026-07-22 19:01:00 +00:00
Claude 7e56f2e5bf test(009): failing tests for Message.images carrier (T002-T)
Message gains an optional images field carrying base64 payloads per turn;
text-only messages must dump without an images key (FR-003/SC-004). Red:
current Message has no images attribute.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvS9TFMCFYNHC4MMvzJaZS
2026-07-22 19:00:21 +00:00
Claude f4a3390f13 build(deps): add pillow to dev group for image-encode tests (T001)
Enables unit-testing the ChatCompletion tensor→PNG encoder without a live
ComfyUI. Runtime Pillow/numpy remain ComfyUI-provided — no core runtime dep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvS9TFMCFYNHC4MMvzJaZS
2026-07-22 18:59:57 +00:00
Claude a40c1ba373 docs(tasks): task breakdown + BDD witnesses for VLM image input (009)
Phase 2 task decomposition for spec 009 with BEACON test-first discipline.

- tasks.md — interleaved -T/-I TDD pairs mapped to contracts T1–T6:
  * Setup: add pillow to dev deps (no core runtime dep)
  * Foundational: Message.images carrier (blocks all stories)
  * US1 (MVP): node optional IMAGE input + tensor→PNG encode helper +
    Ollama flat-images passthrough; text-only path byte-identical
  * US2: llama.cpp images→OpenAI image_url content-parts (parity)
  * US3: chat_structured images→pydantic-ai BinaryContent (shared, both backends)
  * Polish: docs/CHANGELOG, quality gate; live-backend e2e marked [-] deferred
    (needs a vision model / llama-server --mmproj not available in CI)
- features/ — 3 .feature files, 6 scenarios verbatim from spec.md, referenced
  by name from the -T tasks (spec.md -> .feature -> test traceability)

beacon doctor: spec-bdd-coverage green (6 scenarios witnessed), fail=0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvS9TFMCFYNHC4MMvzJaZS
2026-07-22 18:56:26 +00:00
Claude eab86b0324 docs(plan): plan VLM image input (009) — research, data-model, contracts
Phase 0/1 design artifacts for spec 009, resolving the wire shapes ADR-008
deferred for live verification.

- research.md — verified pydantic-ai BinaryContent against the installed
  pydantic-ai-slim 2.9.0 source (structured path is shared via OpenAIChatModel,
  one change covers both backends); Ollama flat images passthrough; llama.cpp
  OpenAI image_url content-parts (requires --mmproj); tensor→PNG via Pillow
  (dev dep) with _llm/ kept torch/numpy/Pillow-free per Constitution IV;
  byte-identical text path by omitting an empty images key
- data-model.md — Message gains optional images: list[str] | None; one carrier,
  three wire shapes; optional IMAGE node input (outputs untouched)
- contracts/image-input-contract.md — behavioural + test contracts T1–T6
- quickstart.md — describe-an-image workflow, structured variant, backend swap
- plan.md — Constitution Check passes (no new class, no new runtime dep,
  outputs unchanged); no Complexity Tracking needed
- CLAUDE.md SpecKit marker repointed to 009 plan

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvS9TFMCFYNHC4MMvzJaZS
2026-07-22 18:50:09 +00:00
Claude 45e49a98eb docs(spec): draft VLM image input for ChatCompletion (009)
Add the BEACON DESIGN scaffold for wiring a ComfyUI IMAGE into the existing
generic ChatCompletion node so a vision-capable model can describe/understand
images on either backend.

- specs/009-vlm-image-input/spec.md — WHAT/WHY: optional IMAGE input, same
  node on both backends, structured-output-with-image, graceful degradation
  on non-vision models; text-only path unchanged when no image is wired
- ADR-008 (Proposed) — carry images on an optional Message.images field; each
  provider translates to its own wire shape (Ollama /api/chat images,
  llama.cpp OpenAI image_url parts, shared chat_structured multimodal
  content). Extends ADR-007's adapter pattern to a second input modality
- epics/vlm-image-input.md — new epic with success criteria/non-goals; spec
  backlinked via .beacon.toml and listed under the epic
- Roadmap + ADR index updated

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvS9TFMCFYNHC4MMvzJaZS
2026-07-22 18:38:39 +00:00
James Veitch 87ec79be1b Merge pull request #23 from darth-veitcher/fix/retry-blank-output-with-seed-and-backoff
fix: retry blank/failed LLM responses with a new seed and backoff
2026-07-20 09:03:16 +01:00
James VeitchandClaude Sonnet 5 1dcef77a2b fix: keep chat_structured retry's nested seed in sync with the top-level one
beacon-reviewer caught a real gap: when a caller pins options["seed"],
the retry loop set the new seed on ModelSettings' top-level "seed"
field but left the same request's extra_body.options.seed (the
Ollama-native passthrough) at the original pinned value. A backend
that honors the nested field over the top-level OpenAI one would keep
sending the identical effective seed on every retry, silently
defeating this fix for exactly the pinned-seed case it needs to cover.

Now both fields are kept in sync on every retry attempt, built via
fresh copies rather than mutating the caller's options dict in place
(that dict is shared across every attempt, and possibly across other
calls). Added a regression test asserting the caller's options dict is
untouched after a multi-attempt retry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132ojafeazQ3ephcBejEWFj
2026-07-20 08:59:06 +01:00
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
James Veitch 92da4fc117 Merge pull request #22 from darth-veitcher/fix/run-async-real-comfyui-event-loop
fix: chat_structured() crashes in real ComfyUI's async execution engine
2026-07-12 10:33:50 +01:00
James VeitchandClaude Sonnet 5 1fe3bef663 docs(tests): stop overclaiming what the _run_async regression tests prove
beacon-reviewer caught it: reconstructing the old buggy _run_async and
running it against test_run_async_works_when_called_from_within_a_running_loop
and test_run_async_propagates_exceptions_from_within_a_running_loop shows
both pass against the old code too. asyncio.get_running_loop() never
spuriously raises under plain CPython/pytest, so the old conditional also
takes the safe worker-thread branch there — the actual reported bug is not
reproducible outside real ComfyUI's execution engine, and only the live
end-to-end run against it proved the fix.

Corrects the block comment to say what the tests actually do: lock in the
documented contract so a regression to a no-thread asyncio.run(coro) call
is caught immediately, without claiming they reproduce the specific
environment-dependent failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132ojafeazQ3ephcBejEWFj
2026-07-12 09:39:55 +01:00
James VeitchandClaude Sonnet 5 3bd857f0ab fix: chat_structured() crashes with real asyncio.run() error in live ComfyUI
Found by running a genuine end-to-end test — real docker-compose
ComfyUI harness, real Ollama, real llama-server (router mode),
actual workflow graphs submitted via POST /prompt and polled via
/history, not direct Python calls to provider classes. Every
structured_output=True ChatCompletion call failed with:

  RuntimeError: asyncio.run() cannot be called from a running event loop

Root cause: _run_async() tried asyncio.get_running_loop() first and
only spun up an isolated worker thread if that succeeded; otherwise
it called asyncio.run(coro) directly on the current thread. Under
pytest or a standalone script, get_running_loop() never spuriously
raises, so this worked and the worker-thread path was rarely
exercised for real. Under ComfyUI's actual async execution engine
(Python 3.13, node functions invoked synchronously from inside an
already-running event loop), get_running_loop() sometimes raised
anyway — routing straight into asyncio.run(coro) on the one thread
guaranteed to already have a loop running, reproducing the crash
every time. Confirmed via a live-instrumented debug run against the
real container: the coroutine executed correctly whenever the
worker-thread path was taken (get_running_loop() succeeding), and
crashed only via the direct-call fallback.

Fixed by removing the conditional entirely: always run the coroutine
in a freshly spawned worker thread. A new thread never has an
ambient loop, so asyncio.run() is safe there unconditionally,
regardless of what the calling thread's loop state actually is.

Also traced down a second symptom from the same end-to-end run:
non-structured chat() calls sometimes returned an empty response
with no error. Verified via direct curl calls to Ollama and
llama-server (bypassing comfydv entirely) that this was a real,
transient GPU/Metal memory-pressure issue on the test machine
(`ggml_metal_synchronize: error: Insufficient Memory`, Ollama's own
backend left in a wedged state returning HTTP 200 with an empty
zero-value body) — not a comfydv bug. Restarting the backend
processes resolved it; no code change was needed for that part.

Added tests/test_ollama_provider.py coverage for the specific shape
that broke: _run_async called synchronously from within code that is
itself already executing inside asyncio.run() — the actual pattern
ComfyUI's execution engine uses, which no prior test exercised (every
existing call site invoked _run_async from a plain synchronous pytest
function with no ambient loop).

Re-verified end-to-end after the fix: 5/5 real workflows passing
through actual ComfyUI execution — Ollama basic chat, Ollama full
lifecycle (Load->Chat->Unload), Ollama structured output, llama.cpp
basic chat, llama.cpp structured output — all with real generated
content, not mocks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132ojafeazQ3ephcBejEWFj
2026-07-12 09:36:21 +01:00
James Veitch 834ae3e9e3 Merge pull request #18 from darth-veitcher/chore/finish-llm-provider-abstraction-epic
chore: finish llm-provider-abstraction epic
2026-07-11 21:55:08 +01:00
49 changed files with 8197 additions and 213 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
"feature_directory": "specs/008-llamacpp-integration"
"feature_directory": "specs/009-vlm-image-input"
}
+7
View File
@@ -11,9 +11,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `CHANGELOG.md` (this file)
- README: What-is-this, Install, and Quickstart sections
- `LLMProvider` protocol (`comfydv._llm`) — a shared adapter boundary so ComfyUI LLM nodes work with any backend that implements it, starting with `OllamaProvider`. Structured output now goes through `pydantic-ai` (ADR-007), superseding the hand-rolled Ollama tool-calling approach.
- **Chat Completion** now accepts an optional `image` input for vision-capable models (VLMs): wire a ComfyUI `IMAGE` and the connected model can describe or reason about it. Works identically on both backends (Ollama multimodal models; llama.cpp launched with `--mmproj`), and composes with structured output and multi-turn history. Images are carried on `Message.images` and translated to each backend's native shape (Ollama's flat `images` array, llama.cpp's OpenAI `image_url` parts, pydantic-ai `BinaryContent` on the structured path) — ADR-008, extending ADR-007's adapter pattern to a second input modality. Text-only workflows are unchanged when no image is wired.
- **Ollama Option — Disable Thinking** node: turn off (or explicitly re-enable) a "thinking"-capable model's chain-of-thought reasoning. Chains into the same composable `OLLAMA_OPTIONS` socket every other `OllamaOption*` node uses, but works for both backends — each `LLMProvider` implementation pops the `think` key back out and translates it to its own wire shape (Ollama: a top-level `think` field; llama.cpp: `chat_template_kwargs`/`reasoning_effort` request-body fields, not live-verified — see ADR-010).
### Changed
- **Breaking:** `OllamaChatCompletion` → `ChatCompletion`, `OllamaModelSelector` → `LLMModelSelector`, `OllamaLoadModel` → `LLMLoadModel`, `OllamaUnloadModel` → `LLMUnloadModel`, and the `OLLAMA_CLIENT` socket type → `LLM_CLIENT` — these nodes are now backend-generic. `OllamaClient` is unchanged by name but now outputs an `OllamaProvider` rather than a plain string; existing saved workflows using the old node/socket names need reconnecting (see `comfydv.ollama.MIGRATION_MAP` for the full old→new mapping).
- `ChatCompletion`'s `structured_output=True` path now routes Ollama through Ollama's native `/api/chat` + `"format"` instead of the shared `pydantic-ai` OpenAI-compat path — Ollama's OpenAI-compatible endpoint was found to silently reload the model at its default context size on every call, discarding any `options` (e.g. `num_ctx`) override. `LlamaCppProvider` is unaffected and keeps the shared path, switched to `pydantic-ai`'s `NativeOutput` mode (ADR-009).
### Fixed
- `structured_output=True` requests could fail validation ("token limit exceeded before any response was generated") against "thinking"-capable models, which spent their whole token budget on chain-of-thought reasoning before ever producing the structured response (ADR-009).
- A non-required structured-output schema field rejected an explicit `null` value from the model (only an *omitted* field was tolerated), even though models routinely emit explicit `null` for absent optional fields.
## [0.1.0] — 2026-06-01
+1 -1
View File
@@ -1,5 +1,5 @@
<!-- SPECKIT START -->
For additional context about technologies to be used, project structure,
shell commands, and other important information, read the current plan
at specs/008-llamacpp-integration/plan.md
at specs/009-vlm-image-input/plan.md
<!-- SPECKIT END -->
+9
View File
@@ -114,6 +114,15 @@ A complete graph looks like this:
![Full LLM workflow](docs/assets/ollama_workflow.png)
### Describing images (vision)
**Chat Completion** has an optional **image** input. Wire any `IMAGE` into it and, with a vision-capable model loaded, the model can describe or reason about the picture — captioning, visual Q&A, reading text in an image, whatever the model supports.
- **Ollama** — use a multimodal model (e.g. a llava-class model).
- **llama.cpp** — launch `llama-server` with a multimodal projector: `--mmproj <projector.gguf>` alongside the model.
Image input works the same on both backends — same node, same wiring — and composes with everything else: structured output (schema-validated fields pulled straight from the image), multi-turn history, and the option nodes. A batch of images is sent as multiple images on the turn. Leave the input unwired and Chat Completion behaves exactly as before, text only.
### Manual memory management
Single-GPU and memory-constrained setups need explicit control over what's resident in VRAM. **LLM Load Model** pins a model into memory; **LLM Unload Model** evicts it immediately, freeing room for the next model or the rest of your image pipeline.
+6
View File
@@ -5,3 +5,9 @@
# the bullet survives a fresh clone (`beacon doctor`'s active-bullet check then
# works in CI) and no per-branch file is left behind on the trunk after a merge.
# `git log -p` on this file is the audit trail of who started which bullet when.
[bullets."claude/chatcompletion-image-input-7mm6q5"]
title = "VLM image input for ChatCompletion"
owner = "noreply@anthropic.com"
started = "2026-07-22T19:11:08+00:00"
epic = "vlm-image-input"
@@ -0,0 +1,158 @@
# ADR-008: Multimodal image input carried on the Message across the LLMProvider boundary
## Status
> Proposed
_Date:_ 2026-07-22
_Deciders:_ darth-veitcher
---
## Context
The `ChatCompletion` node and the `LLMProvider` protocol (ADR-007) are
text-only today. `Message` (`src/comfydv/_llm/provider.py`) carries a single
`content: str`; `ChatCompletion`'s `INPUT_TYPES` (`src/comfydv/ollama.py`)
exposes no `IMAGE` socket. Users want to couple the existing chat node with a
vision-capable model (a VLM) to describe or reason about an image produced
elsewhere in a ComfyUI workflow.
ADR-007 deliberately scoped this out: it defined `Message` as text-only and
recorded that "if llama.cpp's router mode needs a protocol capability that
doesn't exist yet, that is a protocol change scoped as its own follow-up, not
silently special-cased." This ADR is that follow-up — it extends the same
adapter pattern to a second input modality.
The two shipped backends carry images very differently on the wire, and the
node has two distinct code paths (free-text vs structured), so a decision is
needed about **where** an image lives as it crosses the provider boundary and
**who** translates it into each backend's native shape:
- **Ollama free-text** — `OllamaProvider.chat()` posts `[m.model_dump() for m
in messages]` to the native `/api/chat`, which accepts a per-message
`images` field: an array of base64-encoded image data alongside the text
`content`.
- **llama.cpp free-text** — `LlamaCppProvider.chat()` posts to the
OpenAI-compatible `/v1/chat/completions`, where a message's `content` is a
list of typed parts (`{"type": "text", ...}`,
`{"type": "image_url", "image_url": {"url": "data:image/...;base64,..."}}`)
— a flat sibling `images` field is not understood.
- **Structured output (both backends)** — routed through the shared
`chat_structured()` helper (`src/comfydv/_llm/chat.py`) over pydantic-ai,
which represents images as typed multimodal content
(`BinaryContent` / `ImageUrl`) inside the user prompt, not as a raw request
field.
The competing concern is DRY vs. leakage: a single carrier keeps the graph and
the node backend-agnostic (ADR-007's whole point), but the per-backend wire
shapes are irreducibly different and must be translated somewhere.
## Decision
**Carry images as an optional field on `Message`, and make each provider
responsible for translating that field into its own native wire shape** — the
exact same division of responsibility ADR-007 established for text and model
management (operation-level protocol, wire-format quirks contained inside each
provider).
1. **Protocol** — extend `Message` with an optional
`images: list[str] | None = None`, where each entry is a base64-encoded
image. `content` stays required; a text-only message sets `images=None` and
is byte-for-byte unchanged from today (`model_dump()` omits it or emits
`null`), so all existing Ollama/llama.cpp behavior is preserved.
2. **Node** — `ChatCompletion` gains one **optional** `image: ("IMAGE",)`
input. When wired, the node encodes the ComfyUI `IMAGE` tensor to base64
and attaches it to the user `Message` it already constructs. When not
wired, the node builds exactly the message it builds today. The node never
branches on which concrete provider it holds — consistent with ADR-007.
3. **Per-provider translation** (the leakage lives here, deliberately):
- `OllamaProvider.chat()` — the flat `images` field on the dumped message
already matches Ollama's native `/api/chat` schema; it flows through with
no transform.
- `LlamaCppProvider.chat()` — maps a message's `images` into OpenAI-style
`image_url` content parts before POSTing to `/v1/chat/completions`.
- `chat_structured()` (shared) — maps the last user message's `images` into
pydantic-ai multimodal content on the `user_prompt`; both backends inherit
this single implementation, mirroring how they already share the
structured text path.
4. **No new node classes and no new socket types** — image support is an
additional optional input on the *existing* generic node, so a workflow
author gains vision by wiring one socket, not by learning a new node. This
is the direct extension of ADR-007's "generic, not per-backend" node stance.
The base64 string is the neutral interchange form at the boundary because it
is the one representation every target consumes (Ollama's `images` array,
OpenAI's `data:` URI, and pydantic-ai's `BinaryContent` all accept it),
keeping the `Message` carrier itself provider-agnostic.
_Wire specifics (exact Ollama `/api/chat` image field, llama.cpp multimodal
readiness via `mmproj`, and pydantic-ai's multimodal content type) are
verified live in this feature's `research.md`/`plan.md` per project
convention, not assumed from training data._
## Consequences
**Easier:**
- One carrier (`Message.images`) and one node change unlock vision on both
backends at once; a future third provider implements image translation in
its own `chat()` exactly as it implements text, with no protocol churn.
- The graph and the node stay backend-agnostic — swapping providers still
means rewiring one client node, now including the image path.
- Text-only workflows are entirely unaffected (additive optional field +
optional socket).
**Harder / constrained:**
- `Message` is no longer a trivially-uniform text struct; each provider's
`chat()` (and the shared structured helper) must handle the `images` field,
even if only to pass it through. This is accepted leakage, localized to the
provider layer — the same tradeoff ADR-007 already made for `keep_alive` vs
explicit load/unload.
- Vision requires a model actually loaded with multimodal weights (Ollama
multimodal models; llama.cpp launched with an `mmproj` projector). A
text-only model receiving images degrades to a backend error, not a node
crash — surfacing that clearly is a spec requirement, not something this
boundary can prevent.
**Debt introduced:**
- None deliberately, contingent on text-only requests remaining byte-identical
to today (guarded by the existing Ollama/llama.cpp provider tests, which must
stay green).
## Considered Alternatives
### Alternative A: A separate `images` parameter threaded through `chat()`/`chat_structured()` signatures
**Why rejected:** Widens every provider method signature and the protocol for
a value that is conceptually part of a message turn. Images belong to a
specific message (which turn the picture accompanies), and multi-turn vision
histories need per-message association — a single side-channel parameter can't
express that. Putting it on `Message` keeps turn/image association intact and
leaves method signatures unchanged.
### Alternative B: A dedicated multimodal node / socket type separate from `ChatCompletion`
**Why rejected:** Reintroduces exactly the per-capability node proliferation
ADR-007 eliminated. A workflow author would maintain two chat nodes and
relearn one for vision. An optional input on the existing node is strictly
simpler and keeps the "one generic node set" promise.
### Alternative C: Normalize images to OpenAI content-parts at the boundary; make Ollama un-translate
**Why rejected:** Picks OpenAI's shape as the canonical form and forces the
Ollama provider — whose native API wants the simpler flat `images` array — to
convert *away* from it. That inverts the "each provider owns its own wire
format" principle and does more work on the currently-simpler path. A neutral
base64 carrier that every backend adapts *from* is the orthogonal choice.
---
## Links
- Related epic: `project-management/Roadmap/epics/vlm-image-input.md`
- Related spec: `specs/009-vlm-image-input/`
- Related ADRs: [ADR-007](ADR-007-llm-provider-adapter-pattern.md) (extended — same adapter pattern, second input modality), [ADR-005](ADR-005-ollama-host-config-via-client-node.md) (client-node pattern, unchanged)
- External reference: GitHub issue #15 (llama.cpp parity), Ollama multimodal `/api/chat` `images`, OpenAI vision `image_url` content parts
@@ -0,0 +1,82 @@
# ADR-009: Provider-specific structured output — NativeOutput for llama.cpp, hand-rolled native `/api/chat` for Ollama
## Status
> Accepted
_Date:_ 2026-07-25
_Deciders:_ darth-veitcher
---
## Context
`chat_structured()` (`src/comfydv/_llm/chat.py`, ADR-007) builds its `pydantic-ai` `Agent` with a bare `output_type=schema`. Passing a raw `pydantic.BaseModel` subclass this way makes `pydantic-ai` default to **tool-calling** (a synthetic forced function call) for structured output — inherited silently from ADR-007's move to `pydantic-ai`, never a deliberate re-decision. ADR-007 doesn't discuss output-mode choice at all.
Testing a real multi-agent ComfyUI workflow (`workflows/ltx-i2v-pipeline.json`) against a live local Ollama server surfaced this as a real reliability problem: against a "thinking"-capable model (`qwen3.5:9b`), tool-calling failed consistently — the model spent its entire token budget on internal chain-of-thought reasoning and never emitted the tool call, so every attempt failed pydantic validation ("token limit exceeded before any response was generated"), each attempt taking 5-7+ minutes before giving up.
### First fix attempt: `NativeOutput` + a priming call (superseded within this same ADR)
`pydantic-ai` 2.9.0 exposes `NativeOutput`, which makes the `Agent` use `response_format: {"type": "json_schema", ...}` over the OpenAI-compatible endpoint instead of tool-calling. Live-tested directly against Ollama via `curl` before touching code: `/v1/chat/completions` with `response_format: json_schema` returned clean, schema-valid JSON, with the model's reasoning in a separate `message.reasoning` field — fast, and reasoning no longer competed with structured output for token budget. This part of the fix is real and is kept — see Decision §1.
A second, separate problem was also found: Ollama's OpenAI-compatible endpoint doesn't honor a per-request `options` override (e.g. `num_ctx`) — sending the same request to native `/api/chat` reloaded the model at the requested context size; `/v1/chat/completions` silently kept whatever was already loaded. The first fix attempt worked around this with a priming call: hit native `/api/generate` with the desired `options` immediately before the real `/v1/chat/completions` request, on the theory that Ollama would keep the just-loaded context for the next call.
**This did not work, and the failure mode looked exactly like the original bug** — confirmed while re-testing the actual workflow end-to-end (`workflows/ltx-i2v-pipeline.json`, Agent 2 "Scene Grounder": long system prompt + 9-property schema + image), which kept failing with the identical "token limit exceeded" error even after the priming fix landed, tests passed, and `max_tokens`/`num_ctx`/`timeout_secs` were all raised generously. Isolated the exact mechanism with a direct, non-ComfyUI-mediated `curl` sequence:
1. `POST /api/generate` with `options: {num_ctx: 20480}`, `keep_alive: -1` → confirmed via `GET /api/ps`: `context_length: 20480`, loaded "forever".
2. Immediately `POST /v1/chat/completions` for the same model — **even with the identical `options: {num_ctx: 20480}` included in that request's body** → `GET /api/ps` immediately after: `context_length: 4096` (back to default), `expires_at` reset to a normal ~5-minute keep-alive.
So `/v1/chat/completions` doesn't merely *ignore* `options.num_ctx` — every call to it silently **reloads the model at the default context size**, discarding whatever was primed, regardless of what that same call's own `options` field says. A priming call immediately before the real request is structurally incapable of working, because the real request itself is what undoes the priming.
Re-ran the same sequence against native `/api/chat` instead of `/v1/chat/completions`: the primed `context_length: 20480` was preserved through and after the call. Native `/api/chat` also accepts `"format": <json schema>` directly, giving grammar-constrained structured output in the same request that correctly honors `options` — no separate priming call needed at all.
### Re-litigating ADR-006's model concern
This also revisits [ADR-006](ADR-006-structured-ollama-output-tool-calling-not-pydantic-ai.md), which rejected native `format`-based output — but its rejection was scoped to one specific model, `lukey03/qwen3.5-9b-abliterated-vision`, whose degenerate chat template silently ignored the constraint. ADR-006 explicitly flagged this as revisitable: *"If well-behaved-model testing later shows native `format` is meaningfully more reliable in the common case, this decision should be revisited rather than treated as permanent."* Re-tested that exact model against native structured output: it no longer silently ignores the constraint (ADR-006's specific failure mode) — it returns schema-valid JSON, but the *content* is still garbled (`"ponáp∵49\n"` instead of the requested `"pong"`), consistent with ADR-006's "degenerate tokenizer" diagnosis. That model was also already failing under the tool-calling path (hanging without completing, observed live during this same investigation). So neither part of this decision regresses that model — it was already unusable for structured output either way. `structured_output` has no production users yet, so there is no back-compat concern in making this change.
## Decision
**1. `LlamaCppProvider` keeps the shared `pydantic-ai` path, switched to `NativeOutput`.** `chat.py`'s `_build_agent()` builds `Agent(chat_model, output_type=NativeOutput(schema), retries=0)` instead of a bare `output_type=schema`. llama-server's OpenAI-compatible endpoint is its genuine native structured-output surface (no equivalent context-reload bug found or expected — llama-server's context is fixed at process launch via `--ctx-size`, not a per-request concern, so there's nothing for a request to silently reset), so the shared-implementation architecture from ADR-007 stays intact for this provider.
**2. `OllamaProvider.chat_structured()` no longer uses `chat.py` at all.** It hand-rolls its own call to Ollama's **native** `/api/chat` with a `"format"` JSON schema, mirroring the request-building and retry/validation contract `chat.py` established (bounded retries 0–5, `RuntimeError` naming the model/attempt-count/truncated-response on exhaustion) but without pydantic-ai in the loop for this provider — there is no bare-metal native-JSON-schema mode in pydantic-ai's OpenAI-compatible model class to point at Ollama's native (non-OpenAI-shaped) endpoint, so this is a direct `_post_json` call, parsed with `schema.model_validate_json(...)`, retried on `pydantic.ValidationError` (which pydantic v2 also raises for malformed JSON, not just schema mismatches). `options` (from `OllamaOption*` nodes) is included directly in this same request's `"options"` field and is correctly honored, since it's the native endpoint — no separate priming call, because none is needed: structured output and context sizing now apply atomically in one request.
The retry/validation contract itself (bounded retries, `RuntimeError` naming the model/attempt-count/truncated-response on exhaustion) is unchanged and now implemented twice — once in `chat.py` for llama.cpp, once directly in `ollama_provider.py` for Ollama — rather than shared, which is the real cost of this decision (see Consequences).
## Consequences
**Easier:**
- Structured output is now reliable against "thinking"-capable models on both providers — reasoning and structured content are separate response fields (`message.reasoning`/`message.thinking` vs `message.content`) under both `NativeOutput` and Ollama's native `format`, rather than competing for the same token stream under tool-calling.
- `num_ctx` and other Ollama-native options now actually apply to structured-output requests — genuinely fixed this time, confirmed by re-running the actual failing workflow agent, not just by a passing test suite (the first fix attempt passed every test and still didn't work end-to-end).
- Meaningfully faster in the success case than tool-calling against a thinking model.
- Closes ADR-006's own explicit "revisit later" flag with concrete evidence rather than leaving it open indefinitely.
**Harder / constrained:**
- `OllamaProvider` and `LlamaCppProvider` now have two independent structured-output implementations instead of one shared one — ADR-007's "share one implementation" goal no longer holds for this piece. A future structured-output feature (e.g. plumbing reasoning content back to the caller) needs to land in both places.
- Structured output guarantees schema-*shape* validity, not semantic correctness, on both providers now — a genuinely broken model (degenerate tokenizer, as with the abliterated test model) can still return valid-JSON garbage instead of raising a clear error. Downstream consumers should not treat "returned without error" as "returned correct content" for low-quality/unreliable models.
- Ollama's native `/api/chat` endpoint's `"format"` field is only checked against top-level `type`/`properties`/`required` the same way the OpenAI-compat `response_format` was — no change to `_build_structured_model`'s shallow-schema behavior in `ollama.py`.
**Debt introduced:**
- Two structured-output code paths (per provider) instead of one shared one, as noted above — accepted because the two providers' actual constraints (Ollama's context-reset-per-OpenAI-compat-call bug vs. llama-server's fixed-at-launch context) are genuinely different, not incidentally different.
- Not addressed here (flagged for a future ADR if pursued): a model's reasoning/thinking content is available (`message.reasoning` natively for Ollama, parsed into pydantic-ai's `ThinkingPart` for llama.cpp) but discarded by both `chat_structured()` implementations, which only return the validated schema instance. Plumbing this back to `ChatCompletion` as a node output would need a `LLMProvider.chat_structured()` return-type change — a `Protocol`-level change affecting both providers, out of scope here.
- Not addressed here (pre-existing, unrelated): `LlamaCppProvider.chat()`'s own code comments already note that `OllamaOption*` nodes emit Ollama-native option names llama-server's OpenAI-compatible endpoint doesn't recognize — an accepted gap from the llama.cpp integration epic, unrelated to this decision.
## Considered Alternatives
### Alternative A: `NativeOutput` + priming call for both providers (the first fix attempt)
**Why rejected:** This is what ADR-009 originally shipped as. It passed every test (including a new one added specifically for the priming call) and one successful live single-agent ComfyUI run, but failed to actually fix the real workflow — confirmed by re-running the full pipeline and hitting the identical original failure on a later, more complex agent. Root-caused only after that: `/v1/chat/completions` unconditionally reloads the model at default context on *every* call, so priming immediately before the real call is undone by the real call itself. No amount of retrying, raising `max_tokens`, or raising `timeout_secs` fixes a context-size problem that the request itself keeps resetting.
### Alternative B: Fully switch both providers off `pydantic-ai`, hand-roll native structured output everywhere
**Why rejected:** Unnecessary for `LlamaCppProvider` — no evidence llama-server's OpenAI-compatible endpoint has Ollama's context-reset behavior (its context is fixed at process launch regardless of request), so `NativeOutput` over the existing shared path is strictly simpler there and keeps ADR-007's sharing goal intact for at least one provider.
### Alternative C: Do nothing, document Ollama's context-reset behavior as a known limitation
**Why rejected:** The underlying failure mode (indefinite-looking hangs, or outright failures, against any Ollama model needing more than the default 4096-token context while using structured output) is common enough — any long system prompt plus a non-trivial schema hits it — that documenting around it would leave `structured_output=True` effectively broken for Ollama in exactly the cases where structured output is most useful (complex, multi-field extraction tasks).
---
## Links
- Related ADRs: [ADR-006](ADR-006-structured-ollama-output-tool-calling-not-pydantic-ai.md) (superseded rationale, not superseded status — ADR-006's tool-calling-vs-native evidence and reasoning stand as historical record; this ADR only revisits its "revisit later" flag), [ADR-007](ADR-007-llm-provider-adapter-pattern.md) (provider abstraction this decision partially steps outside of, for Ollama only)
- Discovered while building/testing: `workflows/ltx-i2v-pipeline.json`
@@ -0,0 +1,64 @@
# ADR-010: `"think"` as an options-carried, per-provider-translated toggle
## Status
> Accepted
_Date:_ 2026-07-25
_Deciders:_ darth-veitcher
---
## Context
ADR-009's investigation into structured-output reliability surfaced, as a side effect, how expensive a "thinking"-capable model's chain-of-thought reasoning is: on a real workflow, a single agent call could spend 5-7+ minutes and its entire token budget on reasoning before ever producing the requested response. Both Ollama and llama-server can turn this off, but neither exposes it through the generic `options` dict `ChatCompletion` already forwards — each has a completely different, incompatible wire shape:
- **Ollama** — live-tested directly: `"think": false` must be a **top-level** field on `/api/chat` (and `/v1/chat/completions`). Nested inside `options` (`{"options": {"think": false}}`) it's silently ignored — confirmed live (`eval_count: 223` reasoning tokens burned vs. `eval_count: 2` with it top-level). One existing test (`test_multi_turn_receives_context`) already carried `options={"think": False}` in its docstring's stated intent; it was a no-op the whole time.
- **llama.cpp** — not live-tested (no router-mode `llama-server` instance available; user explicitly chose doc-based research over waiting for one). Per `tools/server/README.md`: `chat_template_kwargs: {"enable_thinking": false}` (Qwen3-style HF chat-template convention) and/or `reasoning_effort: "none"` (a more model-agnostic OpenAI-style convention llama-server also accepts) — both as request-body fields on `/v1/chat/completions`, not nested in `options` either.
Two shapes were considered for exposing this from comfydv:
1. **A first-class `ChatCompletion` input + `LLMProvider` protocol parameter** — mirroring how `Message.images` crossed the provider boundary (ADR-008). Initially implemented this way.
2. **A composable `OllamaOption*`-style node merging a `"think"` key into the existing `OLLAMA_OPTIONS` chain**, with each provider popping that one key back out and translating it before building its own request — proposed as a simplification once (1) was drafted, since every other tunable knob already flows through this exact composition pattern and a new top-level node parameter would be the only one that doesn't.
## Decision
Went with option 2. `OllamaOptionDisableThinking` (`src/comfydv/ollama.py`) is a new node, identical in shape to `OllamaOptionTemperature`/`OllamaOptionSeed`/etc.: `disable_thinking: BOOLEAN` (default `True`), merges `{"think": not disable_thinking}` into whatever `OLLAMA_OPTIONS` chain it's wired into. No `ChatCompletion` or `LLMProvider` protocol signature change.
Both providers now start `chat()`/`chat_structured()` by popping `"think"` out of the incoming `options` dict (`_pop_think()`, `ollama_provider.py`, shared by both — a pure function, doesn't mutate the caller's dict) and translate it into their own shape before building the request:
- `OllamaProvider`: sets `payload["think"]` at the top level (both `chat()`'s native `/api/chat` call and `chat_structured()`'s, per ADR-009's native-endpoint rewrite).
- `LlamaCppProvider`: sets `chat_template_kwargs`/`reasoning_effort` — directly in its own hand-rolled `chat()` payload, and via `chat.py`'s `extra_body` (the same mechanism `options` itself uses) for `chat_structured()`, which still shares the pydantic-ai path per ADR-009.
Despite living in `ollama.py` and following the `OllamaOption*` naming convention (matching every other option node in that module, all genuinely Ollama-native and untranslated for llama.cpp — see `LlamaCppProvider.chat()`'s own comment), `"think"` is the one key from that chain **both** providers recognize and translate; it isn't itself Ollama's native wire format, it's a comfydv-level convention that happens to reuse Ollama's own field name since Ollama's is the more literal of the two backends' conventions.
## Consequences
**Easier:**
- One node works for both backends, reusing the exact composition pattern (`OLLAMA_OPTIONS` chaining into `ChatCompletion`'s `options` input) every other tunable parameter already uses — no new socket type, no `ChatCompletion.INPUT_TYPES` change, no `LLMProvider` protocol change.
- Fixes an existing test's stated-but-unfulfilled intent for free: `test_multi_turn_receives_context` and `test_structured_output_against_unreliable_model_stays_schema_valid` already passed `options={"think": False}` and now it actually works.
- Meaningfully faster for any thinking-capable model, and directly reduces the token-budget pressure ADR-009 had to fix around.
**Harder / constrained:**
- The llama.cpp translation is not live-verified — sourced from the server's documented request-body fields, not confirmed against a running `llama-server`. Verify against your own deployment before relying on it; a follow-up should close this gap once an instance is available (the user explicitly chose this tradeoff over waiting).
- `"think"` living among genuinely-Ollama-native `OllamaOption*` nodes (which llama.cpp does *not* translate — see that class's own code comment) is a small naming/mental-model inconsistency: one key out of that whole chain is special-cased by both providers. Documented here and in `_pop_think()`'s own docstring so it doesn't read as an oversight later.
**Debt introduced:**
- None. No new dependency, no new socket type.
## Considered Alternatives
### Alternative A: First-class `ChatCompletion` input + protocol parameter (mirroring `Message.images`, ADR-008)
**Why rejected:** Correct in principle (this is a cross-provider concern needing real translation, exactly like images), but heavier than necessary — a new node input plus a `LLMProvider.chat()`/`chat_structured()` signature change plus threading a new parameter through every call site, when the existing `options` dict composition already has a clean seam (`_pop_think`) for a value that needs per-provider translation before hitting the wire. Started implementing this way; reverted once the composable-option alternative was raised.
### Alternative B: Separate provider-specific nodes (`OllamaOptionDisableThinking` / a llama.cpp-only equivalent)
**Why rejected:** Splits one concept into two nodes for no real benefit — both backends' translation lives in code either way, so there's no cost to having one node recognize the same key on both.
---
## Links
- Related ADRs: [ADR-007](ADR-007-llm-provider-adapter-pattern.md) (the `LLMProvider` boundary this operates within), [ADR-008](ADR-008-multimodal-image-input-across-llmprovider-boundary.md) (the pattern this ADR considered and didn't need — cross-provider concerns don't always require a protocol change), [ADR-009](ADR-009-native-structured-output-mode.md) (the investigation that surfaced how expensive unmanaged thinking is)
- llama.cpp server docs (request-body fields, not live-verified): `tools/server/README.md` in `ggml-org/llama.cpp`
+1
View File
@@ -31,3 +31,4 @@ Superseded ADRs keep their file; update their status to `Superseded by ADR-###`.
| ADR | Title | Status | Date |
|-----|-------|--------|------|
| [ADR-000](ADR-000-template.md) | Template | — | — |
| [ADR-008](ADR-008-multimodal-image-input-across-llmprovider-boundary.md) | Multimodal image input across the LLMProvider boundary | Proposed | 2026-07-22 |
+1
View File
@@ -28,6 +28,7 @@
- **ComfyUI UX Polish & Manager Compatibility** — `epics/ux-and-install.md` — 🔄 ACTIVE — Fix installation, core UX bugs (debounce, connection drops, alert dialogs), correctness bugs (class-level mutation, IS_CHANGED, seed=0), and metadata drift
- **LLM Provider Abstraction** — `epics/archive/llm-provider-abstraction.md` — ✅ DONE — shared `LLMProvider` protocol (list/load/unload/chat/structured-output) and generic ComfyUI nodes; Ollama integration migrated onto it (ADR-007, supersedes ADR-006); merged via PR #17
- **llama.cpp Model Integration** — `epics/llamacpp-integration.md` — 🔄 ACTIVE — Add a `LlamaCppProvider` implementing the shared protocol via llama-server's router mode (GitHub issue #15); dependency on LLM Provider Abstraction now satisfied
- **VLM Image Input for ChatCompletion** — `epics/vlm-image-input.md` — 📋 PLANNING — Wire a ComfyUI IMAGE into the existing generic ChatCompletion node so a vision-capable model can describe/understand images; images carried on the `Message` and translated per-provider (ADR-008 extends ADR-007)
For the live rollup (specs per epic, % tasks complete, last-commit age):
@@ -0,0 +1,61 @@
# Epic: VLM Image Input for ChatCompletion
## Status
Planning — started 2026-07-22
## Why now
The generic `ChatCompletion` node and the `LLMProvider` protocol landed
text-only (ADR-007), which explicitly deferred any protocol change for a new
capability as "its own follow-up." Both shipped backends can already serve
vision models — Ollama multimodal models via `/api/chat`'s per-message
`images`, and llama.cpp via a multimodal projector (`mmproj`) on the same
OpenAI-compatible `/v1/chat/completions` the provider already calls — so the
gap is entirely on comfydv's client side, not the servers'. Coupling the chat
node with a VLM to describe or reason about images produced elsewhere in a
workflow is a frequently-wanted next step, and the adapter pattern makes it a
small, symmetric addition rather than a new node family.
## Specs
_SpecKit specs that contribute to this epic._
- specs/009-vlm-image-input/ — wire a ComfyUI IMAGE into the existing ChatCompletion node; images carried on the Message and translated per-provider
## ADRs
_Cross-cutting decisions this epic required._
- project-management/ADRs/ADR-008-multimodal-image-input-across-llmprovider-boundary.md — carry images as an optional `Message.images` field; each provider translates to its own wire shape (extends ADR-007's adapter pattern to a second input modality)
- project-management/ADRs/ADR-007-llm-provider-adapter-pattern.md — the adapter pattern this epic extends; the generic node/protocol it adds an image path to
## Success criteria
- `Message` carries an optional `images` field; text-only requests remain byte-for-byte unchanged (existing Ollama + llama.cpp provider tests stay green)
- `ChatCompletion` gains one **optional** `IMAGE` input — no new node classes, no new socket types; a workflow author gains vision by wiring one socket
- A wired image reaches a vision model and produces a description/answer on **both** backends:
- Ollama: flat per-message `images` passes through `/api/chat` untransformed
- llama.cpp: mapped to OpenAI `image_url` content parts on `/v1/chat/completions`
- Structured output with an image works via the shared `chat_structured()` (pydantic-ai multimodal content) — one implementation, both backends
- A text-only model that receives an image degrades to a clear backend error surfaced by the node, not a crash
- Test coverage mirrors the `OllamaProvider`/`LlamaCppProvider` conventions (mock at the provider's own transport seam); CI smoke test passes
- No new runtime dependencies beyond what ADR-007 already introduced
## Non-goals
- No image **output** or image generation — input-to-VLM only
- No new node classes or socket types — additive optional input on the existing generic node
- No changes to the client/config nodes (`OllamaClient`, `LlamaCppClient`) or model-management nodes
- No auto-provisioning of vision models — the user must have a multimodal model loaded (Ollama multimodal model; llama.cpp launched with an `mmproj` projector); this epic does not install or configure it
- No video, audio, or document/PDF modalities — still images only
- No image preprocessing beyond what's needed to hand a ComfyUI IMAGE tensor to a backend (no resizing policy, tiling, or OCR of our own)
- No `OllamaOption*` parameter translation work — inherited unchanged from ADR-007's scope
## Notes
Multimodal readiness is a deployment prerequisite, not something comfydv
configures: document in node tooltips that the wired model must be
vision-capable, and that llama.cpp needs `--mmproj`. The exact wire shapes
(Ollama `/api/chat` `images`, OpenAI `image_url`, pydantic-ai `BinaryContent`)
are verified live in the spec's `research.md`/`plan.md`, consistent with how
the llama.cpp epic verified router-mode endpoints.
Reference: ADR-008, ADR-007, GitHub issue #15.
File diff suppressed because it is too large Load Diff
+1
View File
@@ -27,6 +27,7 @@ source = "vcs"
[dependency-groups]
dev = [
"pillow>=10.0.0",
"playwright>=1.60.0",
"pytest>=8.4.2",
"pytest-cov>=6.0.0",
+1
View File
@@ -0,0 +1 @@
epic = "vlm-image-input"
@@ -0,0 +1,41 @@
# Specification Quality Checklist: VLM Image Input for ChatCompletion
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-07-22
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- The cross-provider "where does the image live / who translates it" decision
is intentionally kept out of the spec (WHAT/WHY) and recorded in
ADR-008 (HOW). The spec references it via the epic, not inline.
- Multi-image-per-turn is documented as an out-of-MVP extension in Assumptions,
not a functional requirement — keeps scope bounded.
- Items are validated by review, not by an automated gate (`beacon` CLI is not
installed in this environment; placeholder/ADR-reference checks were run
manually — see the session's validation step).
@@ -0,0 +1,64 @@
# Contract: Image Input across the LLMProvider boundary
**Spec**: [spec.md](../spec.md) · **Data model**: [data-model.md](../data-model.md) · **ADR**: [ADR-008](../../../project-management/ADRs/ADR-008-multimodal-image-input-across-llmprovider-boundary.md)
This feature adds no new protocol methods and no new socket types. The contract
below is the **behavioural conformance** every `LLMProvider` must satisfy for
the new `Message.images` field, plus the node's input contract.
---
## C1 — `Message.images` carrier
- `Message.images: list[str] | None = None`, base64 strings (no `data:` prefix).
- `images=None` or `[]` ⇒ the turn is text-only and MUST produce a request
**byte-for-byte identical** to the pre-feature behaviour.
## C2 — `LLMProvider.chat()` conformance (both providers)
Given `messages` where the last user turn carries `images`:
1. The provider MUST transmit those images with that turn to its backend using
its native shape (Ollama flat `images`; llama.cpp OpenAI `image_url` parts).
2. The provider MUST NOT transmit an `images` field for turns that have none
(empty key omitted).
3. All existing behaviour is preserved: blank-retry-with-new-seed loop, response
caching, timeout, and error surfacing are unchanged by the presence of images.
4. A backend that cannot process images (non-vision model / no `--mmproj`) MUST
have its error surfaced to the caller, not swallowed (FR-006).
## C3 — `chat_structured()` conformance (shared helper, both providers)
1. Images on the last user turn MUST be attached as `BinaryContent` on the
`Agent.run` `user_prompt`; images on history user turns MUST be attached to
their `UserPromptPart`.
2. All existing structured guarantees hold unchanged: bounded retries (0–5),
`RuntimeError` on exhaustion naming model/attempts/snippet, never returns a
value that failed schema validation.
3. A text-only structured call MUST be indistinguishable from today's.
## C4 — `ChatCompletion` node input contract
1. Adds exactly one **optional** `image: ("IMAGE",)` input. No required input
added; `RETURN_TYPES`/`RETURN_NAMES` positions unchanged (Constitution VI).
2. Un-wired ⇒ behaviour, request, and outputs identical to today.
3. Wired ⇒ image(s) attached to the current user turn only; `history` turns
unchanged (FR-007).
4. Works with `structured_output=True` (C3) and free-text (C2) alike, on either
backend, with no per-backend wiring difference (FR-004).
---
## Test contracts (test-first — Constitution III)
| ID | Level | Asserts |
|---|---|---|
| T1 | `Message` unit | `images` defaults `None`; round-trips base64 list; text-only dump omits the key |
| T2 | `OllamaProvider.chat` | image turn → payload message has flat `images:[...]`; text-only payload byte-identical to today (regression) |
| T3 | `LlamaCppProvider.chat` | image turn → `content` becomes text+`image_url` parts; text-only `content` stays a plain string (regression) |
| T4 | `chat_structured` | image turn builds `BinaryContent` on the prompt; text-only path unchanged; retry/validation contract intact |
| T5 | node encode helper | synthetic `[1,H,W,3]` tensor → decodable base64 PNG; `None`/empty → `[]` |
| T6 | node contract | optional `image` in `INPUT_TYPES`; un-wired run == today; wired run attaches to last turn only |
All tests run without a live ComfyUI or a live backend (mock at each provider's
own `_post_json`/`Agent.run` seam, per the `test_ollama_provider.py`
convention). T5 uses a synthetic tensor + Pillow (dev dep), no ComfyUI.
+83
View File
@@ -0,0 +1,83 @@
# Phase 1 Data Model: VLM Image Input for ChatCompletion
**Spec**: [spec.md](./spec.md) · **Research**: [research.md](./research.md)
The feature adds **one optional field** to an existing model and defines how it
maps into each backend's wire shape. No new entities, no new socket types.
---
## Modified entity — `Message` (`src/comfydv/_llm/provider.py`)
```python
class Message(BaseModel):
role: Literal["system", "user", "assistant"]
content: str
images: list[str] | None = None # NEW — base64-encoded images (no data: prefix)
```
**Field: `images`**
- **Type**: `list[str] | None`, default `None`.
- **Meaning**: base64-encoded image payloads associated with this turn. `None`
(or empty) means a text-only turn — **byte-for-byte identical to today**.
- **Carrier form**: raw base64 string, no `data:` URI prefix. Chosen because
every target adapts *from* it (Ollama `images` array, OpenAI data-URI,
pydantic-ai `BinaryContent`) — ADR-008.
- **Validation**: no format validation at the model layer (the model stays a
dumb carrier); malformed data surfaces as a backend error (FR-006). A turn
may carry ≥1 image; MVP exercises exactly one.
- **Serialization invariant**: provider payload construction MUST omit the
`images` key when `None`/empty so existing text-only requests are unchanged
(research.md Decision 2; FR-003, SC-004).
---
## Mapping table — one carrier, three wire shapes
| Path | Code site | Transform |
|---|---|---|
| Ollama free-text | `ollama_provider.py::chat` | none — `model_dump()`'s flat `images` array already matches `/api/chat`; only drop the key when empty |
| llama.cpp free-text | `llamacpp_provider.py::chat` | rebuild `content` as OpenAI parts: `[{"type":"text",...},{"type":"image_url","image_url":{"url":"data:image/png;base64,<b64>"}}]` |
| Structured (both) | `chat.py::chat_structured` | build `BinaryContent(data=b64decode(img), media_type="image/png")`; attach to `user_prompt` (last turn) / `UserPromptPart` (history turns) as `[text, *images]` |
---
## Node input — `ChatCompletion` (`src/comfydv/ollama.py`)
Add to `INPUT_TYPES["optional"]`:
```python
"image": ("IMAGE",),
```
- **Optional** — un-wired ⇒ `image=None` ⇒ the node builds exactly today's
text-only user message. No new required input; no output/socket change
(Constitution VI untouched — `RETURN_TYPES` positions 0/1 unchanged).
- When wired: encode the tensor to base64 PNG(s) (research.md Decision 4) and
set them on the appended `Message(role="user", ...)`. History turns are not
modified (FR-007).
### Encode helper (node-local, `comfy`/Pillow lazy)
```
_encode_image_tensor(image) -> list[str]:
# image: ComfyUI IMAGE, torch float tensor [B, H, W, C] in 0..1
# → for each frame: *255 → uint8 → PIL.Image.fromarray → PNG bytes → base64
# returns [] for None/empty so callers treat it as "no image"
```
Lives in `ollama.py` (node module, already `comfy`-guarded). `src/comfydv/_llm/`
never imports torch/numpy/Pillow — it deals only in the base64 strings this
helper produces.
---
## State & relationships
- No persistent state; no new caching entity. Existing `_CHAT_RESPONSE_CACHE`
keys already include the dumped messages, so an added `images` value
participates in the cache key automatically (same image + prompt ⇒ cache
hit), and a text-only turn's key is unchanged since the empty key is omitted.
- Relationship: `images` belongs to exactly one `Message` (one turn) — this is
why the carrier is a message field, not a side-channel parameter (ADR-008
Alternative A rejected).
@@ -0,0 +1,11 @@
Feature: US1 — Describe an image with a chat node
Scenario: Describe a wired image
Given a chat node connected to a backend with a vision-capable model loaded and an image wired into the node's image input
When the workflow executes with a prompt like "describe this image"
Then the node returns a text response that reflects the actual content of the wired image
Scenario: No image wired behaves exactly as today
Given the same chat node with no image wired
When the workflow executes
Then the node behaves exactly as it does today — text-only chat, identical response for identical text input — with no new required inputs and no change in output
@@ -0,0 +1,11 @@
Feature: US2 — Same image input on either backend
Scenario: Swap Ollama for llama.cpp and the image path still works
Given a workflow that describes an image via the chat node wired to Ollama
When the connection node is swapped to a llama.cpp one (pointed at a server with a multimodal model) with no other change
Then the workflow still returns a description of the same image
Scenario: Both backends produce an image-grounded response
Given equivalent image + prompt inputs on both backends
When each workflow executes
Then both produce a coherent image-grounded text response — no backend requires a different node, input shape, or wiring for the image
@@ -0,0 +1,11 @@
Feature: US3 — Structured output about an image
Scenario: Structured fields populated from the image
Given the chat node with an image wired and structured output enabled with a valid schema
When the workflow executes against a vision-capable model
Then each schema field is available as its own typed output, populated from the image, with no required field blank
Scenario: Invalid structured output retries then fails clearly
Given the same setup where the model first returns invalid or incomplete structured output
When the workflow executes
Then the node retries and, if still unsuccessful, fails with a clear error — the same retry/validation behaviour the text-only structured path already guarantees
+125
View File
@@ -0,0 +1,125 @@
# Implementation Plan: VLM Image Input for ChatCompletion
**Branch**: `009-vlm-image-input` | **Date**: 2026-07-22 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/009-vlm-image-input/spec.md`
## Summary
Let a workflow author wire a ComfyUI `IMAGE` into the existing generic
`ChatCompletion` node so a vision-capable model can describe or reason about it,
on **either** backend. Per ADR-008 (extending ADR-007's adapter pattern to a
second input modality), images ride on an optional `Message.images` carrier and
each provider translates that carrier into its own wire shape: Ollama's flat
`/api/chat` `images` array (passes through untouched), llama.cpp's OpenAI
`image_url` content-parts, and — for structured output — pydantic-ai
`BinaryContent`, shared by both backends through `OpenAIChatModel`. The node
converts its `IMAGE` tensor to base64 PNG; everything below the node deals only
in base64 strings. Text-only behaviour is byte-for-byte unchanged when no image
is wired.
## Technical Context
**Language/Version**: Python ≥3.11 (unchanged, per `pyproject.toml`)
**Primary Dependencies**: existing only for runtime — `aiohttp` (Ollama/llama.cpp
REST), `pydantic-ai-slim[openai]>=2.9.0` (structured path; its `BinaryContent`
multimodal type was verified against the installed 2.9.0 source, see
`research.md`). **No new core runtime dependency.** `pillow` is added to the
**dev** group so the node's tensor→PNG encoder is unit-testable without a live
ComfyUI; at runtime Pillow/numpy are ComfyUI-provided (same stance the repo
already takes for torch).
**Storage**: N/A — no persistent storage; reuses the existing
`_CHAT_RESPONSE_CACHE` (an `images` value participates in the cache key
automatically).
**Testing**: `pytest` via `uv run pytest`, following the
`tests/test_ollama_provider.py` convention (mock at each provider's own
`_post_json` / `Agent.run` seam, no live server or ComfyUI required). Test-first
per Constitution III; the tensor-encode test uses a synthetic tensor + Pillow.
**Target Platform**: ComfyUI custom-node runtime, same as the existing LLM nodes.
**Project Type**: Library / ComfyUI custom-node pack (single project).
**Performance Goals**: No new numeric target; image encoding is a one-shot
per-execution PNG encode, negligible against inference latency.
**Constraints**: Text-only requests MUST stay byte-identical (FR-003/SC-004) —
providers omit an empty `images` key. `src/comfydv/_llm/` must not import
torch/numpy/Pillow (Constitution IV) — tensor handling stays in the node.
llama.cpp image support requires a server launched with `--mmproj` (deployment
prerequisite, surfaced as a clear error when absent, not configured by comfydv).
**Scale/Scope**: One new `Message` field; a per-provider mapping in each
`chat()` plus the shared `chat_structured()`; one optional node input + a
node-local encode helper. No new node classes, no new socket types, no protocol
method changes.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Verdict | Notes |
|---|---|---|
| I. ComfyUI Contract First | PASS | `ChatCompletion` keeps its `INPUT_TYPES`/`RETURN_TYPES`/`FUNCTION`/`CATEGORY`; only an optional input is added. No new registration, no ComfyUI changes. |
| II. Sandbox All User-Supplied Code | N/A | No template/expression evaluation in this feature. |
| III. Test-First | PASS (binding) | Test contracts T1–T6 (`contracts/image-input-contract.md`) written test-first, mirroring `test_ollama_provider.py`. Each runs without a live ComfyUI/backend. |
| IV. Graceful Degradation Outside ComfyUI | PASS (binding) | `_llm/` stays torch/numpy/Pillow-free — pure base64 carrier + mapping, unit-testable. Tensor→PNG lives in `ollama.py` (already `comfy`-guarded) with lazy Pillow/numpy import, so module import outside ComfyUI is unaffected. |
| V. Simplicity — Function Before Class | PASS | No new class. New logic is a `Message` field, two small per-provider transforms, one shared helper edit, and one module-level encode function. |
| VI. Fixed Output Positions | PASS | Outputs are untouched — only an optional **input** is added; `RETURN_TYPES`/`RETURN_NAMES` positions 0/1 and the structured extra-outputs contract are unchanged. |
Re-checked post-Phase 1 design (data-model.md, contracts/): unchanged — no new
gate violations. No Complexity Tracking entries needed.
## Project Structure
### Documentation (this feature)
```text
specs/009-vlm-image-input/
├── plan.md # This file
├── research.md # Phase 0 — wire shapes verified against installed deps
├── data-model.md # Phase 1 — Message.images + per-provider mapping
├── quickstart.md # Phase 1 — minimal describe-an-image workflow
├── contracts/
│ └── image-input-contract.md # Phase 1 — behavioural + test contracts (T1–T6)
├── checklists/requirements.md # Spec quality checklist (from /speckit-specify)
└── tasks.md # Phase 2 (/speckit-tasks) — not created here
```
### Source Code (repository root)
```text
src/comfydv/
├── ollama.py # MODIFIED — ChatCompletion: optional `image` input +
│ # node-local _encode_image_tensor() (lazy Pillow/numpy)
├── _llm/
│ ├── provider.py # MODIFIED — Message gains `images: list[str] | None = None`
│ ├── ollama_provider.py # MODIFIED — chat(): pass flat images through; omit empty key
│ ├── llamacpp_provider.py # MODIFIED — chat(): map images → OpenAI image_url parts
│ └── chat.py # MODIFIED — chat_structured(): images → BinaryContent on prompt
└── __init__.py # unchanged — no new node class or mapping
tests/
├── test_provider.py (or test_ollama_provider.py) # T1 Message carrier + regression
├── test_ollama_provider.py # MODIFIED — T2 Ollama image mapping + text regression
├── test_llamacpp_provider.py # MODIFIED — T3 llama.cpp content-parts + text regression
├── test_llm_chat.py / chat tests # T4 chat_structured multimodal + regression
└── test_ollama.py # MODIFIED — T5 encode helper, T6 node input contract
pyproject.toml # MODIFIED — add `pillow` to [dependency-groups].dev only
```
**Structure Decision**: Purely additive edits to the four existing `_llm`/node
files that ADR-007 established — no new module, because there is no new class or
node (contrast 008, which added a provider + node). The image path threads
through the exact seams the text path already uses, which is the whole point of
ADR-008: a second modality on the same adapter, not a parallel structure.
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
None — see Constitution Check above.
+48
View File
@@ -0,0 +1,48 @@
# Quickstart: Describe an image with ChatCompletion
**Spec**: [spec.md](./spec.md)
Minimal end-to-end walkthrough of the feature once shipped.
## Prerequisites
- A running backend with a **vision-capable** model:
- **Ollama** — a multimodal model pulled and available (e.g. a llava-class model), or
- **llama.cpp** — `llama-server` launched in router mode **with a multimodal projector**: `--mmproj <projector.gguf>` alongside the model.
- comfydv installed in ComfyUI.
## Steps
1. Add an image source to the canvas (e.g. **Load Image**) → gives an `IMAGE`.
2. Add a client node (**OllamaClient** or **LlamaCppClient**) → gives `LLM_CLIENT`.
3. Add **ChatCompletion**. Wire:
- `client` ← the client node
- `model` ← a vision-capable model name (typed or wired)
- `prompt` ← `"Describe this image in one sentence."`
- `image` ← the `IMAGE` from step 1 ← **the only new wire**
4. Queue the prompt. The `response` output is a text description of the image.
## Structured variant (optional)
- On **ChatCompletion**, set `structured_output = True` and provide a schema, e.g.:
```json
{"type":"object","properties":{"caption":{"type":"string"},"has_text":{"type":"boolean"}},"required":["caption","has_text"]}
```
- Run: each field (`caption`, `has_text`) appears as its own typed output,
populated from the image, with no required field blank.
## Swap backends (proves FR-004 / SC-002)
- Replace **OllamaClient** with **LlamaCppClient** (pointed at an `--mmproj`
server) — **change nothing else**. Re-queue: same image description path.
## What stays the same
- Leave `image` un-wired and ChatCompletion behaves exactly as before — text-only,
identical results. No existing workflow changes.
## Expected failure (proves FR-006 / SC-005)
- Wire an image but select a **non-vision** model (or a `llama-server` started
without `--mmproj`): the node reports a clear error that the model/server
can't process images — it does not silently answer as if no image was sent.
+155
View File
@@ -0,0 +1,155 @@
# Phase 0 Research: VLM Image Input for ChatCompletion
**Spec**: [spec.md](./spec.md) · **Plan**: [plan.md](./plan.md) · **ADR**: [ADR-008](../../project-management/ADRs/ADR-008-multimodal-image-input-across-llmprovider-boundary.md)
ADR-008 recorded the boundary decision (images on `Message.images`, translated
per-provider) but deferred the exact wire shapes for live verification. This
file resolves them against the **installed** dependency versions and the
current provider code, not from memory.
---
## Decision 1 — pydantic-ai multimodal vehicle (structured-output path)
**Decision**: In the shared `chat_structured()` helper (`src/comfydv/_llm/chat.py`),
attach images as `pydantic_ai.messages.BinaryContent(data=<png bytes>,
media_type="image/png")` inside a `Sequence[UserContent]`. The current-turn
image rides on `Agent.run(user_prompt=[text, BinaryContent(...)])`; a
history turn's image rides on `UserPromptPart(content=[text, BinaryContent(...)])`.
**Rationale / verified**: Read directly from the pinned
`pydantic_ai_slim==2.9.0` source in this environment:
- `BinaryContent` (`messages.py:521`) — `__init__(self, data: bytes, *,
media_type: ..., identifier=None, ...)`; exposes a `.base64` helper. `data`
is **bytes**, so `chat.py` must `base64.b64decode()` the `Message.images`
string into bytes when building it.
- `UserPromptPart.content: str | Sequence[UserContent]` (`messages.py:1022`)
and `user_prompt` on `Agent.run` accept the same. `UserContent = str |
TextContent | MultiModalContent | CachePoint` (`messages.py:899`), and
`MultiModalContent` includes `BinaryContent`/`ImageUrl` — so a `[text,
image]` list is the supported shape.
- `OpenAIChatModel` renders `BinaryContent` for images as an OpenAI
`image_url` data-URI, and reads `BinaryContent.vendor_metadata['detail']`
for the `detail` setting (documented in the field's own docstring).
**Consequence**: the structured path is provider-agnostic *for free* — both
Ollama and llama.cpp reach `/v1/chat/completions` through the same
`OpenAIChatModel`, so one change in `chat.py` covers structured output on both
backends. No per-provider structured code.
**Alternatives considered**: `ImageUrl(url="data:image/png;base64,...")` — also
supported, but requires assembling a data URI string; `BinaryContent` from raw
bytes + media type is the more direct representation of what we hold and lets
pydantic-ai own the data-URI formatting.
---
## Decision 2 — Ollama free-text path (`/api/chat`)
**Decision**: `OllamaProvider.chat()` sends each message's images as a flat
`images` array of **base64 strings** (no `data:` prefix) alongside `content`,
which is exactly Ollama's native `/api/chat` message schema. Because
`Message.images` already holds base64 strings, `Message.model_dump()` produces
the correct shape with **no transform** — the field flows straight through.
**Rationale / verified**: `OllamaProvider.chat()`
(`src/comfydv/_llm/ollama_provider.py:280`) already builds
`payload_messages = [m.model_dump() for m in messages]` and POSTs to
`/api/chat`. Ollama's documented `/api/chat` message object is
`{"role", "content", "images": [<base64>, ...]}` — the flat sibling field this
carrier maps onto directly. This is the reason base64 is the neutral carrier
form (ADR-008).
**Constraint discovered — byte-identical text path (FR-003/SC-004)**: adding
`images: list[str] | None = None` to `Message` means a text-only message would
dump as `{"role","content","images":null}`, changing today's request body.
Providers MUST drop a `None`/empty `images` before sending. Resolution:
serialize provider payload messages with the images key omitted when empty
(e.g. `model_dump(exclude_none=True)`, or drop the key explicitly). Guarded by
the existing Ollama provider tests, which assert the exact payload.
---
## Decision 3 — llama.cpp free-text path (`/v1/chat/completions`)
**Decision**: `LlamaCppProvider.chat()` maps a message carrying images into
OpenAI-style multimodal `content` **parts** before POSTing:
`content: [{"type":"text","text":<content>},
{"type":"image_url","image_url":{"url":"data:image/png;base64,<b64>"}}]`.
Messages with no images keep the plain-string `content` unchanged.
**Rationale / verified**: `LlamaCppProvider.chat()`
(`src/comfydv/_llm/llamacpp_provider.py:163`) builds
`payload_messages = [m.model_dump() for m in messages]` and POSTs to
`/v1/chat/completions`. Unlike Ollama, a flat `images` sibling is **not**
understood there — OpenAI's vision schema requires images inside `content` as
typed parts. `llama-server` implements this OpenAI-compatible multimodal
format **only when launched with a multimodal projector (`--mmproj`)**; without
it, image parts yield a server error (surfaced per FR-006, not crashed on).
This is the single point where the two providers genuinely diverge — exactly
the leakage ADR-008 localizes inside each provider.
**Alternatives considered**: normalizing Ollama *up* to content-parts too (one
shared mapper) — rejected in ADR-008 Alternative C: it forces the
currently-simpler Ollama path to do extra work and inverts "each provider owns
its wire format."
---
## Decision 4 — ComfyUI IMAGE tensor → base64 PNG (node layer)
**Decision**: The `ChatCompletion` node converts its optional `IMAGE` input to
base64 PNG(s) via Pillow: ComfyUI IMAGE is a float tensor `[B, H, W, C]` in
`0..1`; scale to `uint8`, `PIL.Image.fromarray(...)`, save PNG to an in-memory
buffer, base64-encode. A batch of `B` frames becomes `B` base64 strings in the
turn's `images` list (natural multi-image; MVP exercises `B=1`). The import of
Pillow/numpy is **lazy** (inside the encode function), so the module still
imports cleanly outside ComfyUI (Constitution IV).
**Rationale**: Pillow is the ComfyUI-ecosystem standard for IMAGE tensor ↔
file and is present in every ComfyUI install; numpy comes with torch. Neither
is added to comfydv's **core** runtime deps — they are ComfyUI-provided, the
same stance the repo already takes for torch (dev-only in `pyproject.toml`).
To keep the encoder **test-first** (Constitution III) without a live ComfyUI,
add `pillow` to the **dev** dependency group so a unit test can feed a
synthetic `numpy`/`torch` tensor through the pure encode function and assert a
decodable PNG.
**Boundary kept clean**: only the node (`src/comfydv/ollama.py`, already
`comfy`-guarded) touches tensors/Pillow. Everything in `src/comfydv/_llm/`
deals purely in base64 strings and stays unit-testable with hand-crafted
strings — no torch, numpy, or Pillow import there.
**Edge cases (FR-006, Edge Cases)**: an un-wired optional input arrives as
`None` → node builds today's exact text-only message. A zero-size / empty batch
tensor → treated as "no image". A non-vision model or non-`mmproj` server
returns a backend error → surfaced with a clear message, never a silent
image-less answer.
---
## Decision 5 — where the image attaches on the turn (FR-007)
**Decision**: The node attaches images to the **current user turn only** — the
`Message(role="user", content=prompt, images=[...])` it already appends. Prior
`history` turns are untouched. The structured helper likewise only lifts images
onto the final user turn (and any history turn that already carried them),
matching its existing "last message is the prompt" contract
(`chat.py:106`, which requires `messages[-1].role == "user"`).
---
## Summary of resolved unknowns
| Unknown (from ADR-008) | Resolved to |
|---|---|
| pydantic-ai multimodal type | `BinaryContent(data=bytes, media_type="image/png")` — verified in installed 2.9.0 |
| Structured path per-provider? | No — shared via `OpenAIChatModel`; one change in `chat.py` |
| Ollama wire shape | flat `images: [base64]` on the message; passes through `model_dump()` |
| llama.cpp wire shape | OpenAI `image_url` content-parts; requires `--mmproj` |
| Text-path byte-identity | drop empty `images` key in provider payloads (guarded by existing tests) |
| Tensor → base64 | Pillow, lazy import in node; `pillow` added to dev deps for testability |
| No new runtime deps | Confirmed — Pillow/numpy are ComfyUI-provided, dev-only here |
No `NEEDS CLARIFICATION` remain.
+148
View File
@@ -0,0 +1,148 @@
# Feature Specification: VLM Image Input for ChatCompletion
**Feature Branch**: `009-vlm-image-input`
**Created**: 2026-07-22
**Status**: Draft
**Input**: User description: "Let a workflow author wire a ComfyUI IMAGE into the existing generic ChatCompletion node so a vision-capable model (VLM) on either backend (Ollama multimodal models, llama.cpp multimodal via mmproj) can describe or understand the image. Provider-agnostic per ADR-007/ADR-008: the node attaches the image to the user message; each provider maps it to its own wire format. The Message carrier gains an optional image field; text-only behaviour is unchanged when no image is wired."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Describe an image with a chat node (Priority: P1) 🎯 MVP
As a ComfyUI workflow author with a vision-capable model available, I want to
wire an image into the chat node I already use and get back a text description
or answer about that image, so I can add image understanding to a workflow
without learning a new node.
**Why this priority**: This is the entire point of the feature — a picture in,
a text understanding out — and the proof that image input works through the
existing generic node on at least one backend.
**Independent Test**: Wire any image source into the chat node's image input,
point the node at a loaded vision-capable model, run the workflow, and confirm
the response text describes the wired image.
**Acceptance Scenarios**:
1. **Given** a chat node connected to a backend with a vision-capable model loaded and an image wired into the node's image input, **When** the workflow executes with a prompt like "describe this image", **Then** the node returns a text response that reflects the actual content of the wired image.
2. **Given** the same chat node with **no** image wired, **When** the workflow executes, **Then** the node behaves exactly as it does today — text-only chat, identical response for identical text input — with no new required inputs and no change in output.
---
### User Story 2 - Same image input on either backend (Priority: P1)
As a workflow author, I want image input to work the same way whether my chat
node is connected to Ollama or to llama.cpp, so I don't have to rebuild or
relearn the image path when I switch backends — exactly as text and structured
output already behave identically across the two.
**Why this priority**: The generic-node promise (ADR-007) is the reason this
feature is small; this story is what proves the image path honours it rather
than quietly becoming backend-specific.
**Independent Test**: Run User Story 1 unchanged against an Ollama connection
and against a llama.cpp connection (each with a vision-capable model), and
confirm both return a description of the wired image using the identical node
setup.
**Acceptance Scenarios**:
1. **Given** a workflow that describes an image via the chat node wired to Ollama, **When** the connection node is swapped to a llama.cpp one (pointed at a server with a multimodal model) with no other change, **Then** the workflow still returns a description of the same image.
2. **Given** equivalent image + prompt inputs on both backends, **When** each workflow executes, **Then** both produce a coherent image-grounded text response — no backend requires a different node, input shape, or wiring for the image.
---
### User Story 3 - Structured output about an image (Priority: P2)
As a workflow author, I want to combine image input with the node's existing
structured-output mode, so a VLM can return schema-validated fields extracted
from an image (for example a caption, a list of detected objects, or a
yes/no), not just free text.
**Why this priority**: Structured output is an existing, valued capability;
making it work with images turns "describe this" into usable, wired,
downstream-typed data. It builds on User Story 1 and is lower risk to defer
than getting basic image chat working at all.
**Independent Test**: Enable structured output on the chat node with a schema,
wire an image, run against a vision-capable model, and confirm each schema
field is populated from the image and no required field is blank.
**Acceptance Scenarios**:
1. **Given** the chat node with an image wired and structured output enabled with a valid schema, **When** the workflow executes against a vision-capable model, **Then** each schema field is available as its own typed output, populated from the image, with no required field blank.
2. **Given** the same setup where the model first returns invalid or incomplete structured output, **When** the workflow executes, **Then** the node retries and, if still unsuccessful, fails with a clear error — the same retry/validation behaviour the text-only structured path already guarantees.
---
### Edge Cases
- What happens when an image is wired but the selected model is **not**
vision-capable? The node must surface a clear error attributable to the model
lacking image support, not crash and not silently drop the image and answer
as if none was sent.
- What happens when the backend server is reachable but was not started with
multimodal support (e.g. a llama.cpp server launched without an `mmproj`
projector)? The node should report a clear, specific error rather than an
unhelpful generic failure.
- What happens with an empty or zero-size image input, or an image input that
is wired but carries no actual image data? The node should treat it as "no
image" or report a clear error — never send a malformed request.
- What happens when both an image and a multi-turn history are present? The
image must be associated with the current user turn, and prior turns must
remain unaffected.
- What happens to the node's text-only path for a model/backend that does not
understand images at all — does an un-wired image input leave the request
byte-for-byte identical to today's? (It must.)
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST let a workflow author provide an image to the existing chat node through a single, **optional** image input — no new node and no new required input.
- **FR-002**: When an image is provided, the system MUST include it with the current user turn sent to the connected model, so a vision-capable model can ground its response in that image.
- **FR-003**: When **no** image is provided, the system MUST send exactly the request it sends today — text-only behaviour, inputs, and outputs unchanged, with no regression for existing workflows.
- **FR-004**: Image input MUST work identically across both supported backends from the workflow author's perspective — same node, same wiring, same input shape — with each backend's differing native image format handled internally, not exposed on the graph.
- **FR-005**: Image input MUST be compatible with the node's existing structured-output mode: an image-grounded response can be schema-validated with the same retry and validation guarantees as the text-only structured path.
- **FR-006**: The system MUST surface a clear, specific error when an image is provided but the target model or backend cannot process images (non-vision model, or a server without multimodal support), rather than crashing or silently discarding the image.
- **FR-007**: The system MUST associate a provided image with the current user turn only, leaving any prior conversation history unchanged.
### Key Entities *(include if feature involves data)*
- **Chat message**: The existing per-turn unit of a chat request. Extended so a
turn can optionally carry one or more images in addition to its text; a
turn with no image is unchanged from today.
- **Image input**: An image supplied on the workflow canvas (the standard
ComfyUI image type) and attached to the current user turn; provider-neutral
at the boundary, translated to each backend's native shape internally.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: A workflow author can make an existing chat node describe a wired image by adding exactly one connection (the image), with no new node and no other node changes.
- **SC-002**: The same image-describing workflow runs unchanged when repointed from one backend to the other — zero edits beyond swapping the connection node.
- **SC-003**: Structured-output workflows with an image populate every required schema field from the image content, with zero blank-required-field results, matching the text-only structured guarantee.
- **SC-004**: Every existing text-only workflow produces identical results after this feature ships — no observable change when no image is wired (existing backend behaviour tests remain green).
- **SC-005**: Providing an image to a non-vision model or a non-multimodal server yields a clear, specific error in 100% of such cases — never a crash and never a silently image-less answer presented as if the image was seen.
## Assumptions
- Workflow authors run their own backend (Ollama or llama.cpp) with a
vision-capable model available and loaded; for llama.cpp this means the
server was launched with a multimodal projector (`mmproj`). This feature does
not install, configure, download, or launch vision models.
- Scope is still **images only** — no video, audio, or document modalities; and
image **input** only — no image generation or output.
- A single image per turn is the primary target; carrying more than one image
per turn is a natural extension of the same carrier but is not a required
acceptance criterion of the MVP.
- The generic `ChatCompletion` node, the `LLMProvider` protocol, and both
providers already exist (ADR-007) and are extended, not replaced; the
cross-provider image-carrier decision is recorded in ADR-008.
- The standard ComfyUI image type is the input; converting it to the neutral
form each backend consumes is an internal concern of this feature, not
something the workflow author sees.
+146
View File
@@ -0,0 +1,146 @@
# Tasks: VLM Image Input for ChatCompletion
**Input**: Design documents from `/specs/009-vlm-image-input/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/image-input-contract.md
**Tests**: First-class — every implementation task has a paired failing-test task (`-T`/`-I` suffix). Contracts T1–T6 in `contracts/image-input-contract.md` map to the pairs below.
**Organization**: Grouped by user story (spec.md priorities: US1 P1 🎯 MVP, US2 P1, US3 P2).
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: US1–US3
- **-T / -I**: paired test (red) / implementation (green) — the `-T` is committed failing before its `-I` partner (no test + impl in one commit)
## Path Conventions
Single project: `src/comfydv/`, `tests/` at repo root. Purely additive edits to
the existing `_llm`/node files (plan.md Structure Decision) — no new module.
`src/comfydv/_llm/` stays torch/numpy/Pillow-free (Constitution IV); tensor
handling lives only in the `comfy`-guarded `ollama.py`.
---
## Phase 1: Setup
- [x] T001 Add `pillow` to `[dependency-groups].dev` in `pyproject.toml` — lets the node's tensor→PNG encoder be unit-tested without a live ComfyUI; runtime Pillow/numpy are ComfyUI-provided, so **no core runtime dependency is added** (research.md Decision 4)
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: the image carrier every path depends on. **⚠️ No user story work can begin until this is complete.**
- [x] T002-T Write FAILING test: `Message.images` defaults to `None`, round-trips a base64 list, and a text-only message's transport dump **omits** the `images` key (byte-identical to today), in `tests/test_llm_provider.py` (contract T1)
- [x] T002-I Add `images: list[str] | None = None` to `Message` in `src/comfydv/_llm/provider.py` — makes T002-T pass
**Checkpoint**: carrier ready — user stories can begin.
---
## Phase 3: User Story 1 — Describe an image with a chat node (Priority: P1) 🎯 MVP
**Goal**: A workflow author wires a ComfyUI `IMAGE` into the existing `ChatCompletion` node and gets back a text description via an Ollama vision model; the text-only path is unchanged when no image is wired.
**Independent Test**: Wire an image → `ChatCompletion` → Ollama (vision model), confirm the response describes the image; un-wire the image and confirm behaviour/output identical to today.
- [x] T003-T [P] [US1] Write FAILING test: node-local `_encode_image_tensor()` converts a synthetic `[1,H,W,3]` float tensor (0..1) into a **decodable** base64 PNG, encodes a `B>1` batch to a list of that length, and returns `[]` for `None`/empty, in `tests/test_ollama.py` (contract T5; witnesses `features/us1_describe_image.feature` scenario "Describe a wired image")
- [x] T003-I [US1] Implement `_encode_image_tensor()` in `src/comfydv/ollama.py` — lazy `PIL`/`numpy` import so module import stays clean outside ComfyUI (Constitution IV); batch → one base64 string per frame — makes T003-T pass
- [x] T004-T [US1] Write FAILING test: `ChatCompletion.INPUT_TYPES` exposes an **optional** `image: ("IMAGE",)`; `RETURN_TYPES`/`RETURN_NAMES` positions are unchanged; an un-wired run builds the same text-only messages as today; a wired run attaches images to the **last user turn only** (history untouched), in `tests/test_ollama.py` (contract T6; witnesses both `features/us1_describe_image.feature` scenarios)
- [x] T004-I [US1] Add the optional `image` input (with a tooltip noting a vision-capable model is required; llama.cpp needs `--mmproj`) and attach encoded images to the appended user `Message` in `ChatCompletion.chat()` in `src/comfydv/ollama.py` — makes T004-T pass (depends on T003-I, T002-I)
- [x] T005-T [P] [US1] Write FAILING test: `OllamaProvider.chat()` forwards a message's images as a flat `images:[...]` array to `/api/chat`, and a text-only call's payload is **byte-identical to today** (regression), in `tests/test_ollama_provider.py` (contract T2; witnesses `features/us1_describe_image.feature` scenario "Describe a wired image")
- [x] T005-I [US1] Ensure `OllamaProvider.chat()` passes images through and omits the empty `images` key (e.g. `model_dump(exclude_none=True)`) in `src/comfydv/_llm/ollama_provider.py` — makes T005-T pass (depends on T002-I)
**Checkpoint**: describe-an-image works end-to-end on Ollama (MVP); every existing text-only test stays green.
---
## Phase 4: User Story 2 — Same image input on either backend (Priority: P1)
**Goal**: The same node and wiring drive image input on llama.cpp too, via its OpenAI-compatible content-parts shape — proving the generic-node promise (ADR-007/008) holds for the image path.
**Independent Test**: Run the US1 workflow unchanged against a llama.cpp server (launched with `--mmproj`); swap the Ollama client node for the llama.cpp one with no other change and confirm the image is still described.
- [x] T006-T [P] [US2] Write FAILING test: `LlamaCppProvider.chat()` maps a message's images into OpenAI `content` parts (`{"type":"text",...}` + `{"type":"image_url","image_url":{"url":"data:image/png;base64,..."}}`) for `/v1/chat/completions`, and a text-only message keeps a **plain-string** `content` (regression), in `tests/test_llamacpp_provider.py` (contract T3; witnesses both `features/us2_both_backends.feature` scenarios)
- [x] T006-I [US2] Implement the images→content-parts mapping in `LlamaCppProvider.chat()` in `src/comfydv/_llm/llamacpp_provider.py`; leave text-only messages untouched — makes T006-T pass (depends on T002-I)
**Checkpoint**: parity proven — the identical node/wiring describes an image on both backends; swapping the client node is the only change.
---
## Phase 5: User Story 3 — Structured output about an image (Priority: P2)
**Goal**: Image input works with the node's existing structured-output mode, via the shared `chat_structured()` helper (pydantic-ai `BinaryContent`) — one implementation covering both backends through `OpenAIChatModel`.
**Independent Test**: Enable structured output with a schema, wire an image, run against a vision model, confirm each field is populated from the image with no required field blank; a first-invalid response retries then fails clearly.
- [x] T007-T [US3] Write FAILING test: `chat_structured()` attaches a message's images as `BinaryContent(data=b64decode(img), media_type="image/png")` onto the run's `user_prompt` (last turn) and onto history `UserPromptPart`s, a text-only structured call is unchanged, and the retry/validation contract is intact, in `tests/test_llm_chat_structured.py` (contract T4; witnesses both `features/us3_structured_image.feature` scenarios) — mock at the `Agent.run`/`_build_agent` seam per the established convention
- [x] T007-I [US3] Implement image→`BinaryContent` handling in `chat_structured()` and `_history_to_messages()` in `src/comfydv/_llm/chat.py` — makes T007-T pass (depends on T002-I)
**Checkpoint**: structured image output works on both backends via the one shared helper; all prior stories remain green.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [x] T008 [P] Document image input on `ChatCompletion` in `README.md` and add a `CHANGELOG.md` Unreleased entry — note the vision-model / llama.cpp `--mmproj` prerequisite (quickstart.md)
- [x] T009 Run the full quality gate: `ruff check` ✓, `ruff format` ✓, `pytest` ✓ (289 passed, +21 new; all spec-009 code green), `beacon doctor --strict` ✓ for this spec (bullet + BDD + backlinks pass). _Pre-existing, out of scope: `ty check` has 36 diagnostics repo-wide (0 from spec-009 code — verified), one Docker packaging test (`test_dockerfile_uses_python_311_base`) fails at baseline, and `spec-task-alignment` flags 007's deferred tasks under --strict._
- [-] T010 End-to-end `quickstart.md` validation against a live vision backend (Ollama multimodal model and `llama-server --mmproj`) _Deferred — requires a live vision-capable backend not available in CI/this environment; validate manually before release._
---
## Dependencies & Execution Order
- **Setup (T001)** → no dependencies; start immediately.
- **Foundational (T002-T/I)** → depends on nothing; **blocks all user stories** (every path reads `Message.images`).
- **US1 (T003–T005)** → after T002-I. `T004-I` depends on `T003-I`; `T005-I` depends on `T002-I`. MVP.
- **US2 (T006)** → after T002-I. Independent of US1's files; independently testable.
- **US3 (T007)** → after T002-I. Independent of US1/US2's files; independently testable.
- **Polish (T008–T010)** → after the stories you intend to ship.
### Within each story
- The `-T` task is written and committed **failing** before its `-I` partner (`tdd-commit-discipline`).
- `-I` is never `[P]` with its own `-T`.
### Parallel opportunities
- US1: `T003-T` (`tests/test_ollama.py`) and `T005-T` (`tests/test_ollama_provider.py`) are different files → `[P]`.
- Across stories: US1, US2, US3 touch different provider/helper files and can proceed in parallel once T002-I lands.
---
## Parallel Example: User Story 1
```bash
# Different test files, no shared deps — write both failing tests together:
Task: "T003-T encode-helper test in tests/test_ollama.py"
Task: "T005-T Ollama image-passthrough test in tests/test_ollama_provider.py"
```
---
## Implementation Strategy
### MVP first (US1 only)
1. T001 Setup → T002 carrier → T003–T005 US1.
2. **STOP and VALIDATE**: an Ollama vision model describes a wired image; every text-only test stays green.
3. Demoable as-is.
### Incremental delivery
1. Foundation + US1 → describe-an-image on Ollama (MVP).
2. + US2 → same node works on llama.cpp (parity).
3. + US3 → structured output about an image (both backends).
4. Polish → docs, quality gate, manual live validation (T010).
---
## Notes
- `[-]` (T010) is a **known-deferred** follow-up — `beacon bullet finish` skips it rather than flipping to `[x]`; `beacon doctor` reports it as deferred, held under `--strict`.
- `beacon doctor` runs two gates against this discipline: `spec-bdd-coverage` (every acceptance scenario has a `.feature` witness — 6 scenarios across 3 features here) and `tdd-commit-discipline` (no test + implementation in the same commit). Both FAIL under `--strict`.
- Commit after each task or `-T`/`-I` pair; keep existing Ollama/llama.cpp/text tests green throughout (FR-003/SC-004 regression guard).
+6
View File
@@ -14,8 +14,10 @@ from .ollama import (
OllamaHeaderBearerToken,
OllamaHeaderCustom,
OllamaHistoryLength,
OllamaOptionDisableThinking,
OllamaOptionExtraBody,
OllamaOptionMaxTokens,
OllamaOptionRefusalRetry,
OllamaOptionRepeatPenalty,
OllamaOptionSeed,
OllamaOptionTemperature,
@@ -46,6 +48,8 @@ NODE_CLASS_MAPPINGS = {
"OllamaOptionTopP": OllamaOptionTopP,
"OllamaOptionTopK": OllamaOptionTopK,
"OllamaOptionRepeatPenalty": OllamaOptionRepeatPenalty,
"OllamaOptionDisableThinking": OllamaOptionDisableThinking,
"OllamaOptionRefusalRetry": OllamaOptionRefusalRetry,
"OllamaOptionExtraBody": OllamaOptionExtraBody,
"OllamaDebugHistory": OllamaDebugHistory,
"OllamaHistoryLength": OllamaHistoryLength,
@@ -72,6 +76,8 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"OllamaOptionTopP": "Ollama Option — Top P",
"OllamaOptionTopK": "Ollama Option — Top K",
"OllamaOptionRepeatPenalty": "Ollama Option — Repeat Penalty",
"OllamaOptionDisableThinking": "Ollama Option — Disable Thinking",
"OllamaOptionRefusalRetry": "Ollama Option — Refusal Retry",
"OllamaOptionExtraBody": "Ollama Option — Extra Body",
"OllamaDebugHistory": "Ollama Debug History",
"OllamaHistoryLength": "Ollama History Length",
+211 -19
View File
@@ -1,9 +1,23 @@
"""Shared chat_structured() helper — pydantic-ai backed structured output.
Used by every ``LLMProvider`` implementation's ``chat_structured()`` method
(ADR-007) so Ollama and llama.cpp share one implementation of tool-calling/
structured-output logic instead of each hand-rolling it, since both speak
OpenAI-compatible ``/v1/chat/completions``.
Used by ``LlamaCppProvider.chat_structured()`` (ADR-007) over llama-server's
OpenAI-compatible ``/v1/chat/completions``. ``OllamaProvider`` no longer uses
this module (ADR-009): Ollama's OpenAI-compatible endpoint was found to
silently reload the model at its default context size on every call,
discarding any ``options.num_ctx`` override even when included in that same
request — a behavior specific to Ollama's compat layer, not llama-server's.
``OllamaProvider.chat_structured()`` now hand-rolls its own structured-output
call over Ollama's *native* ``/api/chat`` + ``"format"``, which doesn't have
that problem.
ADR-009: the Agent uses ``NativeOutput`` (``response_format``/JSON-schema
constrained decoding), not pydantic-ai's default tool-calling. Live-tested
against a "thinking"-capable model: tool-calling let the model spend its
whole token budget on chain-of-thought reasoning and never emit the tool
call; native output keeps reasoning in a separate response field and the
constrained ``content`` always comes back as schema-valid JSON. This benefit
still applies to llama.cpp, which is why this module (and its NativeOutput
choice) is kept for that provider.
Ports ADR-006's retry/validation contract exactly: bounded retries (0-5,
clamped), and a ``RuntimeError`` naming the model, attempt count, and a
@@ -13,12 +27,15 @@ drives its own retry loop so the error contract is comfydv's, not
pydantic-ai's internal one.
"""
import asyncio
from collections.abc import Callable
from typing import cast
from pydantic import BaseModel, ValidationError
from pydantic_ai import Agent
from pydantic_ai import Agent, NativeOutput
from pydantic_ai.exceptions import ModelRetry, UnexpectedModelBehavior
from pydantic_ai.messages import (
BinaryContent,
ModelRequest,
ModelResponse,
SystemPromptPart,
@@ -30,6 +47,16 @@ from pydantic_ai.providers.openai import OpenAIProvider
from pydantic_ai.settings import ModelSettings
from .provider import Message
from .retry import (
RETRY_BACKOFF_SECS,
EmbedFn,
format_recovered_status,
format_retry_status,
is_refusal,
next_seed,
next_timeout_secs,
record_attempt_info,
)
_STRUCTURED_OUTPUT_FAILURE_EXCEPTIONS = (
UnexpectedModelBehavior,
@@ -55,7 +82,29 @@ def _build_agent(
base_url=base_url, api_key="not-needed", http_client=http_client
)
chat_model = OpenAIChatModel(model, provider=provider)
return Agent(chat_model, output_type=schema, retries=0)
return Agent(chat_model, output_type=NativeOutput(schema), retries=0)
def _user_prompt_content(msg: Message):
"""Render a user turn as pydantic-ai user-prompt content.
Text-only ``msg`` → the plain ``content`` string, byte-identical to the
pre-009 path (FR-003). A turn carrying images → ``[content, *images]``
where each image is a ``BinaryContent`` PNG (ADR-008 / research.md
Decision 1); ``OpenAIChatModel`` renders these as OpenAI ``image_url``
parts, so both backends reach the same multimodal request through one
shared code path.
"""
if not msg.images:
return msg.content
import base64
content: list = [msg.content]
for image in msg.images:
content.append(
BinaryContent(data=base64.b64decode(image), media_type="image/png")
)
return content
def _history_to_messages(messages: list[Message]) -> list:
@@ -71,7 +120,9 @@ def _history_to_messages(messages: list[Message]) -> list:
elif msg.role == "system":
history.append(ModelRequest(parts=[SystemPromptPart(msg.content)]))
else:
history.append(ModelRequest(parts=[UserPromptPart(msg.content)]))
history.append(
ModelRequest(parts=[UserPromptPart(_user_prompt_content(msg))])
)
return history
@@ -85,6 +136,9 @@ async def chat_structured(
options: dict | None = None,
max_retries: int = 2,
timeout_secs: float = 300.0,
embed_fn: EmbedFn | None = None,
attempt_info: dict | None = None,
on_status: Callable[[str], None] | None = None,
) -> BaseModel:
"""Call ``model`` at ``base_url`` (an OpenAI-compatible ``/v1`` root) and
return a validated instance of ``schema``.
@@ -97,45 +151,183 @@ async def chat_structured(
lossily remapped onto pydantic-ai's own standardized ``ModelSettings``
fields.
ADR-010: ``options`` may also carry a ``"think"`` key (bool), popped out
here rather than forwarded inside the nested ``options`` object —
llama-server's OpenAI-compatible endpoint doesn't recognize a literal
``"think"`` key there. Translated to its own two documented
request-body toggles instead: ``chat_template_kwargs:
{"enable_thinking": ...}`` (Qwen3-style models) and, when disabling,
``reasoning_effort: "none"`` (the more model-agnostic OpenAI convention
llama-server also honors) — both via ``extra_body`` the same way
``options`` is. This provider only serves ``LlamaCppProvider`` — see
``OllamaProvider``'s own hand-rolled ``chat_structured`` for why Ollama
needed a different mechanism entirely. Sourced from llama.cpp's server
docs, not live-verified against a running llama-server (no instance
available at implementation time) — verify against your own deployment.
Retries up to ``max_retries`` times (clamped 0-5) on validation failure
before raising ``RuntimeError``. Never returns a value that failed
validation against ``schema``.
``options`` may also carry a ``"refusal_retry"`` config dict (same
comfydv-level convention as ``"think"``, emitted by
``OllamaOptionRefusalRetry``) — a detected refusal/deflection (see
``_llm/retry.py``) is treated exactly like a validation failure: retried
with a bumped seed rather than returned to the caller. ``embed_fn`` is
``LlamaCppProvider``'s own ``embed()``, bound to whatever embedding
model the config names — passed in rather than looked up here since
this module has no provider instance of its own to call.
"""
if not messages or messages[-1].role != "user":
raise ValueError(
"chat_structured requires the last message to have role='user'"
)
agent = _build_agent(
base_url=base_url,
model=model,
schema=schema,
headers=headers,
timeout_secs=timeout_secs,
)
history = _history_to_messages(messages)
prompt = messages[-1].content
prompt = _user_prompt_content(messages[-1])
think = None
if options and "think" in options:
options = dict(options)
think = options.pop("think")
options = options or None
refusal_cfg = None
if options and "refusal_retry" in options:
options = dict(options)
refusal_cfg = options.pop("refusal_retry")
options = options or None
extra_body: dict = {}
if options:
extra_body["options"] = options
if think is not None:
extra_body["chat_template_kwargs"] = {"enable_thinking": think}
if not think:
extra_body["reasoning_effort"] = "none"
model_settings: ModelSettings | None = (
{"extra_body": {"options": options}} if options else None
{"extra_body": extra_body} if extra_body else None
)
total_attempts = max(0, min(int(max_retries), 5)) + 1
last_error: Exception | None = None
last_invalid_text = ""
for _attempt in range(1, total_attempts + 1):
refusal_count = 0
attempt_seed = (options or {}).get("seed", 0) if isinstance(options, dict) else 0
attempt_timeout = timeout_secs
def _emit_retry_status(reason: str, attempt: int) -> None:
if on_status is None or attempt >= total_attempts:
return
upcoming_seed = next_seed(options, attempt + 1)
upcoming_timeout = next_timeout_secs(timeout_secs, attempt + 1)
on_status(
format_retry_status(
reason, attempt, total_attempts, upcoming_seed, upcoming_timeout
)
)
for attempt in range(1, total_attempts + 1):
attempt_timeout = next_timeout_secs(timeout_secs, attempt)
# Rebuilt each attempt so the escalated timeout actually takes
# effect — httpx.AsyncClient's timeout is fixed at construction,
# not mutable per-request.
agent = _build_agent(
base_url=base_url,
model=model,
schema=schema,
headers=headers,
timeout_secs=attempt_timeout,
)
attempt_settings = dict(model_settings) if model_settings else {}
if attempt > 1:
# Confirmed live: a freshly-loaded model's first structured-output
# attempt can fail outright (no valid tool call at all) and then
# behave normally on the very next call. Retrying with the exact
# same request reproduces the same failure if the model is
# genuinely stuck rather than just unlucky, so force a new seed
# (pydantic-ai maps ModelSettings["seed"] to the OpenAI API's
# top-level "seed" param, which works against both Ollama's and
# llama-server's OpenAI-compatible endpoints) and give it a beat
# via RETRY_BACKOFF_SECS in case it's still finishing loading.
seed = next_seed(options, attempt)
attempt_seed = seed
attempt_settings["seed"] = seed
if "extra_body" in attempt_settings:
# beacon-reviewer caught this: if a caller pinned options["seed"],
# it's also sitting in extra_body.options.seed (the Ollama-native
# passthrough). Left untouched, a backend that honors that nested
# field over the top-level OpenAI "seed" above would keep sending
# the same old seed on every retry — silently defeating this fix
# for exactly the pinned-seed case. Copy rather than mutate in
# place: extra_body/options here are the caller's own dicts,
# shared across every attempt (and possibly other calls).
# ModelSettings declares extra_body as `object` (it's an
# opaque passthrough field), so a plain dict() call on it
# doesn't type-check — cast first, this module always builds
# it as a dict (see model_settings above).
extra_body = dict(cast(dict, attempt_settings["extra_body"]))
nested_options = dict(extra_body.get("options") or {})
nested_options["seed"] = seed
extra_body["options"] = nested_options
attempt_settings["extra_body"] = extra_body
try:
result = await agent.run(
prompt, message_history=history, model_settings=model_settings
prompt,
message_history=history,
model_settings=cast(ModelSettings, attempt_settings)
if attempt_settings
else None,
)
# agent's output_type is the caller's `schema` (a runtime value,
# not a static type parameter), so the checker can't narrow
# result.output past Agent's default `str` — cast to the
# function's declared return type, which schema is a subtype of.
return cast(BaseModel, result.output)
output = cast(BaseModel, result.output)
if refusal_cfg and refusal_cfg.get("enabled"):
# Re-serialized, not the original wire text — pydantic-ai's
# NativeOutput doesn't expose that separately, and the
# regex/embedding check works the same either way (same
# textual content, just re-encoded).
content = output.model_dump_json()
refused = await is_refusal(
content,
embed_fn=embed_fn,
embed_cache_key=refusal_cfg.get("embedding_model", ""),
threshold=refusal_cfg.get("threshold", 0.82),
custom_phrases=tuple(refusal_cfg.get("custom_phrases") or ()),
)
if refused:
refusal_count += 1
last_error = RuntimeError("refusal/deflection detected")
last_invalid_text = content
_emit_retry_status("Refusal/deflection detected", attempt)
if attempt < total_attempts:
await asyncio.sleep(RETRY_BACKOFF_SECS)
continue
record_attempt_info(
attempt_info,
seed=attempt_seed,
attempts=attempt,
timeout_secs=attempt_timeout,
refusals=refusal_count,
)
if on_status is not None and attempt > 1:
on_status(
format_recovered_status(attempt, total_attempts, attempt_seed)
)
return output
except _STRUCTURED_OUTPUT_FAILURE_EXCEPTIONS as exc:
last_error = exc
last_invalid_text = str(exc)
_emit_retry_status("Structured output failed", attempt)
if attempt < total_attempts:
await asyncio.sleep(RETRY_BACKOFF_SECS)
record_attempt_info(
attempt_info,
seed=attempt_seed,
attempts=total_attempts,
timeout_secs=attempt_timeout,
refusals=refusal_count,
)
raise RuntimeError(
f"chat_structured: response failed validation against schema after "
f"{total_attempts} attempt(s) (model={model!r}). Last error: "
+240 -34
View File
@@ -13,12 +13,30 @@ Deployment prerequisite: llama-server must be launched with --models-dir or
exist otherwise (spec.md FR-006).
"""
import asyncio
import logging
from collections.abc import Callable
from pydantic import BaseModel
from .ollama_provider import _TTLLRUCache, _cache_key, _get_json, _post_json
from .ollama_provider import (
_TTLLRUCache,
_cache_key,
_get_json,
_pop_refusal_retry,
_pop_think,
_post_json,
)
from .provider import Message, ModelInfo, ModelStatus
from .retry import (
RETRY_BACKOFF_SECS,
format_recovered_status,
format_retry_status,
is_refusal,
next_seed,
next_timeout_secs,
record_attempt_info,
)
logger = logging.getLogger(__name__)
@@ -51,6 +69,30 @@ async def _fetch_models(host: str, headers: dict | None = None) -> list[str]:
return [m.name for m in models]
def _to_openai_message(message: Message) -> dict:
"""Render a ``Message`` in llama.cpp's OpenAI-compatible shape.
A text-only turn stays ``{"role", "content": <str>}`` — byte-identical to
the pre-009 payload (FR-003). A turn carrying images becomes OpenAI
multimodal ``content`` parts: the text followed by one ``image_url`` part
per base64 image, as a ``data:`` URI (ADR-008). ``llama-server`` only
honours these parts when launched with a multimodal projector
(``--mmproj``); without it the server errors, surfaced to the caller
rather than crashed on (FR-006).
"""
if not message.images:
return {"role": message.role, "content": message.content}
parts: list[dict] = [{"type": "text", "text": message.content}]
for image in message.images:
parts.append(
{
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{image}"},
}
)
return {"role": message.role, "content": parts}
class LlamaCppProvider:
"""LLMProvider implementation backed by llama-server's router mode.
@@ -156,43 +198,154 @@ class LlamaCppProvider:
messages: list[Message],
options: dict | None = None,
timeout_secs: float = 300.0,
max_retries: int = 2,
attempt_info: dict | None = None,
on_status: Callable[[str], None] | None = None,
) -> str:
payload_messages = [m.model_dump() for m in messages]
payload: dict = {"model": model, "messages": payload_messages, "stream": False}
if options:
# Passed through verbatim, same nesting OllamaProvider.chat() uses
# (payload["options"] = options) — the OllamaOption* nodes emit
# Ollama-native parameter names (num_predict, repeat_penalty,
# ...), which llama-server's OpenAI-compatible endpoint won't
# recognize either way; translating them is out of scope for
# this epic (plan.md Non-goals — no changes to the generic
# nodes). This keeps the two providers' handling consistent
# rather than silently special-casing one of them.
payload["options"] = options
payload_messages = [_to_openai_message(m) for m in messages]
options, think = _pop_think(options)
options, refusal_cfg = _pop_refusal_retry(options)
embed_fn = None
custom_phrases: tuple[str, ...] = ()
if refusal_cfg and refusal_cfg.get("enabled"):
custom_phrases = tuple(refusal_cfg.get("custom_phrases") or ())
if refusal_cfg.get("embedding_model"):
embedding_model = refusal_cfg["embedding_model"]
embed_fn = lambda t: self.embed(embedding_model, t) # noqa: E731
total_attempts = max(0, min(int(max_retries), 5)) + 1
response_text = ""
refusal_count = 0
attempt_seed = 0
attempt_timeout = timeout_secs
cache_key = _cache_key(
"llamacpp_chat",
self.host,
self.headers or {},
model,
payload_messages,
options or {},
)
cached, hit = _CHAT_RESPONSE_CACHE.get(cache_key)
if hit:
return cached
for attempt in range(1, total_attempts + 1):
attempt_timeout = next_timeout_secs(timeout_secs, attempt)
payload: dict = {
"model": model,
"messages": payload_messages,
"stream": False,
}
if options:
# Passed through verbatim, same nesting OllamaProvider.chat()
# uses (payload["options"] = options) — the OllamaOption*
# nodes emit Ollama-native parameter names (num_predict,
# repeat_penalty, ...), which llama-server's OpenAI-compatible
# endpoint won't recognize either way; translating them is
# out of scope for this epic (plan.md Non-goals — no changes
# to the generic nodes). This keeps the two providers'
# handling consistent rather than silently special-casing
# one of them.
payload["options"] = options
if think is not None:
# ADR-010: llama-server's two documented reasoning toggles —
# sourced from server docs, not live-verified (no instance
# available at implementation time).
payload["chat_template_kwargs"] = {"enable_thinking": think}
if not think:
payload["reasoning_effort"] = "none"
if attempt > 1:
# Unlike the options-passthrough above, this IS the OpenAI
# spec's actual top-level "seed" field, so it takes effect
# against llama-server's /v1/chat/completions.
payload["seed"] = next_seed(options, attempt)
attempt_seed = payload["seed"]
else:
# attempt 1 never sets the top-level "seed" field above (only
# retries do) — fall back to whatever the caller pinned in
# options, so attempt_info/seed_used reports the real seed in
# play even on a first-attempt success, not a stale 0.
attempt_seed = (options or {}).get("seed", 0)
result = await _post_json(
f"{self.host}/v1/chat/completions",
payload,
timeout=timeout_secs,
headers=self.headers,
cache_key = _cache_key(
"llamacpp_chat",
self.host,
self.headers or {},
model,
payload_messages,
options or {},
think,
payload.get("seed"),
)
cached, hit = _CHAT_RESPONSE_CACHE.get(cache_key)
if hit:
record_attempt_info(
attempt_info,
seed=attempt_seed,
attempts=attempt,
timeout_secs=attempt_timeout,
refusals=refusal_count,
)
return cached
result = await _post_json(
f"{self.host}/v1/chat/completions",
payload,
timeout=attempt_timeout,
headers=self.headers,
)
choices = result.get("choices") or []
response_text = (
choices[0].get("message", {}).get("content", "") or ""
if choices
else ""
)
retry_reason: str | None = None
if response_text.strip():
refused = False
if refusal_cfg and refusal_cfg.get("enabled"):
refused = await is_refusal(
response_text,
embed_fn=embed_fn,
embed_cache_key=refusal_cfg.get("embedding_model", ""),
threshold=refusal_cfg.get("threshold", 0.82),
custom_phrases=custom_phrases,
)
if not refused:
_CHAT_RESPONSE_CACHE.set(cache_key, response_text)
record_attempt_info(
attempt_info,
seed=attempt_seed,
attempts=attempt,
timeout_secs=attempt_timeout,
refusals=refusal_count,
)
if on_status is not None and attempt > 1:
on_status(
format_recovered_status(
attempt, total_attempts, attempt_seed
)
)
return response_text
refusal_count += 1
retry_reason = "Refusal/deflection detected"
else:
retry_reason = "Blank response"
if attempt < total_attempts:
if on_status is not None:
upcoming_seed = next_seed(options, attempt + 1)
upcoming_timeout = next_timeout_secs(timeout_secs, attempt + 1)
on_status(
format_retry_status(
retry_reason,
attempt,
total_attempts,
upcoming_seed,
upcoming_timeout,
)
)
await asyncio.sleep(RETRY_BACKOFF_SECS)
record_attempt_info(
attempt_info,
seed=attempt_seed,
attempts=total_attempts,
timeout_secs=attempt_timeout,
refusals=refusal_count,
)
choices = result.get("choices") or []
response_text = (
choices[0].get("message", {}).get("content", "") or "" if choices else ""
)
_CHAT_RESPONSE_CACHE.set(cache_key, response_text)
# Every attempt came back blank — never raises here (chat() has
# never validated its output, unlike chat_structured()); return the
# last (blank) attempt uncached so the next queue run tries fresh.
return response_text
async def chat_structured(
@@ -203,6 +356,8 @@ class LlamaCppProvider:
options: dict | None = None,
timeout_secs: float = 300.0,
max_retries: int = 2,
attempt_info: dict | None = None,
on_status: Callable[[str], None] | None = None,
) -> BaseModel:
from .chat import chat_structured as _chat_structured_impl
@@ -218,8 +373,25 @@ class LlamaCppProvider:
)
cached, hit = _CHAT_RESPONSE_CACHE.get(cache_key)
if hit:
record_attempt_info(
attempt_info,
seed=(options or {}).get("seed", 0),
attempts=1,
timeout_secs=timeout_secs,
refusals=0,
)
return schema.model_validate(cached)
embed_fn = None
refusal_cfg = (options or {}).get("refusal_retry")
if (
refusal_cfg
and refusal_cfg.get("enabled")
and refusal_cfg.get("embedding_model")
):
embedding_model = refusal_cfg["embedding_model"]
embed_fn = lambda t: self.embed(embedding_model, t) # noqa: E731
result = await _chat_structured_impl(
base_url=f"{self.host}/v1",
model=model,
@@ -229,6 +401,40 @@ class LlamaCppProvider:
options=options,
max_retries=max_retries,
timeout_secs=timeout_secs,
embed_fn=embed_fn,
attempt_info=attempt_info,
on_status=on_status,
)
_CHAT_RESPONSE_CACHE.set(cache_key, result.model_dump())
return result
async def embed(self, model: str, text: str) -> list[float] | None:
"""POST {host}/v1/embeddings — llama-server's OpenAI-compatible
embeddings endpoint.
Requires an embedding-capable model to be loaded in the router
(typically a *different* model from whatever's answering chat
requests) — not live-verified against a running llama-server (no
instance available at implementation time), mirroring this
provider's other sourced-from-docs-not-verified caveats. Returns
``None`` rather than raising on any failure, same contract as
``OllamaProvider.embed()``.
"""
if not model.strip() or not text.strip():
return None
try:
result = await _post_json(
f"{self.host}/v1/embeddings",
{"model": model, "input": text},
timeout=30.0,
headers=self.headers,
)
except Exception:
return None
data = result.get("data")
if not isinstance(data, list) or not data:
return None
vec = data[0].get("embedding")
if not isinstance(vec, list) or not vec:
return None
return vec
+449 -44
View File
@@ -15,10 +15,20 @@ import json
import logging
import threading
import time
from collections.abc import Callable
from pydantic import BaseModel
from pydantic import BaseModel, ValidationError
from .provider import Message, ModelInfo, ModelStatus
from .retry import (
RETRY_BACKOFF_SECS,
format_recovered_status,
format_retry_status,
is_refusal,
next_seed,
next_timeout_secs,
record_attempt_info,
)
logger = logging.getLogger(__name__)
@@ -78,8 +88,48 @@ def _cache_key(*parts) -> str:
return json.dumps(parts, sort_keys=True, default=str)
def _pop_think(options: dict | None) -> tuple[dict | None, bool | None]:
"""Split a ``"think"`` toggle out of a generic ``options`` dict.
ADR-010: ``OllamaOptionDisableThinking`` merges a ``"think": bool`` key
into the same composable ``OLLAMA_OPTIONS`` chain every other
``OllamaOption*`` node feeds into ``ChatCompletion``'s ``options``
input — but unlike those (Ollama-native sampling params, passed through
verbatim), ``"think"`` needs real per-provider translation: neither
Ollama's native ``/api/chat`` nor llama-server's OpenAI-compatible
endpoint recognizes a literal ``"think"`` key nested inside their own
``options``/sampling-params object, so every provider pops it out here
(or in ``LlamaCppProvider``'s own copy) before building its request.
Returns ``options`` with ``"think"`` removed (unchanged if absent, so a
falsy/empty result stays falsy) and the popped value, or ``None`` if the
caller didn't set it — never touches the caller's own dict in place.
"""
if not options or "think" not in options:
return options, None
remaining = dict(options)
think = remaining.pop("think")
return (remaining or None), think
def _pop_refusal_retry(options: dict | None) -> tuple[dict | None, dict | None]:
"""Split a ``"refusal_retry"`` config dict out of a generic ``options``
dict — same convention as ``_pop_think``: ``OllamaOptionRefusalRetry``
merges ``{"refusal_retry": {"enabled", "embedding_model", "threshold"}}``
into the same composable ``OLLAMA_OPTIONS`` chain every other
``OllamaOption*`` node feeds into ``ChatCompletion``'s ``options``
input, and neither Ollama's nor llama.cpp's own API recognizes this key,
so every provider pops it out here before building its request.
"""
if not options or "refusal_retry" not in options:
return options, None
remaining = dict(options)
cfg = remaining.pop("refusal_retry")
return (remaining or None), cfg
_MODEL_LIST_CACHE = _TTLLRUCache(maxsize=32, ttl_seconds=20.0)
_CHAT_RESPONSE_CACHE = _TTLLRUCache(maxsize=64, ttl_seconds=None)
_CAPABILITY_CACHE = _TTLLRUCache(maxsize=32, ttl_seconds=300.0)
# ---------------------------------------------------------------------------
@@ -88,17 +138,29 @@ _CHAT_RESPONSE_CACHE = _TTLLRUCache(maxsize=64, ttl_seconds=None)
def _run_async(coro):
"""Run an async coroutine synchronously, safe inside a running event loop."""
try:
asyncio.get_running_loop()
# Called from within a running loop (e.g. ComfyUI's async executor).
# Spin up a worker thread with its own loop to avoid "loop already running".
import concurrent.futures
"""Run an async coroutine synchronously in an isolated worker thread.
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(asyncio.run, coro).result()
except RuntimeError:
return asyncio.run(coro)
Always spins up a fresh thread rather than conditionally checking
asyncio.get_running_loop() first: live-verified against a real running
ComfyUI instance (its execution engine runs its own event loop, in
Python 3.13, on the same process) that the conditional version — try
get_running_loop(), spin up a thread only if it succeeds, otherwise
call asyncio.run(coro) directly — is unreliable there. Under real
ComfyUI, get_running_loop() sometimes raised inside that try block
(unlike under pytest or a standalone script, where it never does),
which routed straight into `asyncio.run(coro)` on the *current* thread
— the one thread guaranteed to already have ComfyUI's own loop running
— reproducing exactly the "asyncio.run() cannot be called from a
running event loop" crash this function exists to prevent. Always
using a dedicated thread sidesteps the detection entirely: a freshly
spawned thread never has an ambient loop, so asyncio.run() is safe
there unconditionally, regardless of what the calling thread's loop
state actually is.
"""
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(asyncio.run, coro).result()
async def _post_json(
@@ -181,6 +243,49 @@ async def _fetch_models(host: str, headers: dict | None = None) -> list[str]:
return models
async def _require_vision_capability(
host: str, model: str, headers: dict | None
) -> None:
"""Raise a clear error if ``model`` lacks Ollama's ``vision`` capability.
Only called when a request carries at least one image (spec 009 FR-006):
Ollama's /api/chat silently accepts an unsupported ``images`` field and
answers with a blank/malformed HTTP 200 instead of an error — which
would otherwise be indistinguishable from an ordinary blank generation
and get swallowed by chat()'s existing blank-response retry. /api/show's
``capabilities`` list is the only place Ollama states support explicitly,
so a request carrying an image is checked against it up front.
Fails open on any lookup problem (older Ollama without ``capabilities``,
unreachable host, unexpected shape) — a lookup failure must not block a
request that would otherwise have worked; the real request surfaces its
own clear error if the host is genuinely unreachable.
"""
cache_key = _cache_key("capabilities", host, headers or {}, model)
cached, hit = _CAPABILITY_CACHE.get(cache_key)
if hit:
capabilities = cached
else:
try:
data = await _post_json(
f"{host}/api/show", {"model": model}, timeout=10.0, headers=headers
)
except Exception:
return
capabilities = data.get("capabilities")
if capabilities is None:
return
_CAPABILITY_CACHE.set(cache_key, capabilities)
if "vision" not in capabilities:
raise ValueError(
f"Model '{model}' does not support image input — Ollama reports "
f"capabilities {capabilities!r} for it, no 'vision'. Wire a "
"vision-capable model, or disconnect the image input for "
"text-only chat."
)
class OllamaProvider:
"""LLMProvider implementation backed by Ollama's REST API.
@@ -262,29 +367,162 @@ class OllamaProvider:
messages: list[Message],
options: dict | None = None,
timeout_secs: float = 300.0,
max_retries: int = 2,
attempt_info: dict | None = None,
on_status: Callable[[str], None] | None = None,
) -> str:
payload_messages = [m.model_dump() for m in messages]
payload: dict = {"model": model, "messages": payload_messages, "stream": False}
if options:
payload["options"] = options
if any(m.images for m in messages):
await _require_vision_capability(self.host, model, self.headers)
cache_key = _cache_key(
"chat",
self.host,
self.headers or {},
model,
payload_messages,
options or {},
)
cached, hit = _CHAT_RESPONSE_CACHE.get(cache_key)
if hit:
return cached
# exclude_none drops the images key for text-only turns so an
# image-less request is byte-identical to the pre-009 payload
# (FR-003); a turn with images keeps Ollama's native flat images
# array (ADR-008 — no transform needed for /api/chat).
payload_messages = [m.model_dump(exclude_none=True) for m in messages]
options, think = _pop_think(options)
options, refusal_cfg = _pop_refusal_retry(options)
embed_fn = None
custom_phrases: tuple[str, ...] = ()
if refusal_cfg and refusal_cfg.get("enabled"):
custom_phrases = tuple(refusal_cfg.get("custom_phrases") or ())
if refusal_cfg.get("embedding_model"):
embedding_model = refusal_cfg["embedding_model"]
embed_fn = lambda t: self.embed(embedding_model, t) # noqa: E731
total_attempts = max(0, min(int(max_retries), 5)) + 1
response_text = ""
incomplete = False
refusal_count = 0
attempt_seed = 0
attempt_timeout = timeout_secs
result = await _post_json(
f"{self.host}/api/chat", payload, timeout=timeout_secs, headers=self.headers
for attempt in range(1, total_attempts + 1):
attempt_options = dict(options) if options else {}
if attempt > 1:
attempt_options["seed"] = next_seed(options, attempt)
attempt_seed = attempt_options.get("seed", 0)
attempt_timeout = next_timeout_secs(timeout_secs, attempt)
payload: dict = {
"model": model,
"messages": payload_messages,
"stream": False,
}
if attempt_options:
payload["options"] = attempt_options
if think is not None:
# ADR-010: confirmed live this must be a top-level field —
# Ollama silently ignores "think" nested inside "options".
payload["think"] = think
cache_key = _cache_key(
"chat",
self.host,
self.headers or {},
model,
payload_messages,
attempt_options,
think,
)
cached, hit = _CHAT_RESPONSE_CACHE.get(cache_key)
if hit:
record_attempt_info(
attempt_info,
seed=attempt_seed,
attempts=attempt,
timeout_secs=attempt_timeout,
refusals=refusal_count,
)
return cached
result = await _post_json(
f"{self.host}/api/chat",
payload,
timeout=attempt_timeout,
headers=self.headers,
)
response_text = result.get("message", {}).get("content", "")
retry_reason: str | None = None
if response_text.strip():
refused = False
if refusal_cfg and refusal_cfg.get("enabled"):
refused = await is_refusal(
response_text,
embed_fn=embed_fn,
embed_cache_key=refusal_cfg.get("embedding_model", ""),
threshold=refusal_cfg.get("threshold", 0.82),
custom_phrases=custom_phrases,
)
if not refused:
_CHAT_RESPONSE_CACHE.set(cache_key, response_text)
record_attempt_info(
attempt_info,
seed=attempt_seed,
attempts=attempt,
timeout_secs=attempt_timeout,
refusals=refusal_count,
)
if on_status is not None and attempt > 1:
on_status(
format_recovered_status(
attempt, total_attempts, attempt_seed
)
)
return response_text
# A detected refusal is handled exactly like a blank
# response below: fall through to the backoff/retry with a
# bumped seed (next_seed), rather than returning the refusal
# text to the caller.
refusal_count += 1
retry_reason = "Refusal/deflection detected"
# done: false alongside blank content is a distinct signal from
# an ordinary blank generation — it's Ollama answering before
# the model has actually finished loading/swapping in, observed
# live under model-swap load (issue #27), not the model having
# genuinely generated nothing. Tracked separately so it can be
# raised on below instead of silently returned like a real
# blank generation would be.
incomplete = result.get("done") is False
if retry_reason is None and not response_text.strip():
retry_reason = (
"Model still loading/swapping" if incomplete else "Blank response"
)
if attempt < total_attempts:
if on_status is not None and retry_reason is not None:
upcoming_seed = next_seed(options, attempt + 1)
upcoming_timeout = next_timeout_secs(timeout_secs, attempt + 1)
on_status(
format_retry_status(
retry_reason,
attempt,
total_attempts,
upcoming_seed,
upcoming_timeout,
)
)
await asyncio.sleep(RETRY_BACKOFF_SECS)
record_attempt_info(
attempt_info,
seed=attempt_seed,
attempts=total_attempts,
timeout_secs=attempt_timeout,
refusals=refusal_count,
)
response_text = result.get("message", {}).get("content", "")
_CHAT_RESPONSE_CACHE.set(cache_key, response_text)
if incomplete:
raise RuntimeError(
f"Ollama returned an incomplete response after "
f"{total_attempts} attempt(s) for model '{model}' — it may "
"still be loading or swapping in memory. Try again in a "
"few seconds."
)
# Every attempt came back blank (and complete) — never raises here
# (chat() has never validated its output, unlike chat_structured());
# return the last (blank) attempt uncached so the next queue run
# tries fresh.
return response_text
async def chat_structured(
@@ -295,10 +533,41 @@ class OllamaProvider:
options: dict | None = None,
timeout_secs: float = 300.0,
max_retries: int = 2,
attempt_info: dict | None = None,
on_status: Callable[[str], None] | None = None,
) -> BaseModel:
from .chat import chat_structured as _chat_structured_impl
"""Native ``/api/chat`` + ``"format"`` (grammar-constrained JSON
decoding), not the shared pydantic-ai ``chat.py`` helper.
payload_messages = [m.model_dump() for m in messages]
ADR-009 originally routed this through the OpenAI-compatible
``/v1/chat/completions`` endpoint via pydantic-ai's ``NativeOutput``.
Confirmed live that endpoint silently *reloads the model at its
default context size on every call*, discarding any prior
``options.num_ctx`` — even when the same ``options`` are included in
that very request. Priming with a separate native call first
(the original fix) didn't help: the very next OpenAI-compat call
undid it immediately. The native ``/api/chat`` endpoint doesn't
have this problem — confirmed live it preserves an already-primed
context, and it supports structured output directly via
``"format"``, so ``options`` and structured output now apply
atomically in one request. ``LlamaCppProvider`` is unaffected — it
keeps using the shared pydantic-ai path, since llama-server's
context is fixed at process launch, not a per-request concern.
"""
if any(m.images for m in messages):
await _require_vision_capability(self.host, model, self.headers)
payload_messages = [m.model_dump(exclude_none=True) for m in messages]
json_schema = schema.model_json_schema()
options, think = _pop_think(options)
options, refusal_cfg = _pop_refusal_retry(options)
embed_fn = None
custom_phrases: tuple[str, ...] = ()
if refusal_cfg and refusal_cfg.get("enabled"):
custom_phrases = tuple(refusal_cfg.get("custom_phrases") or ())
if refusal_cfg.get("embedding_model"):
embedding_model = refusal_cfg["embedding_model"]
embed_fn = lambda t: self.embed(embedding_model, t) # noqa: E731
cache_key = _cache_key(
"chat_structured",
self.host,
@@ -306,21 +575,157 @@ class OllamaProvider:
model,
payload_messages,
options or {},
schema.model_json_schema(),
json_schema,
think,
)
cached, hit = _CHAT_RESPONSE_CACHE.get(cache_key)
if hit:
record_attempt_info(
attempt_info,
seed=(options or {}).get("seed", 0),
attempts=1,
timeout_secs=timeout_secs,
refusals=0,
)
return schema.model_validate(cached)
result = await _chat_structured_impl(
base_url=f"{self.host}/v1",
model=model,
messages=messages,
schema=schema,
headers=self.headers,
options=options,
max_retries=max_retries,
timeout_secs=timeout_secs,
total_attempts = max(0, min(int(max_retries), 5)) + 1
last_error: Exception | None = None
last_invalid_text = ""
refusal_count = 0
attempt_seed = 0
attempt_timeout = timeout_secs
def _emit_retry_status(reason: str, attempt: int) -> None:
if on_status is None or attempt >= total_attempts:
return
upcoming_seed = next_seed(options, attempt + 1)
upcoming_timeout = next_timeout_secs(timeout_secs, attempt + 1)
on_status(
format_retry_status(
reason, attempt, total_attempts, upcoming_seed, upcoming_timeout
)
)
for attempt in range(1, total_attempts + 1):
attempt_options = dict(options) if options else {}
if attempt > 1:
attempt_options["seed"] = next_seed(options, attempt)
attempt_seed = attempt_options.get("seed", 0)
attempt_timeout = next_timeout_secs(timeout_secs, attempt)
payload: dict = {
"model": model,
"messages": payload_messages,
"format": json_schema,
"stream": False,
}
if attempt_options:
payload["options"] = attempt_options
if think is not None:
# ADR-010: confirmed live this must be a top-level field —
# Ollama silently ignores "think" nested inside "options".
payload["think"] = think
try:
result = await _post_json(
f"{self.host}/api/chat",
payload,
timeout=attempt_timeout,
headers=self.headers,
)
except RuntimeError as exc:
last_error = exc
last_invalid_text = str(exc)
_emit_retry_status("Request failed", attempt)
if attempt < total_attempts:
await asyncio.sleep(RETRY_BACKOFF_SECS)
continue
content = result.get("message", {}).get("content", "")
try:
parsed = schema.model_validate_json(content)
except ValidationError as exc:
last_error = exc
last_invalid_text = content
_emit_retry_status("Schema validation failed", attempt)
if attempt < total_attempts:
await asyncio.sleep(RETRY_BACKOFF_SECS)
continue
if refusal_cfg and refusal_cfg.get("enabled"):
# Checked against the raw JSON text, not a specific parsed
# field: ChatCompletion's schema is caller-defined and this
# provider has no idea which field would carry refusal
# language — the regex/embedding check still matches text
# sitting inside a JSON string value either way.
refused = await is_refusal(
content,
embed_fn=embed_fn,
embed_cache_key=refusal_cfg.get("embedding_model", ""),
threshold=refusal_cfg.get("threshold", 0.82),
custom_phrases=custom_phrases,
)
if refused:
refusal_count += 1
last_error = RuntimeError("refusal/deflection detected")
last_invalid_text = content
_emit_retry_status("Refusal/deflection detected", attempt)
if attempt < total_attempts:
await asyncio.sleep(RETRY_BACKOFF_SECS)
continue
_CHAT_RESPONSE_CACHE.set(cache_key, parsed.model_dump())
record_attempt_info(
attempt_info,
seed=attempt_seed,
attempts=attempt,
timeout_secs=attempt_timeout,
refusals=refusal_count,
)
if on_status is not None and attempt > 1:
on_status(
format_recovered_status(attempt, total_attempts, attempt_seed)
)
return parsed
record_attempt_info(
attempt_info,
seed=attempt_seed,
attempts=total_attempts,
timeout_secs=attempt_timeout,
refusals=refusal_count,
)
_CHAT_RESPONSE_CACHE.set(cache_key, result.model_dump())
return result
raise RuntimeError(
f"chat_structured: response failed validation against schema after "
f"{total_attempts} attempt(s) (model={model!r}). Last error: "
f"{last_error}. Last response (truncated): {last_invalid_text[:300]!r}"
)
async def embed(self, model: str, text: str) -> list[float] | None:
"""POST {host}/api/embed — Ollama's native embeddings endpoint.
Returns ``None`` rather than raising on any failure (wrong/missing
embedding model, unreachable server, malformed response) — this is
a best-effort capability per the ``LLMProvider`` protocol, and its
one current caller (refusal-retry detection) already treats
``None`` as "skip the embedding check", not an error.
"""
if not model.strip() or not text.strip():
return None
try:
result = await _post_json(
f"{self.host}/api/embed",
{"model": model, "input": text},
timeout=30.0,
headers=self.headers,
)
except Exception:
return None
embeddings = result.get("embeddings")
if not isinstance(embeddings, list) or not embeddings:
return None
vec = embeddings[0]
if not isinstance(vec, list) or not vec:
return None
return vec
+69 -2
View File
@@ -8,6 +8,7 @@ project-management/ADRs/ADR-007-llm-provider-adapter-pattern.md and
specs/007-llm-provider-abstraction/contracts/llm_provider_protocol.md.
"""
from collections.abc import Callable
from enum import Enum
from typing import Literal, Protocol
@@ -39,10 +40,21 @@ class ModelInfo(BaseModel):
class Message(BaseModel):
"""One turn in a chat request."""
"""One turn in a chat request.
``images`` carries optional base64-encoded image payloads (no ``data:``
prefix) associated with this turn, for vision-capable models. ``None``
(the default) means a text-only turn that serializes byte-for-byte as
before — providers dump with ``exclude_none=True`` so no ``images`` key
reaches the wire for image-less turns. Each provider translates this
neutral carrier into its own native shape (ADR-008): Ollama's flat
per-message ``images`` array, llama.cpp's OpenAI ``image_url`` content
parts, and pydantic-ai ``BinaryContent`` on the structured path.
"""
role: Literal["system", "user", "assistant"]
content: str
images: list[str] | None = None
class LLMProvider(Protocol):
@@ -71,8 +83,42 @@ class LLMProvider(Protocol):
messages: list[Message],
options: dict | None = None,
timeout_secs: float = 300.0,
max_retries: int = 2,
attempt_info: dict | None = None,
on_status: Callable[[str], None] | None = None,
) -> str:
"""Free-text chat response."""
"""Free-text chat response.
Retries up to ``max_retries`` times (clamped 0-5) with a new seed if
the response comes back blank — confirmed live on a freshly-loaded
model, whose first response is sometimes empty before it settles
into normal behavior. Still returns the (possibly blank) last
attempt's text rather than raising if every retry comes back blank —
this method has never validated its output, unlike
``chat_structured()``. Each retry's request timeout also escalates
(``_llm/retry.py``'s ``next_timeout_secs``) rather than reusing the
same budget that just ran out.
ADR-010: ``options`` may carry a ``"think"`` key (bool) to disable a
"thinking"-capable model's chain-of-thought reasoning — every
implementation pops it out of ``options`` and translates it to its
own wire shape (Ollama: a top-level ``think`` field; llama.cpp:
``chat_template_kwargs``/``reasoning_effort`` in the request body),
since neither backend recognizes a literal ``"think"`` key nested
inside a generic options object.
``attempt_info``, if given, is populated in place with the retry
loop's final outcome (seed/timeout used, attempt count, refusal
count) via ``_llm/retry.py``'s ``record_attempt_info`` — an optional
out-param, not a return-type change, so existing callers that don't
pass it see no behavior change.
``on_status``, if given, is called synchronously at each retry
boundary with a one-line human-readable status (see
``_llm/retry.py``'s ``format_retry_status``/``format_recovered_status``)
— a live counterpart to ``attempt_info``, which only reports the
final outcome after the call returns.
"""
...
async def chat_structured(
@@ -83,6 +129,8 @@ class LLMProvider(Protocol):
options: dict | None = None,
timeout_secs: float = 300.0,
max_retries: int = 2,
attempt_info: dict | None = None,
on_status: Callable[[str], None] | None = None,
) -> BaseModel:
"""Schema-validated chat response.
@@ -90,5 +138,24 @@ class LLMProvider(Protocol):
truncated snippet of the last invalid response) if every retry is
exhausted — never returns a value with a missing/blank required
field.
ADR-010: see ``chat()`` — same ``options["think"]`` convention,
same per-provider translation, same escalating per-attempt timeout,
and the same ``attempt_info``/``on_status`` conventions.
"""
...
async def embed(self, model: str, text: str) -> list[float] | None:
"""Embedding vector for ``text``, or ``None`` if unavailable.
Best-effort, not a core capability every deployment has configured:
``model`` must itself be embedding-capable, which is typically a
*different* model from whatever's answering chat requests (e.g.
``nomic-embed-text``, not the model passed to ``chat()``). Returns
``None`` rather than raising when embeddings aren't usable right now
(wrong/missing model, unreachable server) — the one current caller,
refusal-retry detection (see ``_llm/retry.py``), degrades gracefully
to lexical-only detection when this returns ``None``, so a provider
with no embedding model configured is never a hard failure.
"""
...
+333
View File
@@ -0,0 +1,333 @@
"""Shared retry-on-empty-output helpers for chat()/chat_structured().
Both providers' chat() calls (ADR-007) and the shared chat_structured()
helper (_llm/chat.py) hit the same class of failure, confirmed live against
a freshly-started Ollama instance on a fresh runpod: the model's first
response after loading is sometimes blank or fails structured-output
validation outright, then behaves normally on the very next call. Centralized
here so both providers and both chat modes retry the same way rather than
each re-deriving the policy.
Only blank/whitespace-only responses trigger a retry for plain chat() —
not merely "short" ones — because a fixed length threshold would misfire on
legitimately short, valid answers (single-word replies, labels, "yes"/"no").
Refusal/deflection detection (below) is a separate, opt-in trigger for the
same retry-with-a-new-seed mechanism: some models (observed with an
abliterated Qwen variant) answer with a soft refusal on a topic they judge
"sensitive" instead of erroring or returning blank, so neither of the above
checks catches it. This is deliberately a model-behavior concern, not a
backend one — every ``LLMProvider`` implementation (Ollama, llama.cpp, and
whatever comes next) wires the same detector into its own retry loop via its
own ``embed()``, rather than each backend inventing its own heuristic.
"""
import math
import re
from collections.abc import Awaitable, Callable
RETRY_BACKOFF_SECS = 1.5
"""Flat delay between retries — gives a still-loading model time to finish
before the next attempt, rather than hammering it with identical requests
back-to-back."""
def next_seed(options: dict | None, attempt: int) -> int:
"""Deterministic seed for retry ``attempt`` (1-indexed).
Attempt 1 is the caller's original request and is never touched by this
function — callers only call it for attempt >= 2. Starts from
``options["seed"]`` if the caller pinned one, else 0, and increments by
``attempt - 1`` so each retry is a new, reproducible value instead of
repeating the exact same request that just failed.
"""
base = 0
if options and isinstance(options.get("seed"), int):
base = options["seed"]
return base + (attempt - 1)
def next_timeout_secs(base_timeout: float, attempt: int) -> float:
"""Escalating per-attempt timeout for retries (1-indexed ``attempt``).
Attempt 1 gets the caller's own ``timeout_secs`` unchanged; each retry
multiplies it by the attempt number. A request that timed out may
genuinely need more time — a slow-to-load or heavily-loaded model, a
large prompt — not just an identical retry under the same budget it
just failed to meet.
"""
return base_timeout * attempt
def record_attempt_info(
attempt_info: dict | None,
*,
seed: int,
attempts: int,
timeout_secs: float,
refusals: int,
) -> None:
"""Populate an optional caller-supplied dict with the retry loop's
final outcome — the seed/timeout actually used, how many attempts it
took, and how many were refusal-triggered.
A plain out-param rather than a return-type change, so it's fully
backward compatible: a caller that doesn't pass ``attempt_info`` sees
no change in behavior at all. ``ChatCompletion`` uses this to expose
the seed actually used as a node output and to build a UI status line
when a retry/refusal happened.
"""
if attempt_info is None:
return
attempt_info.update(
{
"seed": seed,
"attempts": attempts,
"timeout_secs": timeout_secs,
"refusals": refusals,
}
)
OnStatus = Callable[[str], None]
"""A caller-supplied, synchronous, best-effort progress callback — see
``format_retry_status``/``format_recovered_status``. Not async: providers
call it inline mid-retry-loop, and the one real implementation
(``ChatCompletion``'s closure over ``PromptServer.send_progress_text``) is
itself synchronous, so there's nothing to await."""
def format_retry_status(
reason: str, attempt: int, total_attempts: int, seed: int, timeout_secs: float
) -> str:
"""One-line, human-readable status for ``on_status()`` callers — shown
live on the node via ComfyUI's ``PromptServer.send_progress_text``
(see ``ChatCompletion.chat()``). Centralized so every provider's retry
loop describes a retry the same way rather than each inventing its own
wording.
"""
return (
f"⚠ {reason} on attempt {attempt}/{total_attempts} — "
f"retrying with seed={seed}, timeout={timeout_secs:.0f}s"
)
def format_recovered_status(attempt: int, total_attempts: int, seed: int) -> str:
"""Final status shown once a retry loop succeeds after >1 attempt —
lets a live status left over from ``format_retry_status`` resolve to
something other than a stale "retrying..." message."""
return f"✅ Recovered on attempt {attempt}/{total_attempts} (seed={seed})"
# ---------------------------------------------------------------------------
# Refusal/deflection detection
# ---------------------------------------------------------------------------
#
# Hybrid, cheapest-check-first: a fast, free lexical pass catches the blatant
# majority ("I cannot generate...") without ever touching the network; only
# a response that's short and/or hedge-y enough to be genuinely ambiguous
# pays for an embedding call. A long, on-topic response never reaches the
# embedding step at all.
REFUSAL_LEXICAL_PATTERNS: tuple[re.Pattern, ...] = tuple(
re.compile(p, re.IGNORECASE)
for p in (
r"\b(?:I\s*(?:'m|\s+am)?\s*)?(?:cannot|can't|won't|will not)\b[^.]{0,60}?\b"
r"(?:generate|create|produce|write|provide|help|assist|describe|depict|continue)\b",
r"\bI(?:'m|\s+am) (?:(?:not able|unable) to|restricted from)\b",
r"\bI don't feel comfortable\b",
r"\bI'm sorry,?\s*(?:but\s+)?I\s*(?:can't|cannot)\b",
r"\bas an AI\b[^.]{0,60}?\b(?:cannot|can't|unable|not able)\b",
r"\bI must (?:decline|refuse)\b",
r"\bthis (?:request|content|image|prompt) (?:violates|goes against)\b",
r"\bnot something I(?:'m| am)? (?:able to|going to) (?:help with|generate|create|provide)\b",
r"\bI (?:won't|will not) be (?:able to )?(?:generat|creat|provid)",
)
)
_SOFT_HEDGE_KEYWORDS: tuple[str, ...] = (
"sensitive",
"unfortunately",
"i must",
"policy",
"guideline",
"responsible ai",
"not appropriate",
"inappropriate",
"instead, i",
"i'd rather",
"i would rather",
"controversial",
"harmful",
"offensive",
"disturbing",
"explicit content",
"nsfw",
"consider an alternative",
)
_AMBIGUOUS_LENGTH_THRESHOLD = 600
"""Below this many characters, a response is short enough that a soft
refusal is plausible and worth the extra embedding check — chosen well
under this pipeline's normal structured-JSON response sizes (typically
1000+ characters), not a tuned/validated threshold.
Note: ``is_refusal`` no longer applies this gate when the caller supplies
``embed_fn`` (see below) — an explicitly configured embedding model always
runs the check regardless of length/keywords. ``is_ambiguous`` is kept as a
standalone, independently tested heuristic for other callers (e.g. logging
"why was this worth embedding-checking") rather than wired into the hybrid
detector itself."""
REFUSAL_EXEMPLARS: tuple[str, ...] = (
"I cannot generate an image or description involving this topic.",
"I'm not able to help with this request as it involves a politically sensitive subject.",
"I don't feel comfortable creating content related to this figure or event.",
"This request touches on a sensitive political topic that I must decline to engage with.",
"As an AI, I'm unable to produce content depicting this scenario.",
"I'd rather not generate this — it involves content I'm not comfortable with.",
)
def is_lexical_refusal(text: str) -> bool:
"""Cheap, free regex pass — catches the blatant majority of refusals."""
return any(p.search(text) for p in REFUSAL_LEXICAL_PATTERNS)
def is_ambiguous(text: str) -> bool:
"""Whether ``text`` is short/hedge-y enough to be worth the pricier
embedding check, having already failed the free lexical pass.
Deliberately cheap and approximate — false positives here only cost one
extra embedding call, false negatives skip a refusal that a real
similarity check might have caught. Not meant to be a precise signal on
its own, just a gate on when the more expensive check runs at all.
"""
stripped = text.strip()
if not stripped:
return False
if len(stripped) < _AMBIGUOUS_LENGTH_THRESHOLD:
return True
lowered = stripped.lower()
return any(keyword in lowered for keyword in _SOFT_HEDGE_KEYWORDS)
def cosine_similarity(a: list[float], b: list[float]) -> float:
"""Standard cosine similarity, no numpy dependency (comfydv has none)."""
if not a or not b or len(a) != len(b):
return 0.0
dot = sum(x * y for x, y in zip(a, b))
norm_a = math.sqrt(sum(x * x for x in a))
norm_b = math.sqrt(sum(y * y for y in b))
if norm_a == 0.0 or norm_b == 0.0:
return 0.0
return dot / (norm_a * norm_b)
EmbedFn = Callable[[str], Awaitable[list[float] | None]]
_exemplar_embedding_cache: dict[str, list[list[float]]] = {}
async def _exemplar_embeddings(
embed_fn: EmbedFn, cache_key: str, exemplars: tuple[str, ...] = REFUSAL_EXEMPLARS
) -> list[list[float]]:
"""Embed ``exemplars`` once per ``cache_key`` and reuse — the exemplar
set only changes if the caller's custom phrases change (folded into
``cache_key`` by the caller), or the embedding space (i.e. which model
produced the vectors) does.
"""
cached = _exemplar_embedding_cache.get(cache_key)
if cached is not None:
return cached
embeddings = []
for exemplar in exemplars:
vec = await embed_fn(exemplar)
if not vec:
# An embedding call failing for one exemplar almost certainly
# means embeddings aren't usable at all right now (wrong/missing
# embedding model, unreachable server) — bail out rather than
# caching a partial, unusable exemplar set.
return []
embeddings.append(vec)
_exemplar_embedding_cache[cache_key] = embeddings
return embeddings
def _matches_custom_phrase(text: str, custom_phrases: tuple[str, ...]) -> bool:
"""Case-insensitive substring match against user-supplied phrases."""
if not custom_phrases:
return False
lowered = text.lower()
return any(phrase.lower() in lowered for phrase in custom_phrases)
async def is_refusal(
text: str,
*,
embed_fn: EmbedFn | None = None,
embed_cache_key: str = "",
threshold: float = 0.82,
custom_phrases: tuple[str, ...] = (),
) -> bool:
"""Hybrid refusal/deflection detector: free lexical pass first, then an
embedding-similarity fallback whenever the caller has configured one.
``embed_fn`` is supplied by the caller's own ``LLMProvider.embed()`` —
this function has no idea which backend or model produced ``text``, by
design (ADR: refusal detection is a model-behavior concern, not a
backend one). ``embed_fn=None`` (no embedding model configured) degrades
to lexical-only detection rather than erroring; ``embed_fn`` present
means the caller already opted in to the extra cost, so every non-blank,
non-lexically-caught response gets checked — no further length/keyword
gating. Any failure while embedding (unreachable server, no
embedding-capable model loaded) is swallowed the same way — an optional
enhancement failing shouldn't take down the retry loop it's assisting.
``custom_phrases`` lets a caller extend detection at runtime — e.g. a
ComfyUI node field the user edits directly — without touching the
shipped patterns/exemplars. Each phrase is checked two ways: a free
case-insensitive substring match (same cost tier as the lexical pass,
so it runs even with no ``embed_fn`` configured), and, when ``embed_fn``
is present, folded in as additional exemplars for the similarity check
so near-matches (not just exact substrings) of the user's phrases count
too.
"""
if not text or not text.strip():
return False # blank responses are the *other* retry trigger, not this one
if is_lexical_refusal(text):
return True
custom_phrases = tuple(p.strip() for p in custom_phrases if p and p.strip())
if _matches_custom_phrase(text, custom_phrases):
return True
if embed_fn is None:
return False
# embed_fn only exists when the caller explicitly configured an
# embedding_model — that's an opt-in to pay for the check, so run it on
# every non-blank, non-lexically-caught response rather than gating
# further on is_ambiguous. The length/keyword heuristic exists to avoid
# *unwanted* embedding calls when no embedding model is configured (see
# the embed_fn is None branch above); it has no reason to also suppress
# calls once the caller has already asked for them, and doing so was
# exactly what let the subtle/on-topic-looking deflections this feature
# targets slip through undetected.
exemplars = (
REFUSAL_EXEMPLARS + custom_phrases if custom_phrases else REFUSAL_EXEMPLARS
)
exemplar_cache_key = (
f"{embed_cache_key}|custom:{','.join(custom_phrases)}"
if custom_phrases
else embed_cache_key
)
try:
exemplar_vecs = await _exemplar_embeddings(
embed_fn, exemplar_cache_key, exemplars
)
if not exemplar_vecs:
return False
text_vec = await embed_fn(text)
if not text_vec:
return False
except Exception:
return False
return max(cosine_similarity(text_vec, vec) for vec in exemplar_vecs) >= threshold
+34 -20
View File
@@ -21,7 +21,7 @@ import sys
from typing import Any, Dict, List
from aiohttp import web
from jinja2 import exceptions, sandbox
from jinja2 import exceptions, meta, sandbox
# Set up logger for this module
logger = logging.getLogger(__name__)
@@ -75,6 +75,10 @@ class FormatString:
# Create a sandboxed Jinja2 environment for security
jinja_env = sandbox.SandboxedEnvironment()
# Jinja2 ships `tojson` but not its inverse; add one so STRING inputs
# carrying a JSON array/object (ComfyUI has no native list socket type)
# can be parsed back into real Python data, e.g. `{{ hints | fromjson }}`.
jinja_env.filters["fromjson"] = json.loads
# Define additional context
@staticmethod
@@ -252,38 +256,48 @@ class FormatString:
>>> # Test with additional context (should be excluded)
>>> keys = FormatString._extract_keys("Time: {{ datetime.now() }}")
>>> assert keys == []
>>> # Test {% for %} control structures: the loop variable is bound by the
>>> # template itself and must not be treated as a required input, while the
>>> # iterable it draws from must be.
>>> keys = FormatString._extract_keys(
... "{% for hint in extraction_hints %}{{ hint }}{% endfor %}"
... )
>>> assert keys == ['extraction_hints']
-->
"""
variables = []
seen = set()
def add_var(var):
var = var.split("|")[0].split(".")[0].strip()
if var not in seen and var not in FormatString.additional_context:
seen.add(var)
variables.append(var)
# Extract variables from Jinja2 expressions {{ }}
for match in re.finditer(
r"\{\{\s*([\w.]+)(?:\s*\|[\w\s]+)?(?:\.[^\(\)]+\(\))?\s*\}\}", template
):
add_var(match.group(1))
# Extract variables from f-string style { }
# Extract variables from Python str.format() style { }
for match in re.finditer(r"\{(\w+)\}", template):
add_var(match.group(1))
# Extract variables from Jinja2 control structures {% %}
for structure in re.finditer(r"\{%.*?%\}", template):
for var in re.findall(r"\b(\w+)\|\b", structure.group(0)):
if not var.startswith("end") and var not in {
"if",
"else",
"elif",
"for",
"in",
}:
add_var(var)
# Extract variables referenced anywhere in Jinja2 syntax ({{ }} expressions
# and {% %} control structures) by parsing the template with Jinja2 itself
# rather than approximating it with regexes. This is what correctly excludes
# names bound within the template (e.g. the `hint` loop variable in
# `{% for hint in extraction_hints %}`) while still surfacing names the
# template expects the caller to supply (e.g. `extraction_hints`).
try:
template_ast = FormatString.jinja_env.parse(template)
except exceptions.TemplateSyntaxError:
pass
else:
# find_undeclared_variables returns an unordered set; sort by first
# textual occurrence so extraction order is deterministic and matches
# the order the template reads left to right (callers rely on this
# for positional outputs, e.g. two {{ }} variables in sequence).
undeclared = sorted(
meta.find_undeclared_variables(template_ast),
key=lambda name: template.find(name),
)
for var in undeclared:
add_var(var)
return variables
+325 -12
View File
@@ -12,8 +12,11 @@ LLMProvider adapter-pattern boundary shared with future backends. Chat,
model listing, and load/unload nodes are generic (ChatCompletion,
LLMModelSelector, LLMLoadModel, LLMUnloadModel) and delegate to whichever
provider is wired in — see MIGRATION_MAP below for the old Ollama-specific
names these replace. Structured output is now pydantic-ai backed
(comfydv._llm.chat), superseding ADR-006's hand-rolled tool-calling.
names these replace. Structured output mechanism is provider-specific as of
ADR-009: LlamaCppProvider is pydantic-ai backed (comfydv._llm.chat, native
JSON-schema output mode); OllamaProvider hand-rolls native Ollama
``/api/chat`` + ``"format"`` directly, since Ollama's OpenAI-compatible
endpoint was found to silently discard per-request context-size options.
"""
import json
@@ -27,6 +30,47 @@ from ._llm.provider import Message
logger = logging.getLogger(__name__)
def _encode_image_tensor(image) -> list[str]:
"""Convert a ComfyUI ``IMAGE`` into base64-encoded PNG string(s).
ComfyUI passes images as a float tensor shaped ``[B, H, W, C]`` in the
``0..1`` range. Each frame in the batch becomes one base64 PNG in the
returned list (spec 009 / ADR-008: a batch is carried as multiple images
on the turn). ``None`` or an empty tensor yields ``[]`` so callers can
treat "no image wired" uniformly.
``numpy``/``PIL`` are imported lazily here — they are provided by the
ComfyUI runtime, not a comfydv core dependency, and importing them at
module scope would break loading outside ComfyUI (Constitution IV). The
``_llm`` layer never touches tensors or Pillow; only this node does.
"""
if image is None:
return []
import base64
from io import BytesIO
import numpy as np
from PIL import Image
arr = image
if hasattr(arr, "detach"): # torch tensor
arr = arr.detach().cpu().numpy()
arr = np.asarray(arr)
if arr.size == 0:
return []
if arr.ndim == 3: # a bare [H, W, C] — treat as a batch of one
arr = arr[None, ...]
encoded: list[str] = []
for frame in arr:
frame_u8 = np.clip(frame * 255.0 + 0.5, 0, 255).astype(np.uint8)
buffer = BytesIO()
Image.fromarray(frame_u8).save(buffer, format="PNG")
encoded.append(base64.b64encode(buffer.getvalue()).decode("ascii"))
return encoded
# ---------------------------------------------------------------------------
# Migration mapping (ADR-007) — old Ollama-specific node/socket names to
# their generic replacements, for anyone reconnecting a pre-upgrade
@@ -260,6 +304,18 @@ class LLMModelSelector:
}
}
@classmethod
def VALIDATE_INPUTS(cls, model):
"""Bypass the frozen-at-startup combo list (see _load_default_models).
The JS Refresh button keeps the widget's dropdown live, but
``_DEFAULT_MODELS`` is only ever populated once, at server start-up.
Without this, models pulled into Ollama afterward validate in the
UI but fail prompt validation with "value ... is not available"
until ComfyUI is restarted.
"""
return True
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("model_name",)
FUNCTION = "select_model"
@@ -284,6 +340,11 @@ class LLMLoadModel:
}
}
@classmethod
def VALIDATE_INPUTS(cls, model):
"""See LLMModelSelector.VALIDATE_INPUTS for why this bypass exists."""
return True
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("model_name",)
FUNCTION = "load_model"
@@ -349,9 +410,10 @@ def _history_preview(messages: list[dict]) -> str:
# ---------------------------------------------------------------------------
# Structured output schema helpers — stay here (pure/local, no network
# dependency), shared by ChatCompletion.chat() and the live-preview route.
# The actual structured-output *mechanism* (tool-calling, retry, validation)
# moved to comfydv._llm.chat / pydantic-ai as of ADR-007, superseding
# ADR-006's hand-rolled approach.
# The actual structured-output *mechanism* (retry, validation, request
# shape) lives per-provider — comfydv._llm.chat (pydantic-ai) for
# LlamaCppProvider, native Ollama /api/chat + "format" for OllamaProvider
# (ADR-009, superseding ADR-007's shared-pydantic-ai-for-both approach).
_JSON_SCHEMA_TO_PY_TYPE: dict = {
"string": str,
@@ -413,6 +475,13 @@ def _build_structured_model(schema: dict):
exactly the "blank output" problem this feature exists to eliminate. An
empty required string now fails validation and triggers a retry like any
other malformed response, rather than silently passing through.
Non-required fields are typed ``py_type | None``, not bare ``py_type``:
a bare type with a ``None`` default only covers the field being *omitted*
entirely — pydantic still rejects an explicitly present ``null`` value
against a non-Optional type. Models routinely emit explicit ``null``
(e.g. `"duration_seconds": null`) for genuinely-absent optional fields
rather than omitting the key, so the type must accept that.
"""
from pydantic import Field, create_model
@@ -421,7 +490,7 @@ def _build_structured_model(schema: dict):
for name, prop in schema["properties"].items():
py_type = _JSON_SCHEMA_TO_PY_TYPE.get(prop.get("type"), str)
if name not in required:
fields[name] = (py_type, None)
fields[name] = (py_type | None, None)
elif py_type is str:
fields[name] = (py_type, Field(..., min_length=1))
else:
@@ -440,8 +509,20 @@ def _coerce_structured_value(value, comfy_type: str):
class ChatCompletion:
OUTPUT_NODE = True
_BASE_RETURN_TYPES = ("STRING", "OLLAMA_HISTORY", "STRING")
_BASE_RETURN_NAMES = ("response", "updated_history", "model_name")
# "seed_used" is always the LAST output, after any dynamic structured-
# output fields — not inserted right after model_name — so a schema's
# fields keep starting at the same fixed index (3) they occupied before
# this output existed. Already-wired workflows (e.g.
# workflows/ltx-i2v-pipeline.json) link FormatString inputs to a
# ChatCompletion node's dynamic field by output *index*; inserting a
# new fixed output ahead of those fields would silently repoint every
# such link at the wrong socket.
_FIXED_RETURN_TYPES = ("STRING", "OLLAMA_HISTORY", "STRING")
_FIXED_RETURN_NAMES = ("response", "updated_history", "model_name")
_SEED_RETURN_TYPE = ("INT",)
_SEED_RETURN_NAME = ("seed_used",)
_BASE_RETURN_TYPES = _FIXED_RETURN_TYPES + _SEED_RETURN_TYPE
_BASE_RETURN_NAMES = _FIXED_RETURN_NAMES + _SEED_RETURN_NAME
# Per-node-instance structured-output config, keyed by unique_id — same
# pattern as FormatString.node_configs.
@@ -461,6 +542,18 @@ class ChatCompletion:
"system": ("STRING", {"multiline": True, "default": ""}),
"history": ("OLLAMA_HISTORY",),
"options": ("OLLAMA_OPTIONS",),
"image": (
"IMAGE",
{
"tooltip": (
"Optional image(s) for a vision-capable model. "
"Requires a multimodal model on the connected "
"server (Ollama multimodal model, or llama.cpp "
"launched with --mmproj). A batch is sent as "
"multiple images on the turn."
)
},
),
"timeout_secs": ("INT", {"default": 300, "min": 30, "max": 3600}),
"structured_output": ("BOOLEAN", {"default": False}),
"output_schema": (
@@ -498,8 +591,14 @@ class ChatCompletion:
cls.RETURN_NAMES = cls._BASE_RETURN_NAMES
return
names = tuple(schema["properties"].keys())
cls.RETURN_TYPES = cls._BASE_RETURN_TYPES + _comfy_types_for_schema(schema)
cls.RETURN_NAMES = cls._BASE_RETURN_NAMES + names
# Dynamic fields land between the fixed base outputs and the
# trailing seed_used — see _SEED_RETURN_TYPE's comment above.
cls.RETURN_TYPES = (
cls._FIXED_RETURN_TYPES
+ _comfy_types_for_schema(schema)
+ cls._SEED_RETURN_TYPE
)
cls.RETURN_NAMES = cls._FIXED_RETURN_NAMES + names + cls._SEED_RETURN_NAME
def chat(
self,
@@ -509,6 +608,7 @@ class ChatCompletion:
system="",
history=None,
options=None,
image=None,
timeout_secs=300,
structured_output=False,
output_schema=_DEFAULT_OUTPUT_SCHEMA,
@@ -535,7 +635,40 @@ class ChatCompletion:
message_dicts = [{"role": "system", "content": system}] + message_dicts
message_dicts.append({"role": "user", "content": prompt})
messages = [Message(**m) for m in message_dicts]
# Attach any wired image(s) to the current user turn only (FR-007) —
# history turns are left untouched. Encoding lives in the node
# (comfy-guarded); providers see only base64 strings on the Message.
user_images = _encode_image_tensor(image)
if user_images:
messages[-1].images = user_images
llm_options = dict(options) if options else None
# Populated in place by the provider (see _llm/retry.py's
# record_attempt_info) with the seed/timeout actually used and how
# many attempts/refusals it took — an out-param rather than a
# return-type change, so it works the same regardless of which
# concrete provider ``client`` is.
attempt_info: dict = {}
# Live counterpart to attempt_info: ComfyUI's own send_progress_text
# mechanism (already used by core nodes like PreviewAny/gaussian
# splat count) shows this text on the node WHILE it's still
# executing, via a "progressText" widget the frontend creates
# automatically — no custom JS needed on our side. Best-effort:
# a failure here must never take down the actual chat call.
on_status = None
if unique_id and "comfy" in sys.modules:
def on_status(message: str) -> None:
try:
from server import PromptServer
PromptServer.instance.send_progress_text(message, unique_id)
except Exception:
logger.debug(
"Failed to send live retry status for node %s",
unique_id,
exc_info=True,
)
# Provider owns transport, caching, and — for structured_output — the
# tool-calling/retry/validation mechanism (pydantic-ai, ADR-007).
@@ -548,6 +681,9 @@ class ChatCompletion:
messages,
llm_options,
timeout_secs=float(timeout_secs),
max_retries=max_retries,
attempt_info=attempt_info,
on_status=on_status,
)
)
else:
@@ -562,20 +698,44 @@ class ChatCompletion:
llm_options,
timeout_secs=float(timeout_secs),
max_retries=max_retries,
attempt_info=attempt_info,
on_status=on_status,
)
)
response_text = parsed.model_dump_json()
seed_used = attempt_info.get("seed", 0)
attempts_made = attempt_info.get("attempts", 1)
refusals = attempt_info.get("refusals", 0)
updated = list(history)
updated.append({"role": "user", "content": prompt})
updated.append({"role": "assistant", "content": response_text})
n = len(updated)
# Visual feedback (US: "show me when a retry/refusal happened") —
# surfaced in the node's existing text preview rather than a new UI
# surface, so it's visible without any frontend/JS changes.
status_line = ""
if attempts_made > 1:
status_line = (
f"⚠️ Refusal/deflection detected — retried {refusals} "
f"time(s), succeeded on attempt {attempts_made} with "
f"seed={seed_used}.\n\n"
if refusals
else f"⚠️ Retried (blank/invalid response) — succeeded on "
f"attempt {attempts_made} with seed={seed_used}.\n\n"
)
ui_text = (
f"{response_text}\n\n── History: {n} message(s) ──\n{_history_preview(updated)}"
f"{status_line}{response_text}\n\n"
f"── History: {n} message(s) ──\n{_history_preview(updated)}"
if n > 2
else response_text
else f"{status_line}{response_text}"
)
# seed_used is appended last, after any dynamic structured fields —
# see ChatCompletion's _SEED_RETURN_TYPE comment for why.
result_tuple = (response_text, updated, effective_model)
if structured_output:
assert schema is not None # structured_output implies this was parsed
@@ -585,6 +745,7 @@ class ChatCompletion:
for name, ctype in zip(schema["properties"].keys(), comfy_types)
)
result_tuple += extra
result_tuple += (seed_used,)
return {
"ui": {"text": [ui_text]},
@@ -779,6 +940,158 @@ class OllamaOptionRepeatPenalty:
return (_merge_option(options, "repeat_penalty", repeat_penalty),)
class OllamaOptionDisableThinking:
"""Turn off (or explicitly re-enable) a "thinking"-capable model's
chain-of-thought reasoning (ADR-010).
Rides the same composable ``OLLAMA_OPTIONS`` chain as every other
``OllamaOption*`` node, but unlike those (Ollama-native sampling
params passed through verbatim), the ``"think"`` key this node emits is
a comfydv-level convention: every ``LLMProvider`` implementation pops
it out of the merged ``options`` dict and translates it to its own
wire shape — Ollama's native top-level ``think`` field (confirmed live:
silently ignored if left nested in ``options``), or llama-server's
``chat_template_kwargs``/``reasoning_effort`` request-body fields
(per llama.cpp's server docs — not live-verified). Works for both
backends from the same node.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"disable_thinking": (
"BOOLEAN",
{
"default": True,
"tooltip": (
"On: skip chain-of-thought reasoning entirely "
"— faster, and the model's whole token budget "
"goes to the actual response. Off: explicitly "
"re-enable thinking (only useful to override a "
"server-side default)."
),
},
),
},
"optional": {"options": ("OLLAMA_OPTIONS",)},
}
RETURN_TYPES = ("OLLAMA_OPTIONS",)
RETURN_NAMES = ("options",)
FUNCTION = "set_disable_thinking"
CATEGORY = "dv/ollama/options"
def set_disable_thinking(self, disable_thinking, options=None):
return (_merge_option(options, "think", not disable_thinking),)
class OllamaOptionRefusalRetry:
"""Retry with a bumped seed when a response looks like a soft
refusal/deflection rather than an actual error (see
``_llm/retry.py``'s ``is_refusal()``).
Observed with an abliterated Qwen variant: it sometimes answers a
request it judges "politically sensitive" with hedging refusal
language instead of erroring or returning blank — neither of which the
existing blank-response or schema-validation retry triggers catch, so
without this the response just passes through as-is.
Rides the same composable ``OLLAMA_OPTIONS`` chain as every other
``OllamaOption*`` node, but like ``OllamaOptionDisableThinking``, the
``"refusal_retry"`` key this node emits is a comfydv-level convention,
not an Ollama-native sampling param: every ``LLMProvider``
implementation pops it out of ``options`` and drives its own retry
loop with it (ADR: refusal detection is a model-behavior concern, not
a backend one — see ``LLMProvider.embed()`` in ``_llm/provider.py``).
Works for both backends from the same node.
``custom_phrases`` (comma-separated) lets you add your own trigger
phrases at runtime, without a code change/release — useful for a new
deflection phrasing a specific model uses that the shipped patterns in
``REFUSAL_LEXICAL_PATTERNS``/``REFUSAL_EXEMPLARS`` don't cover yet.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"enabled": ("BOOLEAN", {"default": True}),
"embedding_model": (
"STRING",
{
"default": "",
"tooltip": (
"Name of a separate embedding-capable model "
'(e.g. "nomic-embed-text") — NOT the chat '
"model itself; most chat models can't produce "
"usable embeddings. Leave blank to skip the "
"embedding-similarity check and detect only "
"blatant, literal refusal phrases (still "
"useful, cheaper, catches less)."
),
},
),
"threshold": (
"FLOAT",
{
"default": 0.82,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": (
"Cosine-similarity threshold against canonical "
"refusal exemplars, above which an ambiguous "
"(short/hedge-y) response is treated as a "
"refusal. Only consulted when embedding_model "
"is set and the cheap lexical pass didn't "
"already catch it."
),
},
),
"custom_phrases": (
"STRING",
{
"default": "",
"tooltip": (
"Comma-separated phrases you want treated as "
'refusals too, e.g. "I am restricted from, as an '
'AI model, I must avoid". Checked as free, exact '
"case-insensitive substrings (no embedding model "
"needed) and, when embedding_model is set, also "
"folded in as extra exemplars for the similarity "
"check — lets you extend detection at runtime "
"without waiting on a shipped pattern update."
),
},
),
},
"optional": {"options": ("OLLAMA_OPTIONS",)},
}
RETURN_TYPES = ("OLLAMA_OPTIONS",)
RETURN_NAMES = ("options",)
FUNCTION = "set_refusal_retry"
CATEGORY = "dv/ollama/options"
def set_refusal_retry(
self, enabled, embedding_model, threshold, custom_phrases="", options=None
):
phrases = tuple(p.strip() for p in custom_phrases.split(",") if p and p.strip())
return (
_merge_option(
options,
"refusal_retry",
{
"enabled": enabled,
"embedding_model": embedding_model.strip(),
"threshold": threshold,
"custom_phrases": phrases,
},
),
)
class OllamaOptionExtraBody:
@classmethod
def INPUT_TYPES(s):
+36 -5
View File
@@ -1,3 +1,4 @@
import json
import logging
import random
import sys
@@ -7,6 +8,28 @@ from .utils import any_type
logger = logging.getLogger(__name__)
def _preview_text(value) -> str:
"""Best-effort text preview for RandomChoice's arbitrary-typed output.
Mirrors ComfyUI core's own ``PreviewAny`` node's value handling (str/
number passthrough, else JSON, else ``str()``) rather than inventing a
new convention — RandomChoice's output can be anything (an IMAGE
tensor, a LATENT, a plain string), so this only needs to be "good
enough to glance at," not a faithful repr of every type.
"""
if isinstance(value, str):
return value
if isinstance(value, (int, float, bool)):
return str(value)
try:
return json.dumps(value, default=str, indent=2)
except Exception:
try:
return str(value)
except Exception:
return "<value could not be serialized>"
class RandomChoice:
def __init__(self):
pass
@@ -25,15 +48,20 @@ class RandomChoice:
FUNCTION = "random_choice"
OUTPUT_NODE = False
OUTPUT_NODE = True
CATEGORY = "dv/utils"
@classmethod
def IS_CHANGED(s, **kwargs):
return s.random_choice(s, **kwargs)
# Unchanged from before the UI-preview addition: returns the raw
# picked value (not the ui-wrapped dict random_choice() now returns)
# so ComfyUI's change-detection comparison keeps working exactly as
# it did previously.
return s._pick(**kwargs)
def random_choice(self, **kwargs):
@staticmethod
def _pick(**kwargs):
(
random.seed(kwargs.get("seed"))
if kwargs.get("seed")
@@ -41,10 +69,13 @@ class RandomChoice:
)
input = [i for i in kwargs.items() if i[0] != "seed"]
logger.debug("RandomChoice inputs: %s", input)
return random.choice(input)[1]
def random_choice(self, **kwargs):
try:
choice = random.choice(input)[1]
choice = self._pick(**kwargs)
logger.debug("RandomChoice chose: %s", choice)
return (choice,)
return {"ui": {"text": [_preview_text(choice)]}, "result": (choice,)}
except Exception as e:
logger.error("RandomChoice: unexpected error: %s", e)
raise
+57
View File
@@ -0,0 +1,57 @@
/**
* preview_text.js — read-only output preview for comfydv's OUTPUT_NODE=True
* nodes that return a ComfyUI "ui": {"text": [...]} payload
* (ChatCompletion, FormatString, RandomChoice).
*
* ComfyUI does NOT auto-render an arbitrary node's ui.text — each node type
* that wants one implements its own onExecuted handler. This mirrors core's
* own ``PreviewAny`` node (comfy_extras/nodes_preview_any.py +
* "Comfy.PreviewAny" in the frontend bundle) minus its Markdown/Plaintext
* toggle, which none of these three nodes need.
*/
import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";
const PREVIEW_NODES = new Set(["ChatCompletion", "FormatString", "RandomChoice"]);
app.registerExtension({
name: "comfydv.previewText",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (!PREVIEW_NODES.has(nodeData.name)) return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
const widget = ComfyWidgets.STRING(
this,
"comfydv_preview_text",
["STRING", { multiline: true }],
app
).widget;
widget.label = "Preview";
widget.options.read_only = true;
// Not a real input — nothing to save/replay in the saved
// workflow JSON, and read-only anyway.
widget.options.serialize = false;
widget.serialize = false;
widget.inputEl.readOnly = true;
return result;
};
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
const widget = this.widgets?.find(w => w.name === "comfydv_preview_text");
if (!widget) return;
const text = message?.text ?? "";
widget.value = Array.isArray(text) ? (text.join("\n\n") ?? "") : text;
this.setDirtyCanvas(true, true);
};
},
});
+9
View File
@@ -60,6 +60,15 @@ def pytest_configure(config):
sys.modules["folder_paths"] = MockFolderPaths
# Force "comfydv" to resolve to src/comfydv and get cached in sys.modules now,
# while our sys.path.insert(0, ...) above is still the definitive answer. The
# repo root's own __init__.py (ComfyUI's custom-node entry point) is also a
# valid "comfydv" package from certain sys.path states pytest transiently
# constructs during fixture setup; without this, a later bare `import comfydv`
# (e.g. in the _clear_ollama_caches fixture) can resolve to that root package
# instead, which lacks the _llm submodule and fails with ModuleNotFoundError.
import comfydv # noqa: F401
# ---------------------------------------------------------------------------
# Ollama fixtures (used by @pytest.mark.integration tests)
+41 -5
View File
@@ -35,9 +35,32 @@ class TestVariableExtraction:
def test_extract_jinja2_with_multiple_filters(self, format_string_class):
"""Test extraction of variables with multiple Jinja2 filters."""
keys = format_string_class._extract_keys("{{ name | upper | trim }}")
# Multiple chained filters may not extract - that's a limitation of the regex
# Just test that it doesn't crash
assert isinstance(keys, list)
assert keys == ["name"]
def test_extract_jinja2_for_loop_excludes_loop_variable(self, format_string_class):
"""The for-loop target (e.g. `hint`) is bound by the template and must
not be treated as a required input; the iterable it draws from must be."""
keys = format_string_class._extract_keys(
"{% for hint in extraction_hints %}- {{ hint }}\n{% endfor %}"
)
assert keys == ["extraction_hints"]
def test_extract_jinja2_if_condition_variable(self, format_string_class):
"""A variable referenced only in an {% if %} condition must still be
detected, even without a matching {{ }} expression elsewhere."""
keys = format_string_class._extract_keys(
"{% if extraction_hints is defined and extraction_hints %}yes{% endif %}"
)
assert keys == ["extraction_hints"]
def test_extract_jinja2_filter_with_arguments(self, format_string_class):
"""A filter called with arguments (e.g. tojson(indent=2)) has parens
in the way of the old regex's anchor to the closing }} — the variable
must still be detected."""
keys = format_string_class._extract_keys(
"{{ scene_manifest | tojson(indent=2) }}"
)
assert keys == ["scene_manifest"]
def test_extract_jinja2_multiple_variables(self, format_string_class):
"""Test extraction of multiple variables from Jinja2 template."""
@@ -178,6 +201,18 @@ class TestJinja2Formatting:
assert result[2] == "John"
assert result[3] == "Doe"
def test_jinja2_fromjson_filter_parses_array(self, format_string_class):
"""ComfyUI has no native list socket, so a STRING input carrying a
JSON array must be parseable back into a real list for iteration."""
result = format_string_class.format_string(
template_type="Jinja2",
template="{% for hint in hints | fromjson %}- {{ hint }}\n{% endfor %}",
save_path="",
unique_id="test-fromjson",
hints='["motion", "camera pan"]',
)["result"]
assert result[0] == "- motion\n- camera pan\n"
def test_jinja2_with_datetime(self, format_string_class):
"""Test Jinja2 formatting with datetime context."""
result = format_string_class.format_string(
@@ -199,10 +234,11 @@ class TestJinja2Formatting:
unique_id="test9",
value=sample_data["value"],
)["result"]
# value is not extracted as a variable because it's used in an expression
assert len(result) == 2 # Just formatted_string, saved_file_path
# value is extracted even though it's used in an expression
assert len(result) == 3 # formatted_string, saved_file_path, value
assert result[0] == "Result: 10"
assert result[1] == ""
assert result[2] == str(sample_data["value"])
class TestInlineDisplay:
+19 -1
View File
@@ -39,8 +39,26 @@ class _FakeProvider:
async def unload_model(self, model):
self.calls.append(("unload_model", model))
async def chat(self, model, messages, options=None, timeout_secs=300.0):
async def chat(
self,
model,
messages,
options=None,
timeout_secs=300.0,
max_retries=2,
attempt_info=None,
on_status=None,
):
self.calls.append(("chat", model))
if attempt_info is not None:
attempt_info.update(
{
"seed": (options or {}).get("seed", 0),
"attempts": 1,
"timeout_secs": timeout_secs,
"refusals": 0,
}
)
return self.chat_response
+374 -1
View File
@@ -276,7 +276,7 @@ def test_chat_no_choices_returns_empty_string(monkeypatch):
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
result = _run_async(
LlamaCppProvider("http://localhost:8080").chat(
"gemma-3-4b", [Message(role="user", content="hi")]
"gemma-3-4b", [Message(role="user", content="hi")], max_retries=0
)
)
@@ -301,6 +301,185 @@ def test_chat_second_identical_call_is_cached(monkeypatch):
assert calls["n"] == 1
# ---------------------------------------------------------------------------
# chat — retry-on-blank-output. Mirrors test_ollama_provider.py's coverage;
# the one llama.cpp-specific detail is that the retry seed must land in the
# top-level OpenAI "seed" field, not nested under "options" (see chat()'s
# comment on why the options passthrough doesn't reach llama-server at all).
# ---------------------------------------------------------------------------
async def _fake_sleep(_secs):
"""No-op stand-in for asyncio.sleep — keeps retry tests instant."""
def test_chat_retries_on_blank_response_and_returns_second_attempt(monkeypatch):
calls = []
async def fake_post(url, payload, *, timeout=120.0, headers=None):
calls.append(payload)
if len(calls) == 1:
return {"choices": [{"message": {"content": ""}}]}
return {"choices": [{"message": {"content": "real answer"}}]}
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
monkeypatch.setattr(provider_mod.asyncio, "sleep", _fake_sleep)
result = _run_async(
LlamaCppProvider("http://localhost:8080").chat(
"gemma-3-4b", [Message(role="user", content="hi")]
)
)
assert result == "real answer"
assert len(calls) == 2
def test_chat_retry_seed_is_top_level_not_nested_in_options(monkeypatch):
calls = []
async def fake_post(url, payload, *, timeout=120.0, headers=None):
calls.append(payload)
if len(calls) < 3:
return {"choices": [{"message": {"content": ""}}]}
return {"choices": [{"message": {"content": "ok"}}]}
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
monkeypatch.setattr(provider_mod.asyncio, "sleep", _fake_sleep)
_run_async(
LlamaCppProvider("http://localhost:8080").chat(
"gemma-3-4b", [Message(role="user", content="hi")], max_retries=2
)
)
assert "seed" not in calls[0]
assert calls[1]["seed"] == 1
assert calls[2]["seed"] == 2
def test_chat_disable_thinking_sets_chat_template_kwargs_and_reasoning_effort(
monkeypatch,
):
"""ADR-010: llama-server doesn't recognize a "think" key nested inside
"options" (that's an Ollama-native convention) — it needs its own two
documented request-body toggles instead, and "think" must not leak into
the nested options object llama-server actually does understand."""
captured = {}
async def fake_post(url, payload, *, timeout=120.0, headers=None):
captured.update(payload)
return {"choices": [{"message": {"content": "ok"}}]}
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
_run_async(
LlamaCppProvider("http://localhost:8080").chat(
"gemma-3-4b",
[Message(role="user", content="hi")],
options={"temperature": 0.5, "think": False},
)
)
assert captured["chat_template_kwargs"] == {"enable_thinking": False}
assert captured["reasoning_effort"] == "none"
assert captured["options"] == {"temperature": 0.5} # "think" popped out
def test_chat_exhausted_retries_returns_blank_without_raising(monkeypatch):
calls = {"n": 0}
async def fake_post(url, payload, *, timeout=120.0, headers=None):
calls["n"] += 1
return {"choices": [{"message": {"content": ""}}]}
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
monkeypatch.setattr(provider_mod.asyncio, "sleep", _fake_sleep)
result = _run_async(
LlamaCppProvider("http://localhost:8080").chat(
"gemma-3-4b", [Message(role="user", content="hi")], max_retries=2
)
)
assert result == ""
assert calls["n"] == 3 # original + 2 retries, per max_retries=2
def test_chat_timeout_escalates_per_retry_attempt(monkeypatch):
timeouts = []
async def fake_post(url, payload, *, timeout=120.0, headers=None):
timeouts.append(timeout)
if len(timeouts) < 3:
return {"choices": [{"message": {"content": ""}}]}
return {"choices": [{"message": {"content": "done"}}]}
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
monkeypatch.setattr(provider_mod.asyncio, "sleep", _fake_sleep)
_run_async(
LlamaCppProvider("http://localhost:8080").chat(
"gemma-3-4b",
[Message(role="user", content="hi")],
timeout_secs=50.0,
max_retries=2,
)
)
assert timeouts == [50.0, 100.0, 150.0]
def test_chat_attempt_info_populated_on_success(monkeypatch):
async def fake_post(url, payload, *, timeout=120.0, headers=None):
return {"choices": [{"message": {"content": "ok"}}]}
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
attempt_info: dict = {}
_run_async(
LlamaCppProvider("http://localhost:8080").chat(
"gemma-3-4b",
[Message(role="user", content="hi")],
options={"seed": 9},
attempt_info=attempt_info,
)
)
assert attempt_info == {
"seed": 9,
"attempts": 1,
"timeout_secs": 300.0,
"refusals": 0,
}
def test_chat_on_status_called_on_retry_and_recovery(monkeypatch):
calls = []
async def fake_post(url, payload, *, timeout=120.0, headers=None):
calls.append(payload)
if len(calls) == 1:
return {"choices": [{"message": {"content": "I cannot generate that."}}]}
return {"choices": [{"message": {"content": "a real, on-topic answer"}}]}
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
monkeypatch.setattr(provider_mod.asyncio, "sleep", _fake_sleep)
statuses = []
_run_async(
LlamaCppProvider("http://localhost:8080").chat(
"gemma-3-4b",
[Message(role="user", content="hi")],
options={"refusal_retry": {"enabled": True, "embedding_model": ""}},
on_status=statuses.append,
)
)
assert len(statuses) == 2
assert "Refusal/deflection detected" in statuses[0]
assert "Recovered" in statuses[1]
# ---------------------------------------------------------------------------
# chat_structured — zero new logic, delegates to the shared helper unchanged
# ---------------------------------------------------------------------------
@@ -357,6 +536,64 @@ def test_chat_structured_forwards_options(monkeypatch):
assert captured["options"] == {"temperature": 0.0}
def test_chat_structured_forwards_attempt_info(monkeypatch):
from pydantic import BaseModel
class Widget(BaseModel):
name: str
captured = {}
async def fake_chat_structured(**kwargs):
captured.update(kwargs)
return Widget(name="x")
monkeypatch.setattr("comfydv._llm.chat.chat_structured", fake_chat_structured)
attempt_info: dict = {}
_run_async(
LlamaCppProvider("http://localhost:8080").chat_structured(
"gemma-3-4b",
[Message(role="user", content="hi")],
Widget,
attempt_info=attempt_info,
)
)
# Same object passed straight through — the shared helper populates it,
# this provider doesn't need to know its shape.
assert captured["attempt_info"] is attempt_info
def test_chat_structured_forwards_on_status(monkeypatch):
from pydantic import BaseModel
class Widget(BaseModel):
name: str
captured = {}
async def fake_chat_structured(**kwargs):
captured.update(kwargs)
return Widget(name="x")
monkeypatch.setattr("comfydv._llm.chat.chat_structured", fake_chat_structured)
def on_status(msg):
pass
_run_async(
LlamaCppProvider("http://localhost:8080").chat_structured(
"gemma-3-4b",
[Message(role="user", content="hi")],
Widget,
on_status=on_status,
)
)
assert captured["on_status"] is on_status
# ---------------------------------------------------------------------------
# _fetch_models — name-only view used by ComfyUI's /dv/ollama/models?backend=
# llamacpp route (the JS refresh button / node-creation auto-populate).
@@ -402,3 +639,139 @@ def test_fetch_models_degrades_to_empty_when_unreachable(monkeypatch):
names = _run_async(_fetch_models("http://localhost:19999"))
assert names == []
# ---------------------------------------------------------------------------
# chat — image input (spec 009, US2; features/us2_both_backends.feature)
# ---------------------------------------------------------------------------
def test_chat_maps_images_to_openai_content_parts(monkeypatch):
"""llama.cpp's OpenAI-compatible endpoint takes images as image_url parts
inside content, not a flat images field (ADR-008)."""
captured = {}
async def fake_post(url, payload, *, timeout=120.0, headers=None):
captured["payload"] = payload
return {"choices": [{"message": {"content": "a red square"}}]}
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
_run_async(
LlamaCppProvider("http://localhost:8080").chat(
"m", [Message(role="user", content="describe", images=["QUJD"])]
)
)
assert captured["payload"]["messages"][-1] == {
"role": "user",
"content": [
{"type": "text", "text": "describe"},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,QUJD"},
},
],
}
def test_chat_text_only_content_stays_plain_string(monkeypatch):
"""FR-003/SC-004: an image-less message keeps a plain string content,
byte-identical to today (no content-parts, no images key)."""
captured = {}
async def fake_post(url, payload, *, timeout=120.0, headers=None):
captured["payload"] = payload
return {"choices": [{"message": {"content": "ok"}}]}
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
_run_async(
LlamaCppProvider("http://localhost:8080").chat(
"m", [Message(role="user", content="hi")]
)
)
assert captured["payload"]["messages"] == [{"role": "user", "content": "hi"}]
# ---------------------------------------------------------------------------
# refusal-retry — parity with test_ollama_provider.py's coverage (ADR: this
# is a model-behavior concern, not a backend one — both providers wire the
# same comfydv._llm.retry.is_refusal() into their own retry loop).
# ---------------------------------------------------------------------------
def test_chat_retries_on_lexical_refusal_and_returns_clean_second_attempt(
monkeypatch,
):
calls = []
async def fake_post(url, payload, *, timeout=120.0, headers=None):
calls.append(payload)
if len(calls) == 1:
return {"choices": [{"message": {"content": "I cannot generate that."}}]}
return {"choices": [{"message": {"content": "a real, on-topic answer"}}]}
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
monkeypatch.setattr(provider_mod.asyncio, "sleep", _fake_sleep)
result = _run_async(
LlamaCppProvider("http://localhost:8080").chat(
"gemma-3-4b",
[Message(role="user", content="hi")],
options={"refusal_retry": {"enabled": True, "embedding_model": ""}},
)
)
assert result == "a real, on-topic answer"
assert len(calls) == 2
def test_chat_refusal_retry_disabled_returns_refusal_text_unchanged(monkeypatch):
calls = []
async def fake_post(url, payload, *, timeout=120.0, headers=None):
calls.append(payload)
return {"choices": [{"message": {"content": "I cannot generate that."}}]}
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
result = _run_async(
LlamaCppProvider("http://localhost:8080").chat(
"gemma-3-4b", [Message(role="user", content="hi")]
)
)
assert result == "I cannot generate that."
assert len(calls) == 1
def test_embed_returns_vector_from_v1_embeddings(monkeypatch):
captured = {}
async def fake_post(url, payload, *, timeout=120.0, headers=None):
captured["url"] = url
captured["payload"] = payload
return {"data": [{"embedding": [0.4, 0.5, 0.6], "index": 0}]}
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
result = _run_async(
LlamaCppProvider("http://localhost:8080").embed("nomic-embed-text", "hello")
)
assert result == [0.4, 0.5, 0.6]
assert captured["url"] == "http://localhost:8080/v1/embeddings"
assert captured["payload"] == {"model": "nomic-embed-text", "input": "hello"}
def test_embed_returns_none_when_no_embedding_model_configured(monkeypatch):
async def fake_post(url, payload, *, timeout=120.0, headers=None):
raise RuntimeError("llama-server returned HTTP 404 for /v1/embeddings")
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
result = _run_async(
LlamaCppProvider("http://localhost:8080").embed("nomic-embed-text", "hello")
)
assert result is None
+499
View File
@@ -23,6 +23,18 @@ from comfydv._llm.ollama_provider import _run_async
from comfydv._llm.provider import Message
@pytest.fixture(autouse=True)
def _no_retry_backoff(monkeypatch):
"""Keep the real RETRY_BACKOFF_SECS delay out of this suite's wall-clock
time for every test except the ones that specifically assert on it
(which re-monkeypatch locally, overriding this)."""
async def _instant_sleep(_secs):
pass
monkeypatch.setattr(chat_mod.asyncio, "sleep", _instant_sleep)
class _Widget(BaseModel):
name: str
count: int
@@ -75,6 +87,42 @@ def test_chat_structured_returns_validated_output(monkeypatch):
assert fake.calls[0][0] == "describe a widget"
def test_build_agent_uses_native_output_not_tool_calling(monkeypatch):
"""ADR-009: the Agent must be built with NativeOutput (response_format /
JSON-schema constrained decoding), not pydantic-ai's tool-calling
default. Regression guard against reverting to a bare `output_type=schema`,
which let a thinking-capable model exhaust its token budget on reasoning
and never emit a tool call (confirmed live against a real Ollama server).
Spies on the real pydantic_ai.Agent constructor (only _build_agent's own
seam is mocked in every other test in this file) and asserts on its
output_type argument via the public NativeOutput marker class, rather
than pydantic-ai's private internal schema representation."""
from pydantic_ai import NativeOutput
captured = {}
real_agent_cls = chat_mod.Agent
class _SpyAgent(real_agent_cls):
def __init__(self, *args, **kwargs):
captured.update(kwargs)
super().__init__(*args, **kwargs)
monkeypatch.setattr(chat_mod, "Agent", _SpyAgent)
chat_mod._build_agent(
base_url="http://localhost:11434/v1",
model="llama3",
schema=_Widget,
headers=None,
timeout_secs=300.0,
)
output_type = captured["output_type"]
assert isinstance(output_type, NativeOutput)
assert output_type.outputs == _Widget
def test_chat_structured_retries_on_validation_failure(monkeypatch):
bad = ValidationError.from_exception_data("Widget", [])
fake = _FakeAgent([bad, _Widget(name="b", count=2)])
@@ -94,6 +142,100 @@ def test_chat_structured_retries_on_validation_failure(monkeypatch):
assert len(fake.calls) == 2
def test_chat_structured_timeout_escalates_per_retry_attempt(monkeypatch):
bad = ValidationError.from_exception_data("Widget", [])
fake = _FakeAgent([bad, bad, _Widget(name="b", count=2)])
build_calls = []
def fake_build_agent(**kw):
build_calls.append(kw["timeout_secs"])
return fake
monkeypatch.setattr(chat_mod, "_build_agent", fake_build_agent)
_run_async(
chat_mod.chat_structured(
base_url="http://localhost:11434/v1",
model="llama3",
messages=_messages(),
schema=_Widget,
timeout_secs=100.0,
max_retries=2,
)
)
assert build_calls == [100.0, 200.0, 300.0]
def test_chat_structured_attempt_info_populated_on_success(monkeypatch):
fake = _FakeAgent([_Widget(name="a", count=1)])
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
attempt_info: dict = {}
_run_async(
chat_mod.chat_structured(
base_url="http://localhost:11434/v1",
model="llama3",
messages=_messages(),
schema=_Widget,
options={"seed": 5},
attempt_info=attempt_info,
)
)
assert attempt_info == {
"seed": 5,
"attempts": 1,
"timeout_secs": 300.0,
"refusals": 0,
}
def test_chat_structured_attempt_info_populated_on_exhaustion(monkeypatch):
bad = ValidationError.from_exception_data("Widget", [])
fake = _FakeAgent([bad, bad])
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
attempt_info: dict = {}
with pytest.raises(RuntimeError):
_run_async(
chat_mod.chat_structured(
base_url="http://localhost:11434/v1",
model="llama3",
messages=_messages(),
schema=_Widget,
max_retries=1,
attempt_info=attempt_info,
)
)
assert attempt_info["attempts"] == 2
def test_chat_structured_on_status_called_on_retry_and_recovery(monkeypatch):
bad = ValidationError.from_exception_data("Widget", [])
fake = _FakeAgent([bad, _Widget(name="b", count=2)])
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
statuses = []
_run_async(
chat_mod.chat_structured(
base_url="http://localhost:11434/v1",
model="llama3",
messages=_messages(),
schema=_Widget,
max_retries=2,
on_status=statuses.append,
)
)
assert len(statuses) == 2
assert "Structured output failed" in statuses[0]
assert "attempt 1/3" in statuses[0]
assert "Recovered" in statuses[1]
assert "attempt 2/3" in statuses[1]
def test_chat_structured_exhausted_retries_raises_runtime_error(monkeypatch):
bad = ValidationError.from_exception_data("Widget", [])
fake = _FakeAgent([bad, bad, bad]) # max_retries=2 -> 3 total attempts
@@ -175,6 +317,53 @@ def test_chat_structured_no_options_means_no_model_settings(monkeypatch):
assert fake.calls[0][2] is None
def test_chat_structured_disable_thinking_sets_chat_template_kwargs(monkeypatch):
"""ADR-010: llama-server's two documented reasoning toggles, applied via
extra_body the same way options is — "think" must not leak into the
nested options.extra_body.options object llama-server's native sampling
params live in."""
fake = _FakeAgent([_Widget(name="a", count=1)])
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
_run_async(
chat_mod.chat_structured(
base_url="http://localhost:8080/v1",
model="gemma-3-4b",
messages=_messages(),
schema=_Widget,
options={"temperature": 0.0, "think": False},
)
)
assert fake.calls[0][2] == {
"extra_body": {
"options": {"temperature": 0.0},
"chat_template_kwargs": {"enable_thinking": False},
"reasoning_effort": "none",
}
}
def test_chat_structured_enable_thinking_skips_reasoning_effort(monkeypatch):
fake = _FakeAgent([_Widget(name="a", count=1)])
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
_run_async(
chat_mod.chat_structured(
base_url="http://localhost:8080/v1",
model="gemma-3-4b",
messages=_messages(),
schema=_Widget,
options={"think": True},
)
)
extra_body = fake.calls[0][2]["extra_body"]
assert extra_body["chat_template_kwargs"] == {"enable_thinking": True}
assert "reasoning_effort" not in extra_body
assert "options" not in extra_body # only "think" was in options
def test_chat_structured_requires_last_message_user_role():
with pytest.raises(ValueError, match="role='user'"):
_run_async(
@@ -187,6 +376,235 @@ def test_chat_structured_requires_last_message_user_role():
)
# ---------------------------------------------------------------------------
# retry-on-failure seed/backoff — live-verified: a freshly-loaded model's
# first structured-output attempt can fail outright, then behave normally on
# the very next call.
# ---------------------------------------------------------------------------
def test_chat_structured_retry_injects_incrementing_seed(monkeypatch):
bad = ValidationError.from_exception_data("Widget", [])
fake = _FakeAgent([bad, bad, _Widget(name="c", count=3)])
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
_run_async(
chat_mod.chat_structured(
base_url="http://localhost:11434/v1",
model="llama3",
messages=_messages(),
schema=_Widget,
max_retries=2,
)
)
assert fake.calls[0][2] is None # attempt 1 untouched — no options set
assert fake.calls[1][2]["seed"] == 1
assert fake.calls[2][2]["seed"] == 2
def test_chat_structured_retry_seed_starts_from_pinned_base(monkeypatch):
bad = ValidationError.from_exception_data("Widget", [])
fake = _FakeAgent([bad, _Widget(name="c", count=3)])
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
_run_async(
chat_mod.chat_structured(
base_url="http://localhost:11434/v1",
model="llama3",
messages=_messages(),
schema=_Widget,
options={"seed": 42},
max_retries=2,
)
)
assert fake.calls[0][2] == {"extra_body": {"options": {"seed": 42}}}
assert fake.calls[1][2]["seed"] == 43 # base(42) + (attempt 2 - 1)
# The nested Ollama-native options.seed must track the same retry seed as
# the top-level one — a backend that honors the nested field over the
# top-level OpenAI "seed" must not keep seeing the stale pinned value.
assert fake.calls[1][2]["extra_body"] == {"options": {"seed": 43}}
def test_chat_structured_retry_does_not_mutate_callers_options_dict(monkeypatch):
"""Regression guard for the fix above: syncing the nested seed must copy,
not mutate, the caller's options dict — otherwise a second call reusing
the same options object would start from the wrong base seed."""
bad = ValidationError.from_exception_data("Widget", [])
fake = _FakeAgent([bad, _Widget(name="c", count=3)])
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
caller_options = {"seed": 42}
_run_async(
chat_mod.chat_structured(
base_url="http://localhost:11434/v1",
model="llama3",
messages=_messages(),
schema=_Widget,
options=caller_options,
max_retries=2,
)
)
assert caller_options == {"seed": 42}
def test_chat_structured_retry_sleeps_between_attempts(monkeypatch):
sleep_calls = []
async def fake_sleep(secs):
sleep_calls.append(secs)
monkeypatch.setattr(chat_mod.asyncio, "sleep", fake_sleep)
bad = ValidationError.from_exception_data("Widget", [])
fake = _FakeAgent([bad, _Widget(name="c", count=3)])
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
_run_async(
chat_mod.chat_structured(
base_url="http://localhost:11434/v1",
model="llama3",
messages=_messages(),
schema=_Widget,
max_retries=2,
)
)
assert sleep_calls == [chat_mod.RETRY_BACKOFF_SECS]
# ---------------------------------------------------------------------------
# refusal-retry — parity with test_ollama_provider.py's coverage (this
# module serves LlamaCppProvider.chat_structured() — see
# comfydv._llm.retry.is_refusal and OllamaOptionRefusalRetry).
# ---------------------------------------------------------------------------
def test_chat_structured_retries_on_refusal_and_returns_clean_second_attempt(
monkeypatch,
):
refused = _Widget(name="I cannot generate that content.", count=1)
clean = _Widget(name="clean", count=2)
fake = _FakeAgent([refused, clean])
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
result = _run_async(
chat_mod.chat_structured(
base_url="http://localhost:11434/v1",
model="llama3",
messages=_messages(),
schema=_Widget,
options={"refusal_retry": {"enabled": True, "embedding_model": ""}},
max_retries=2,
)
)
assert result == clean
assert len(fake.calls) == 2
def test_chat_structured_on_status_reports_refusal_reason(monkeypatch):
refused = _Widget(name="I cannot generate that content.", count=1)
clean = _Widget(name="clean", count=2)
fake = _FakeAgent([refused, clean])
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
statuses = []
_run_async(
chat_mod.chat_structured(
base_url="http://localhost:11434/v1",
model="llama3",
messages=_messages(),
schema=_Widget,
options={"refusal_retry": {"enabled": True, "embedding_model": ""}},
max_retries=2,
on_status=statuses.append,
)
)
assert len(statuses) == 2
assert "Refusal/deflection detected" in statuses[0]
assert "Recovered" in statuses[1]
def test_chat_structured_refusal_retry_disabled_returns_refusal_unchanged(
monkeypatch,
):
refused = _Widget(name="I cannot generate that content.", count=1)
fake = _FakeAgent([refused])
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
result = _run_async(
chat_mod.chat_structured(
base_url="http://localhost:11434/v1",
model="llama3",
messages=_messages(),
schema=_Widget,
)
)
assert result == refused
assert len(fake.calls) == 1
def test_chat_structured_refusal_retry_exhausted_raises(monkeypatch):
refused = _Widget(name="I cannot help with this.", count=1)
fake = _FakeAgent([refused, refused])
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
with pytest.raises(RuntimeError, match="failed validation"):
_run_async(
chat_mod.chat_structured(
base_url="http://localhost:11434/v1",
model="llama3",
messages=_messages(),
schema=_Widget,
options={"refusal_retry": {"enabled": True, "embedding_model": ""}},
max_retries=1,
)
)
def test_chat_structured_refusal_retry_uses_embed_fn(monkeypatch):
from comfydv._llm.retry import REFUSAL_EXEMPLARS
refused = _Widget(name="not today, sorry", count=1)
clean = _Widget(name="a clean value", count=2)
fake = _FakeAgent([refused, clean])
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
embed_calls = []
async def fake_embed(text):
embed_calls.append(text)
if "not today" in text or text in REFUSAL_EXEMPLARS:
return [1.0, 0.0]
return [0.0, 1.0]
result = _run_async(
chat_mod.chat_structured(
base_url="http://localhost:11434/v1",
model="llama3",
messages=_messages(),
schema=_Widget,
options={
"refusal_retry": {
"enabled": True,
"embedding_model": "nomic-embed-text",
"threshold": 0.5,
}
},
max_retries=2,
embed_fn=fake_embed,
)
)
assert result == clean
assert embed_calls # embedding path was actually exercised
def test_history_to_messages_preserves_order_and_roles():
from pydantic_ai.messages import ModelRequest, ModelResponse
@@ -203,3 +621,84 @@ def test_history_to_messages_preserves_order_and_roles():
assert isinstance(history[0], ModelRequest) # system
assert isinstance(history[1], ModelRequest) # user
assert isinstance(history[2], ModelResponse) # assistant
# ---------------------------------------------------------------------------
# Image input (spec 009, US3; features/us3_structured_image.feature)
# ---------------------------------------------------------------------------
def test_chat_structured_attaches_image_to_user_prompt(monkeypatch):
"""The current turn's image rides on Agent.run()'s user_prompt as a
pydantic-ai BinaryContent (ADR-008, research.md Decision 1)."""
import base64
from pydantic_ai.messages import BinaryContent
fake = _FakeAgent([_Widget(name="sq", count=1)])
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
b64 = base64.b64encode(b"PNGDATA").decode()
_run_async(
chat_mod.chat_structured(
base_url="http://x/v1",
model="m",
schema=_Widget,
messages=[Message(role="user", content="describe", images=[b64])],
)
)
prompt = fake.calls[0][0]
assert isinstance(prompt, list)
assert prompt[0] == "describe"
assert isinstance(prompt[1], BinaryContent)
assert prompt[1].data == b"PNGDATA"
assert prompt[1].media_type == "image/png"
def test_chat_structured_text_only_prompt_is_plain_string(monkeypatch):
"""FR-003: an image-less structured call is unchanged — plain-string
user_prompt, exactly as before spec 009."""
fake = _FakeAgent([_Widget(name="a", count=1)])
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
_run_async(
chat_mod.chat_structured(
base_url="http://x/v1",
model="m",
schema=_Widget,
messages=[Message(role="user", content="hi")],
)
)
assert fake.calls[0][0] == "hi"
def test_chat_structured_attaches_image_to_history_user_turn(monkeypatch):
"""A prior user turn that carried an image keeps it in message_history."""
import base64
from pydantic_ai.messages import BinaryContent, UserPromptPart
fake = _FakeAgent([_Widget(name="a", count=1)])
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
b64 = base64.b64encode(b"IMG").decode()
msgs = [
Message(role="user", content="earlier", images=[b64]),
Message(role="assistant", content="ok"),
Message(role="user", content="now"),
]
_run_async(
chat_mod.chat_structured(
base_url="http://x/v1", model="m", schema=_Widget, messages=msgs
)
)
history = fake.calls[0][1]
part = history[0].parts[0]
assert isinstance(part, UserPromptPart)
assert isinstance(part.content, list)
assert part.content[0] == "earlier"
assert isinstance(part.content[1], BinaryContent)
assert part.content[1].data == b"IMG"
+31
View File
@@ -41,3 +41,34 @@ def test_message_roles():
Message(role="system", content="be terse")
Message(role="user", content="hi")
Message(role="assistant", content="hello")
# --- US1 foundational: Message.images carrier (spec 009, contract T1) ---
def test_message_images_defaults_to_none():
"""A text-only turn carries no images."""
msg = Message(role="user", content="hi")
assert msg.images is None
def test_message_images_round_trips_base64_list():
msg = Message(role="user", content="describe", images=["aGVsbG8=", "d29ybGQ="])
assert msg.images == ["aGVsbG8=", "d29ybGQ="]
def test_message_text_only_dump_omits_images_key():
"""FR-003/SC-004: an image-less message must serialize byte-identically to
today — no stray ``images`` key in the transport payload."""
msg = Message(role="user", content="hi")
assert msg.model_dump(exclude_none=True) == {"role": "user", "content": "hi"}
def test_message_with_images_dump_includes_images_key():
msg = Message(role="user", content="describe", images=["aGVsbG8="])
dumped = msg.model_dump(exclude_none=True)
assert dumped == {
"role": "user",
"content": "describe",
"images": ["aGVsbG8="],
}
+426
View File
@@ -0,0 +1,426 @@
"""Tests for comfydv._llm.retry — shared retry-on-blank-output helpers used
by both providers' chat() and the shared chat_structured() helper, plus the
refusal/deflection detector that rides the same retry-with-a-new-seed
mechanism.
Refusal-detection tests here are pure-logic only — no provider/HTTP
involved. See test_ollama_provider.py, test_llamacpp_provider.py, and
test_llm_chat_structured.py for the retry-loop integration (does a detected
refusal actually trigger a reseeded retry).
"""
import pytest
from comfydv._llm.ollama_provider import _run_async
from comfydv._llm.retry import (
REFUSAL_EXEMPLARS,
cosine_similarity,
format_recovered_status,
format_retry_status,
is_ambiguous,
is_lexical_refusal,
is_refusal,
next_seed,
next_timeout_secs,
record_attempt_info,
)
def test_next_seed_attempt_one_is_zero_by_default():
assert next_seed(None, 1) == 0
def test_next_seed_increments_from_zero_when_unset():
assert next_seed(None, 2) == 1
assert next_seed({}, 3) == 2
def test_next_seed_starts_from_pinned_base():
assert next_seed({"seed": 42}, 1) == 42
assert next_seed({"seed": 42}, 2) == 43
assert next_seed({"seed": 42}, 3) == 44
def test_next_seed_ignores_non_int_seed():
assert next_seed({"seed": "not-an-int"}, 2) == 1
class TestNextTimeoutSecs:
def test_attempt_one_returns_base_timeout_unchanged(self):
assert next_timeout_secs(300.0, 1) == 300.0
def test_escalates_multiplicatively_per_attempt(self):
assert next_timeout_secs(300.0, 2) == 600.0
assert next_timeout_secs(300.0, 3) == 900.0
class TestRecordAttemptInfo:
def test_none_attempt_info_is_a_no_op(self):
# Must not raise — callers that don't care about this metadata pass
# None and should see no behavior change at all.
record_attempt_info(None, seed=1, attempts=2, timeout_secs=600.0, refusals=1)
def test_populates_dict_in_place(self):
info: dict = {}
record_attempt_info(info, seed=7, attempts=3, timeout_secs=900.0, refusals=2)
assert info == {
"seed": 7,
"attempts": 3,
"timeout_secs": 900.0,
"refusals": 2,
}
def test_overwrites_previous_values(self):
# Callers call this once per attempt (or once per return path) —
# a later call must replace, not merge with, an earlier one.
info: dict = {"seed": 1, "attempts": 1, "timeout_secs": 300.0, "refusals": 0}
record_attempt_info(info, seed=2, attempts=2, timeout_secs=600.0, refusals=1)
assert info == {
"seed": 2,
"attempts": 2,
"timeout_secs": 600.0,
"refusals": 1,
}
class TestFormatStatus:
def test_retry_status_includes_reason_attempt_seed_and_timeout(self):
msg = format_retry_status("Blank response", 1, 3, seed=1, timeout_secs=200.0)
assert "Blank response" in msg
assert "attempt 1/3" in msg
assert "seed=1" in msg
assert "timeout=200s" in msg
def test_recovered_status_includes_attempt_and_seed(self):
msg = format_recovered_status(2, 3, seed=1)
assert "attempt 2/3" in msg
assert "seed=1" in msg
# ---------------------------------------------------------------------------
# Refusal/deflection detection
# ---------------------------------------------------------------------------
class TestLexicalDetection:
def test_blatant_refusal_openers_match(self):
blatant = [
"I cannot generate that image for you.",
"I'm not able to help with this request.",
"I am unable to comply with your instruction.",
"Cannot generate this image due to the sensitive content.",
'{"error": "cannot generate this content"}',
"I don't feel comfortable creating this content.",
"I'm sorry, but I can't produce that description.",
"As an AI, I'm unable to depict this scenario.",
"I must decline this request.",
"This request violates my usage policies.",
"That's not something I'm able to help with right now.",
]
for text in blatant:
assert is_lexical_refusal(text), f"expected refusal match: {text!r}"
def test_ordinary_content_does_not_match(self):
ordinary = [
"The subject turns to face the camera and smiles warmly.",
"A person cannot simply walk into Mordor, the guide joked.",
"I can help you plan a birthday party for your dog.",
"",
]
for text in ordinary:
assert not is_lexical_refusal(text), f"unexpected match: {text!r}"
class TestAmbiguityHeuristic:
def test_short_response_is_ambiguous(self):
assert is_ambiguous("Sorry, can't do that one.")
def test_long_response_without_hedge_keywords_is_not_ambiguous(self):
long_text = "The subject rotates smoothly toward the lens. " * 20
assert len(long_text) >= 400
assert not is_ambiguous(long_text)
def test_long_response_with_hedge_keyword_is_ambiguous(self):
long_text = "Unfortunately, " + "this touches on a sensitive area. " * 20
assert len(long_text) >= 400
assert is_ambiguous(long_text)
def test_blank_text_is_not_ambiguous(self):
assert not is_ambiguous(" ")
class TestCosineSimilarity:
def test_identical_vectors_score_one(self):
assert cosine_similarity([1.0, 0.0], [1.0, 0.0]) == pytest.approx(1.0)
def test_orthogonal_vectors_score_zero(self):
assert cosine_similarity([1.0, 0.0], [0.0, 1.0]) == pytest.approx(0.0)
def test_opposite_vectors_score_negative_one(self):
assert cosine_similarity([1.0, 0.0], [-1.0, 0.0]) == pytest.approx(-1.0)
def test_mismatched_lengths_return_zero(self):
assert cosine_similarity([1.0, 0.0], [1.0, 0.0, 0.0]) == 0.0
def test_empty_vectors_return_zero(self):
assert cosine_similarity([], []) == 0.0
class TestIsRefusalHybrid:
def test_blank_text_is_never_a_refusal(self):
assert not _run_async(is_refusal(""))
assert not _run_async(is_refusal(" "))
def test_lexical_match_short_circuits_without_embedding(self):
calls = []
async def embed_fn(text):
calls.append(text)
return [1.0, 0.0]
result = _run_async(
is_refusal("I cannot generate that image for you.", embed_fn=embed_fn)
)
assert result is True
assert calls == [] # never reached the embedding step
def test_long_clean_response_is_still_embedding_checked_but_not_a_refusal(self):
# embed_fn present means the caller opted in to the embedding check
# regardless of length/hedge-keywords (is_ambiguous no longer gates
# this) — a long, on-topic response should still be embedding-
# checked, it just shouldn't score as similar to the refusal
# exemplars.
calls = []
async def embed_fn(text):
calls.append(text)
if text == long_text:
return [0.0, 1.0] # orthogonal to the exemplar vector below
return [1.0, 0.0] # exemplars
long_text = "The subject rotates smoothly toward the lens. " * 20
result = _run_async(is_refusal(long_text, embed_fn=embed_fn))
assert result is False
assert long_text in calls # embedding check DID run, just scored low
def test_no_embed_fn_degrades_to_lexical_only(self):
# Ambiguous (short), no lexical match, no embed_fn -> can't check further
assert not _run_async(is_refusal("Not today, sorry.", embed_fn=None))
def test_ambiguous_response_above_threshold_is_refusal(self):
async def embed_fn(text):
# Exemplars and a near-identical short "refusal-ish" probe get a
# high similarity score; distinguish by a marker substring.
if "PROBE" in text:
return [1.0, 0.0]
return [0.99, 0.14] # cos-sim with [1,0] is ~0.99
result = _run_async(
is_refusal(
"PROBE: not comfortable with this one",
embed_fn=embed_fn,
embed_cache_key="test-model",
threshold=0.8,
)
)
assert result is True
def test_ambiguous_response_below_threshold_is_not_refusal(self):
async def embed_fn(text):
if "PROBE" in text:
return [1.0, 0.0]
return [0.0, 1.0] # orthogonal -> cos-sim 0.0
result = _run_async(
is_refusal(
"PROBE: a short reply",
embed_fn=embed_fn,
embed_cache_key="test-model-2",
threshold=0.8,
)
)
assert result is False
def test_long_json_shaped_soft_refusal_without_hedge_keywords_is_caught(self):
# Regression case: a structured-output-shaped response (>600 chars
# once you count JSON braces/field names) whose deflection doesn't
# use any of the canned hedge keywords used to be invisible to the
# embedding check entirely, because is_ambiguous gated on length
# and keywords. embed_fn now runs unconditionally once configured.
soft_refusal = (
'{"prompt": "'
+ "Let's take this in a different creative direction that everyone can enjoy. "
* 8
+ '"}'
)
assert len(soft_refusal) >= 600
assert not is_lexical_refusal(soft_refusal)
async def embed_fn(text):
if text == soft_refusal:
return [1.0, 0.0]
return [0.97, 0.24] # exemplars: cos-sim with [1,0] is ~0.97
result = _run_async(
is_refusal(
soft_refusal,
embed_fn=embed_fn,
embed_cache_key="regression-model",
threshold=0.8,
)
)
assert result is True
def test_exemplar_embeddings_cached_across_calls(self):
exemplar_calls = {"n": 0}
async def embed_fn(text):
if "PROBE" in text:
return [1.0, 0.0]
exemplar_calls["n"] += 1
return [1.0, 0.0]
_run_async(
is_refusal(
"PROBE: first ambiguous call",
embed_fn=embed_fn,
embed_cache_key="cache-key-shared",
threshold=0.5,
)
)
first_count = exemplar_calls["n"]
assert first_count > 0
_run_async(
is_refusal(
"PROBE: second ambiguous call",
embed_fn=embed_fn,
embed_cache_key="cache-key-shared",
threshold=0.5,
)
)
# Exemplar embeddings reused from cache -> no additional exemplar calls
assert exemplar_calls["n"] == first_count
def test_embed_fn_failure_degrades_to_not_refused(self):
async def failing_embed_fn(text):
raise RuntimeError("server unreachable")
result = _run_async(
is_refusal(
"Not comfortable with this one, sorry.",
embed_fn=failing_embed_fn,
embed_cache_key="unreachable-model",
)
)
assert result is False
def test_embed_fn_returning_none_degrades_to_not_refused(self):
async def none_embed_fn(text):
return None
result = _run_async(
is_refusal(
"Not comfortable with this one, sorry.",
embed_fn=none_embed_fn,
embed_cache_key="no-embeddings-model",
)
)
assert result is False
class TestCustomPhrases:
def test_custom_phrase_substring_match_needs_no_embed_fn(self):
# A phrase the user added at runtime that the shipped lexical
# patterns don't cover — should be caught for free, no embedding
# model required.
result = _run_async(
is_refusal(
"I am restricted from producing that kind of content.",
custom_phrases=("restricted from",),
)
)
assert result is True
def test_custom_phrase_match_is_case_insensitive(self):
result = _run_async(
is_refusal(
"SORRY, THAT'S OFF LIMITS FOR ME.",
custom_phrases=("off limits",),
)
)
assert result is True
def test_unrelated_custom_phrase_does_not_match(self):
result = _run_async(
is_refusal(
"The subject walks calmly toward the horizon.",
custom_phrases=("restricted from", "off limits"),
)
)
assert result is False
def test_blank_and_whitespace_custom_phrases_are_ignored(self):
# A stray empty entry must never become a universal substring match.
result = _run_async(
is_refusal(
"The subject walks calmly toward the horizon.",
custom_phrases=("", " "),
)
)
assert result is False
def test_custom_phrase_folded_into_embedding_exemplars(self):
# No exact substring match, but embed_fn scores the response as
# similar to the custom phrase (not one of the shipped exemplars).
custom = "my creators have limited what I can show you"
async def embed_fn(text):
if text == custom:
return [1.0, 0.0]
if text in REFUSAL_EXEMPLARS:
return [0.0, 1.0] # shipped exemplars score orthogonal
return [0.99, 0.14] # the probe response is near the custom one
result = _run_async(
is_refusal(
"There are limits my creators placed on what I can show.",
embed_fn=embed_fn,
embed_cache_key="custom-exemplar-model",
threshold=0.8,
custom_phrases=(custom,),
)
)
assert result is True
def test_different_custom_phrase_sets_do_not_share_exemplar_cache(self):
# Regression guard: if the exemplar cache key ignored custom_phrases,
# a second call with a different custom phrase set would incorrectly
# reuse the first call's cached (and now stale) exemplar vectors.
calls = []
async def embed_fn(text):
calls.append(text)
return [1.0, 0.0]
_run_async(
is_refusal(
"short reply",
embed_fn=embed_fn,
embed_cache_key="shared-model",
custom_phrases=("phrase one",),
)
)
first_call_count = len(calls)
_run_async(
is_refusal(
"short reply",
embed_fn=embed_fn,
embed_cache_key="shared-model",
custom_phrases=("phrase two",),
)
)
# A fresh custom phrase set re-embeds the exemplars (including the
# new phrase) rather than reusing the first set's cached vectors.
assert len(calls) > first_call_count
+340 -45
View File
@@ -47,8 +47,10 @@ from comfydv.ollama import (
OllamaHeaderBearerToken,
OllamaHeaderCustom,
OllamaHistoryLength,
OllamaOptionDisableThinking,
OllamaOptionExtraBody,
OllamaOptionMaxTokens,
OllamaOptionRefusalRetry,
OllamaOptionRepeatPenalty,
OllamaOptionSeed,
OllamaOptionTemperature,
@@ -89,12 +91,38 @@ class _FakeProvider:
async def unload_model(self, model):
self.calls.append(("unload_model", model))
async def chat(self, model, messages, options=None, timeout_secs=300.0):
self.calls.append(("chat", model, messages, options, timeout_secs))
async def chat(
self,
model,
messages,
options=None,
timeout_secs=300.0,
max_retries=2,
attempt_info=None,
on_status=None,
):
self.calls.append(("chat", model, messages, options, timeout_secs, max_retries))
if attempt_info is not None:
attempt_info.update(
{
"seed": (options or {}).get("seed", 0),
"attempts": 1,
"timeout_secs": timeout_secs,
"refusals": 0,
}
)
return self.chat_response
async def chat_structured(
self, model, messages, schema, options=None, timeout_secs=300.0, max_retries=2
self,
model,
messages,
schema,
options=None,
timeout_secs=300.0,
max_retries=2,
attempt_info=None,
on_status=None,
):
self.calls.append(
(
@@ -109,6 +137,15 @@ class _FakeProvider:
)
if self.raise_on_chat_structured is not None:
raise self.raise_on_chat_structured
if attempt_info is not None:
attempt_info.update(
{
"seed": (options or {}).get("seed", 0),
"attempts": 1,
"timeout_secs": timeout_secs,
"refusals": 0,
}
)
return schema(**self.structured_field_values)
@@ -268,7 +305,7 @@ class TestUS4ChatCompletion:
def test_chat_model_receives_wired_string(self):
"""Wiring LLMLoadModel.model_name → ChatCompletion.model works."""
fake = _FakeProvider(chat_response="ok")
_, _, used_model = ChatCompletion().chat(
_, _, used_model, _ = ChatCompletion().chat(
client=fake, model="llama3:latest", prompt="hi"
)["result"]
assert used_model == "llama3:latest"
@@ -276,11 +313,17 @@ class TestUS4ChatCompletion:
assert fake.calls[0][1] == "llama3:latest"
def test_chat_completion_returns_model_name(self):
assert ChatCompletion.RETURN_TYPES == ("STRING", "OLLAMA_HISTORY", "STRING")
assert ChatCompletion.RETURN_TYPES == (
"STRING",
"OLLAMA_HISTORY",
"STRING",
"INT",
)
assert ChatCompletion.RETURN_NAMES == (
"response",
"updated_history",
"model_name",
"seed_used",
)
def test_chat_is_output_node(self):
@@ -298,15 +341,24 @@ class TestUS4ChatCompletion:
ret = ChatCompletion().chat(client=fake, model="m", prompt="hi")
assert "hello world" in ret["ui"]["text"][0]
def test_chat_result_is_3_tuple(self):
def test_chat_result_is_4_tuple(self):
fake = _FakeProvider(chat_response="hello")
ret = ChatCompletion().chat(client=fake, model="m", prompt="hi")
assert isinstance(ret["result"], tuple)
assert len(ret["result"]) == 3
response, history, model_name = ret["result"]
assert len(ret["result"]) == 4
response, history, model_name, seed_used = ret["result"]
assert response == "hello"
assert isinstance(history, list)
assert model_name == "m"
assert seed_used == 0
def test_chat_result_seed_used_reflects_pinned_seed(self):
fake = _FakeProvider(chat_response="hello")
ret = ChatCompletion().chat(
client=fake, model="m", prompt="hi", options={"seed": 42}
)
_, _, _, seed_used = ret["result"]
assert seed_used == 42
def test_chat_has_timeout_secs_input(self):
input_types = ChatCompletion.INPUT_TYPES()
@@ -333,7 +385,7 @@ class TestUS4ChatCompletion:
):
"""Scenario: Single-turn completion returns non-empty response."""
(client,) = OllamaClient().create_client(ollama_host)
response, updated_history, model_name = ChatCompletion().chat(
response, updated_history, model_name, _seed = ChatCompletion().chat(
client=client,
model=_CHAT_MODEL,
prompt="Say exactly the word: pong",
@@ -355,14 +407,14 @@ class TestUS4ChatCompletion:
"""
(client,) = OllamaClient().create_client(ollama_host)
no_think = {"think": False}
_, history, _ = ChatCompletion().chat(
_, history, _, _ = ChatCompletion().chat(
client=client,
model=_CHAT_MODEL,
prompt="My name is Alice. Remember it.",
history=[],
options=no_think,
)["result"]
response, updated, _ = ChatCompletion().chat(
response, updated, _, _ = ChatCompletion().chat(
client=client,
model=_CHAT_MODEL,
prompt="What is my name?",
@@ -376,47 +428,58 @@ class TestUS4ChatCompletion:
def test_history_accumulated_correctly(self, ollama_host, skip_if_no_ollama):
"""History list grows by 2 entries per turn."""
(client,) = OllamaClient().create_client(ollama_host)
_, h1, _ = ChatCompletion().chat(
_, h1, _, _ = ChatCompletion().chat(
client=client, model=_CHAT_MODEL, prompt="Turn 1", history=[]
)["result"]
assert len(h1) == 2
_, h2, _ = ChatCompletion().chat(
_, h2, _, _ = ChatCompletion().chat(
client=client, model=_CHAT_MODEL, prompt="Turn 2", history=h1
)["result"]
assert len(h2) == 4
@pytest.mark.integration
def test_structured_output_retries_then_raises_against_live_server(
def test_structured_output_against_unreliable_model_stays_schema_valid(
self, ollama_host, skip_if_no_ollama
):
"""Scenario: structured_output's retry-then-raise fallback against a
real server, using an unreliable model that empirically cannot be
made to call the forced tool consistently.
"""Scenario: structured_output against a real server, using a model
with a known-degenerate chat template/tokenizer (ADR-006) that
empirically cannot be made to call a forced tool consistently.
This intentionally does NOT assert a happy-path clean result — see
the original ADR-006 rationale. What IS worth proving against a live
server: the shared pydantic-ai mechanism (comfydv._llm.chat) makes
genuine repeated network calls and produces a well-formed,
diagnostic error rather than hanging, crashing uninformatively, or
silently returning bad data. The happy path is covered by
tests/test_llm_chat_structured.py's mocked suite.
ADR-006 originally hand-rolled tool-calling specifically because this
model silently ignored Ollama's native `format` field. ADR-009
switched the shared pydantic-ai mechanism to NativeOutput (the same
native `format`/`response_format` constrained decoding ADR-006
avoided) — re-verified live: this model's *output* is still
degenerate (garbled tokens, not the literal requested string), but
NativeOutput's schema-constrained decoding now forces it into valid
JSON shape regardless, so it no longer exhausts retries or hangs.
What's worth proving against a live server: the shared mechanism
(comfydv._llm.chat) makes a genuine network call and returns
well-formed, schema-valid data rather than hanging, crashing
uninformatively, or exhausting retries on a shape it can never
satisfy. The happy-path *and* retry-exhaustion mechanics are covered
deterministically by tests/test_llm_chat_structured.py's mocked
suite — this test only proves the live wiring holds up against a
genuinely unreliable model's *content*, not its shape.
"""
(client,) = OllamaClient().create_client(ollama_host)
with pytest.raises(RuntimeError) as exc_info:
ChatCompletion().chat(
client=client,
model=_CHAT_MODEL,
prompt="Say exactly: pong",
options={"think": False},
structured_output=True,
output_schema=(
'{"type":"object","properties":{"output":{"type":"string"}},'
'"required":["output"]}'
),
max_retries=1,
unique_id="smoke-test",
)
assert _CHAT_MODEL in str(exc_info.value)
result = ChatCompletion().chat(
client=client,
model=_CHAT_MODEL,
prompt="Say exactly: pong",
options={"think": False},
structured_output=True,
output_schema=(
'{"type":"object","properties":{"output":{"type":"string"}},'
'"required":["output"]}'
),
max_retries=1,
unique_id="smoke-test",
)
response_text, _history, model_name, output, _seed = result["result"]
assert model_name == _CHAT_MODEL
assert json.loads(response_text) == {"output": output}
assert isinstance(output, str) and output.strip()
# ---------------------------------------------------------------------------
@@ -440,6 +503,12 @@ _MULTI_FIELD_SCHEMA = (
'"is_positive": {"type": "boolean"}}, '
'"required": ["summary", "score", "is_positive"]}'
)
_SCHEMA_WITH_OPTIONAL_NULLABLE_FIELD = (
'{"type": "object", "properties": {'
'"output": {"type": "string"}, '
'"duration_seconds": {"type": "number"}}, '
'"required": ["output"]}'
)
class TestStructuredOutput:
@@ -483,8 +552,9 @@ class TestStructuredOutput:
"updated_history",
"model_name",
"output",
"seed_used",
)
assert len(ret["result"]) == 4
assert len(ret["result"]) == 5
assert ret["result"][3] == "clean text"
assert json.loads(ret["result"][0]) == {"output": "clean text"}
@@ -511,6 +581,7 @@ class TestStructuredOutput:
"STRING",
"INT",
"BOOLEAN",
"INT",
)
assert ChatCompletion.RETURN_NAMES == (
"response",
@@ -519,13 +590,40 @@ class TestStructuredOutput:
"summary",
"score",
"is_positive",
"seed_used",
)
_, _, _, summary, score, is_positive = ret["result"]
_, _, _, summary, score, is_positive, _seed = ret["result"]
assert summary == "great"
assert score == 9
assert isinstance(score, int)
assert is_positive is True
def test_optional_field_accepts_explicit_null(self):
"""Regression guard: a non-required field must accept an *explicit*
null in the model's JSON, not just being omitted entirely. Models
routinely emit `"duration_seconds": null` rather than dropping the
key — `_build_structured_model` typed non-required fields as bare
`py_type` with a `None` default, which only covers omission; an
explicit `None` value failed pydantic's type check (`None` is not a
`float`) until the field was typed `py_type | None` instead. Found
live: this passed every mocked test (mocks never validate against
the real model) but broke the actual LTX pipeline the first time a
real model returned an explicit null for an optional field."""
fake = _FakeProvider(
structured_field_values={"output": "hi", "duration_seconds": None}
)
ret = ChatCompletion().chat(
client=fake,
model="m",
prompt="hi",
structured_output=True,
output_schema=_SCHEMA_WITH_OPTIONAL_NULLABLE_FIELD,
unique_id="n3b",
)
_, _, _, output, duration_seconds, _seed = ret["result"]
assert output == "hi"
assert duration_seconds is None # FLOAT socket; only STRING coerces None to ""
def test_array_object_property_json_dumped_into_string_slot(self):
schema = (
'{"type": "object", "properties": {'
@@ -551,7 +649,7 @@ class TestStructuredOutput:
structured_output=True,
unique_id="n5",
)
assert len(ChatCompletion.RETURN_TYPES) == 4
assert len(ChatCompletion.RETURN_TYPES) == 5
fake2 = _FakeProvider(chat_response="x")
ChatCompletion().chat(
@@ -663,6 +761,78 @@ class TestUS5ComposableOptions:
(opts,) = OllamaOptionRepeatPenalty().set_repeat_penalty(repeat_penalty=1.1)
assert opts == {"repeat_penalty": 1.1}
def test_disable_thinking_default_sets_think_false(self):
"""ADR-010: default True (disable thinking) merges think=False —
every LLMProvider.chat()/chat_structured() implementation pops this
key out of options and translates it to its own wire shape."""
(opts,) = OllamaOptionDisableThinking().set_disable_thinking(
disable_thinking=True
)
assert opts == {"think": False}
def test_disable_thinking_toggled_off_sets_think_true(self):
"""Explicitly re-enabling thinking (e.g. to override a server-side
default) is the inverse: disable_thinking=False -> think=True."""
(opts,) = OllamaOptionDisableThinking().set_disable_thinking(
disable_thinking=False
)
assert opts == {"think": True}
def test_disable_thinking_merges_existing_options(self):
(opts,) = OllamaOptionDisableThinking().set_disable_thinking(
disable_thinking=True, options={"temperature": 0.5}
)
assert opts == {"temperature": 0.5, "think": False}
def test_refusal_retry_default_merges_config_dict(self):
(opts,) = OllamaOptionRefusalRetry().set_refusal_retry(
enabled=True, embedding_model="", threshold=0.82
)
assert opts == {
"refusal_retry": {
"enabled": True,
"embedding_model": "",
"threshold": 0.82,
"custom_phrases": (),
}
}
def test_refusal_retry_strips_embedding_model_whitespace(self):
(opts,) = OllamaOptionRefusalRetry().set_refusal_retry(
enabled=True, embedding_model=" nomic-embed-text ", threshold=0.9
)
assert opts["refusal_retry"]["embedding_model"] == "nomic-embed-text"
def test_refusal_retry_merges_existing_options(self):
(opts,) = OllamaOptionRefusalRetry().set_refusal_retry(
enabled=False,
embedding_model="",
threshold=0.82,
options={"temperature": 0.5},
)
assert opts["temperature"] == 0.5
assert opts["refusal_retry"]["enabled"] is False
def test_refusal_retry_parses_custom_phrases_csv(self):
# Whitespace around each phrase is trimmed and empty entries (from
# blank items / trailing commas) are dropped.
(opts,) = OllamaOptionRefusalRetry().set_refusal_retry(
enabled=True,
embedding_model="",
threshold=0.82,
custom_phrases=" I am restricted from , not permitted to help,,",
)
assert opts["refusal_retry"]["custom_phrases"] == (
"I am restricted from",
"not permitted to help",
)
def test_refusal_retry_custom_phrases_defaults_empty(self):
(opts,) = OllamaOptionRefusalRetry().set_refusal_retry(
enabled=True, embedding_model="", threshold=0.82
)
assert opts["refusal_retry"]["custom_phrases"] == ()
def test_extra_body_merges_json(self):
(opts,) = OllamaOptionExtraBody().set_extra_body(
extra_body_json='{"stop": ["</s>"]}', options={"temperature": 0.0}
@@ -699,8 +869,8 @@ class TestUS5ComposableOptions:
history=[],
options=opts2,
)
r1, _, _model = ChatCompletion().chat(**kwargs)["result"]
r2, _, _model = ChatCompletion().chat(**kwargs)["result"]
r1, _, _model, _seed = ChatCompletion().chat(**kwargs)["result"]
r2, _, _model, _seed = ChatCompletion().chat(**kwargs)["result"]
assert r1 == r2
@@ -939,6 +1109,7 @@ class TestUpdateStructuredOutputsRoute:
"response",
"updated_history",
"model_name",
"seed_used",
]
def test_valid_schema_returns_dynamic_outputs(self):
@@ -958,6 +1129,7 @@ class TestUpdateStructuredOutputsRoute:
"summary",
"score",
"is_positive",
"seed_used",
]
assert types == [
"STRING",
@@ -966,6 +1138,7 @@ class TestUpdateStructuredOutputsRoute:
"STRING",
"INT",
"BOOLEAN",
"INT",
]
def test_invalid_json_while_typing_falls_back_to_base_outputs(self):
@@ -983,6 +1156,7 @@ class TestUpdateStructuredOutputsRoute:
"response",
"updated_history",
"model_name",
"seed_used",
]
def test_incomplete_schema_missing_properties_falls_back_to_base_outputs(self):
@@ -997,6 +1171,7 @@ class TestUpdateStructuredOutputsRoute:
"response",
"updated_history",
"model_name",
"seed_used",
]
def test_response_reflects_class_state_not_just_this_call(self):
@@ -1010,7 +1185,8 @@ class TestUpdateStructuredOutputsRoute:
"output_schema": _SINGLE_FIELD_SCHEMA,
}
)
assert ChatCompletion.RETURN_NAMES[-1] == "output"
assert ChatCompletion.RETURN_NAMES[-2] == "output"
assert ChatCompletion.RETURN_NAMES[-1] == "seed_used"
data = self._call(
{"unique_id": "r5", "structured_output": False, "output_schema": "{}"}
@@ -1019,6 +1195,7 @@ class TestUpdateStructuredOutputsRoute:
"response",
"updated_history",
"model_name",
"seed_used",
]
assert ChatCompletion.RETURN_NAMES == ChatCompletion._BASE_RETURN_NAMES
@@ -1087,3 +1264,121 @@ class TestNodeContracts:
types = node_cls.INPUT_TYPES()
assert isinstance(types, dict)
assert "required" in types or "optional" in types
# ---------------------------------------------------------------------------
# US1 (spec 009) — Image input on ChatCompletion
# features/us1_describe_image.feature
# ---------------------------------------------------------------------------
class TestUS1ImageEncode:
"""_encode_image_tensor(): ComfyUI IMAGE tensor -> base64 PNG(s)."""
def test_encode_single_image_returns_decodable_png(self):
import base64
import io
import torch
from PIL import Image
from comfydv.ollama import _encode_image_tensor
# ComfyUI IMAGE: [B, H, W, C] float 0..1
img = torch.zeros(1, 4, 8, 3)
img[0, :, :, 0] = 1.0 # solid red
out = _encode_image_tensor(img)
assert isinstance(out, list)
assert len(out) == 1
pil = Image.open(io.BytesIO(base64.b64decode(out[0])))
assert pil.format == "PNG"
assert pil.size == (8, 4) # PIL size is (W, H)
assert pil.convert("RGB").getpixel((0, 0)) == (255, 0, 0)
def test_encode_batch_returns_one_base64_per_frame(self):
import torch
from comfydv.ollama import _encode_image_tensor
img = torch.zeros(3, 4, 8, 3)
out = _encode_image_tensor(img)
assert len(out) == 3
def test_encode_none_returns_empty_list(self):
from comfydv.ollama import _encode_image_tensor
assert _encode_image_tensor(None) == []
def test_encode_empty_batch_returns_empty_list(self):
import torch
from comfydv.ollama import _encode_image_tensor
assert _encode_image_tensor(torch.zeros(0, 4, 8, 3)) == []
class TestUS1ImageInputNode:
"""ChatCompletion optional IMAGE input attaches to the current user turn."""
def _last_messages(self, fake):
# _FakeProvider records ("chat", model, messages, options, timeout, retries)
chat_calls = [c for c in fake.calls if c[0] == "chat"]
return chat_calls[-1][2]
def test_chat_completion_has_optional_image_input(self):
inputs = ChatCompletion.INPUT_TYPES()
assert "image" in inputs["optional"], (
"ChatCompletion must expose an optional IMAGE input for VLM use"
)
assert inputs["optional"]["image"][0] == "IMAGE"
def test_chat_completion_image_input_is_not_required(self):
inputs = ChatCompletion.INPUT_TYPES()
assert "image" not in inputs.get("required", {})
def test_return_positions_unchanged_by_image_input(self):
# Constitution VI: outputs untouched — only an optional input is added.
assert ChatCompletion.RETURN_TYPES[:2] == ("STRING", "OLLAMA_HISTORY")
assert ChatCompletion.RETURN_NAMES[:2] == ("response", "updated_history")
def test_wired_image_attaches_to_last_user_message(self):
import torch
fake = _FakeProvider(chat_response="a red square")
img = torch.zeros(1, 4, 8, 3)
img[0, :, :, 0] = 1.0
ChatCompletion().chat(client=fake, model="m", prompt="describe", image=img)
messages = self._last_messages(fake)
assert messages[-1].role == "user"
assert messages[-1].content == "describe"
assert messages[-1].images and len(messages[-1].images) == 1
def test_unwired_image_leaves_messages_text_only(self):
fake = _FakeProvider(chat_response="ok")
ChatCompletion().chat(client=fake, model="m", prompt="hi")
messages = self._last_messages(fake)
assert messages[-1].images is None
def test_image_not_added_to_history_turns(self):
import torch
fake = _FakeProvider(chat_response="ok")
img = torch.zeros(1, 2, 2, 3)
history = [
{"role": "user", "content": "earlier q"},
{"role": "assistant", "content": "earlier a"},
]
ChatCompletion().chat(
client=fake, model="m", prompt="now", history=history, image=img
)
messages = self._last_messages(fake)
# Every turn except the final user turn must carry no image (FR-007).
assert all(m.images is None for m in messages[:-1])
assert messages[-1].content == "now"
assert messages[-1].images and len(messages[-1].images) == 1
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -210,8 +210,8 @@ class TestDockerCompose:
"docker/Dockerfile not found — run T041-I to create it"
)
def test_dockerfile_uses_python_311_base(self):
def test_dockerfile_uses_python_313_base(self):
text = DOCKERFILE.read_text()
assert "FROM python:3.11" in text, (
"Dockerfile must use python:3.11 base image (CPU-only, no CUDA)"
assert "FROM python:3.13" in text, (
"Dockerfile must use python:3.13 base image (CPU-only, no CUDA)"
)
+74
View File
@@ -0,0 +1,74 @@
"""Tests for comfydv.random_choice.RandomChoice.
Covers the UI-preview addition (OUTPUT_NODE=True + a "ui": {"text": [...]}
return, mirroring ChatCompletion/FormatString so all three get a visible
text preview via src/js/preview_text.js) without changing IS_CHANGED's
change-detection semantics.
"""
import json
from comfydv.random_choice import RandomChoice, _preview_text
def test_output_node_is_true():
assert RandomChoice.OUTPUT_NODE is True
def test_random_choice_returns_ui_result_dict():
ret = RandomChoice().random_choice(input1="a", seed=42)
assert isinstance(ret, dict)
assert "ui" in ret
assert "result" in ret
assert ret["result"] == ("a",)
def test_ui_text_matches_the_chosen_value_for_a_string():
ret = RandomChoice().random_choice(input1="hello", seed=42)
assert ret["ui"]["text"] == ["hello"]
def test_ui_text_for_a_number_is_stringified():
ret = RandomChoice().random_choice(input1=7, seed=42)
assert ret["ui"]["text"] == ["7"]
assert ret["result"] == (7,)
def test_seed_pins_the_choice_deterministically():
ret1 = RandomChoice().random_choice(input1="a", input2="b", input3="c", seed=42)
ret2 = RandomChoice().random_choice(input1="a", input2="b", input3="c", seed=42)
assert ret1["result"] == ret2["result"]
def test_is_changed_returns_raw_pick_not_ui_wrapped_dict():
"""Regression guard: IS_CHANGED must keep returning the same shape it
did before the ui-preview addition (the raw picked value), not the new
{"ui": ..., "result": ...} dict random_choice() now returns — otherwise
ComfyUI's change-detection comparison would be comparing dicts full of
UI-only text noise instead of the actual output value."""
result = RandomChoice.IS_CHANGED(input1="only-choice", seed=42)
assert result == "only-choice"
class TestPreviewText:
def test_string_passthrough(self):
assert _preview_text("hello") == "hello"
def test_number_stringified(self):
assert _preview_text(42) == "42"
assert _preview_text(3.14) == "3.14"
assert _preview_text(True) == "True"
def test_list_json_dumped(self):
assert json.loads(_preview_text([1, 2, 3])) == [1, 2, 3]
def test_unserializable_falls_back_to_str(self):
class Weird:
def __repr__(self):
return "<Weird thing>"
# json.dumps(default=str) actually succeeds here (falls back to
# str() per-value), so this exercises the "else JSON" branch
# rather than the outer except — confirms it never raises either
# way.
assert "<Weird thing>" in _preview_text(Weird())
Generated
+2
View File
@@ -273,6 +273,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "pillow" },
{ name = "playwright" },
{ name = "pytest" },
{ name = "pytest-cov" },
@@ -301,6 +302,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "pillow", specifier = ">=10.0.0" },
{ name = "playwright", specifier = ">=1.60.0" },
{ name = "pytest", specifier = ">=8.4.2" },
{ name = "pytest-cov", specifier = ">=6.0.0" },
+206
View File
@@ -0,0 +1,206 @@
# LTX-2.3 I2V Multi-Agent Pipeline
`ltx-i2v-pipeline.json` wires the 6-agent prompt-compiler pipeline from
[`project-management/Work/planning/ltx.md`](../project-management/Work/planning/ltx.md)
onto comfydv's existing generic LLM nodes — `OllamaClient`, `ChatCompletion`
(structured output, image input) and `FormatString` (Jinja2 templating). No
new node code was needed; this is a wiring exercise, not a feature.
## Loading it
This is a ComfyUI **API-format** workflow (`{node_id: {class_type, inputs}}`),
not the canvas/UI export format. Recent ComfyUI frontends accept this format
directly via drag-and-drop onto the graph (it auto-lays-out the nodes), or you
can `POST` it straight to `/prompt`. This format was chosen deliberately over
hand-authoring the litegraph UI-export format: the latter requires exact
per-node-type widget-value ordering and link/slot bookkeping that's easy to
get subtly wrong by hand and impossible for me to verify without a live
ComfyUI+Ollama instance. Every template, schema, and link index in this file
*was* verified against the actual node source (see "How this was verified"
below) — only the outer graph-serialization format is unverified against a
real ComfyUI load.
If you'd rather have the literal draggable canvas file, load this one once,
arrange the nodes, and use ComfyUI's own "Save (API Format)" vs. regular
"Save" to produce one — that guarantees a format your ComfyUI build actually
round-trips.
## Prerequisites
- Ollama running locally with a model that has both `vision` and `tools`
capability (check `ollama show <model>`'s `capabilities` list) — every
agent in this pipeline uses `structured_output=True`, and some also need
vision. Default baked into the workflow: `qwen3.5:9b`, used for every
agent (text and vision alike) — edit the `model` field on each
`ChatCompletion` node if you have something else installed.
`lukey03/qwen3.5-9b-abliterated-vision` was tried and rejected: its
chat template is degenerate enough that it returns valid-shaped but
garbled content regardless of structured-output mechanism (see
[ADR-009](../project-management/ADRs/ADR-009-native-structured-output-mode.md)).
- **Thinking is off by default.** Node `18` (`OllamaOptionDisableThinking`,
`disable_thinking=True`) sits at the end of the options chain every
`ChatCompletion` node reads from. Without it, a thinking-capable model
routinely burns its whole `max_tokens` budget on chain-of-thought and
never emits the closing JSON — `chat_structured` then fails validation
against an empty string. Flip node `18`'s `disable_thinking` to `False`
if you deliberately want a model to reason before answering; if you do,
give it real headroom: nodes `16`/`17` set `max_tokens=8192`/`num_ctx=32768`
for exactly that case, and Ollama's default context (4096, with
`--context-shift` silently evicting old context rather than stopping)
is nowhere near enough for these agents' long system prompts on top of
reasoning tokens. Node `17`'s `num_ctx` reaches Ollama correctly because
`OllamaProvider.chat_structured()` calls Ollama's *native* `/api/chat` +
`"format"` directly (ADR-009) — an earlier version of this fix tried
priming context via a separate call before the real request and that
didn't work, because Ollama's OpenAI-compatible endpoint silently
reloads the model at its default context on every call, undoing any
priming; the native endpoint doesn't have that problem and applies
`options` and structured output atomically in one request. Every
`ChatCompletion` node's `timeout_secs=600` for the same headroom reason;
lower it if your hardware is faster than the machine this was tuned
against.
- To use `LlamaCppClient` instead of `OllamaClient`, swap node `1`'s
`class_type` and `host` — every `ChatCompletion` node keeps working
unchanged, since both emit the same `LLM_CLIENT` type (ADR-007). Note
llama-server's context is fixed at process launch (`--ctx-size`), not
a per-request setting — node `17`'s `num_ctx` only affects Ollama.
- Replace node `2`'s `image` filename with your actual starting frame.
## Status: confirmed working end-to-end against a live server
Ran to completion (`status: success`) against a real local Ollama server
(`qwen3.5:9b`), through the actual ComfyUI node graph, all 6 agents plus
the final-output node. Getting here took two real comfydv bugs, both fixed
and documented in [ADR-009](../project-management/ADRs/ADR-009-native-structured-output-mode.md):
1. Ollama's OpenAI-compatible endpoint silently reloads the model at its
default (tiny) context size on every call, discarding any
`options.num_ctx` — fixed by switching `OllamaProvider.chat_structured()`
to Ollama's native `/api/chat` + `"format"`, which doesn't have that
problem.
2. `_build_structured_model` (`src/comfydv/ollama.py`) typed non-required
schema fields as bare `py_type` with a `None` default, which only
covers a field being *omitted* — an explicit `null` in the model's JSON
(which models routinely emit) failed pydantic validation. Fixed by
typing those fields `py_type | None`.
**One remaining quirk, not a wiring bug:** `qwen3.5:9b` sometimes
under-attends to short/simple prompt content — in one full run it reported
Agent 1's `user_intent` as "no content provided" despite the field being
populated, which cascaded into an empty Director prompt, which the Judge
correctly caught (`decision: FAIL`) and the Refiner correctly attempted to
repair. That's the multi-agent design working as intended against a bad
upstream extraction — the fix for *that* is prompt/model tuning on Agent 1,
not a pipeline change. Re-run if you hit it; it isn't consistent.
```bash
# isolated single-agent test — much faster to debug than the full graph
python3 -c "
import json
d = json.load(open('ltx-i2v-pipeline.json'))
subset = {k: d[k] for k in ['1','2','16','17','3','4']} # Agent 1 only
json.dump({'prompt': subset}, open('/tmp/agent1_only.json','w'))
"
curl -X POST http://localhost:8188/prompt -H "Content-Type: application/json" \
--data @/tmp/agent1_only.json
```
## Pipeline shape
```
OllamaClient ─┬─────────────────────────────────────────────────────────┐
LoadImage ────┼──────────┬──────────┬──────────┬──────────┐ │
│ │ │ │ │ │
FormatString→ChatCompletion (Agent 1: Intent Compiler) [no image] │
│ │ │
FormatString→ChatCompletion (Agent 2: Scene Grounder) ←image
│
FormatString→ChatCompletion (Agent 3: Manifest Verifier) ←image
│
intent ─┴─ audited_manifest
FormatString→ChatCompletion (Agent 4: Director) ←image
│
+ intent + manifest ────────┴── candidate_prompt
FormatString→ChatCompletion (Agent 5: Judge) ←image
│
+ everything above ─────────┴── judge_report
FormatString→ChatCompletion (Agent 6: Refiner) [no image]
│
FormatString (Final Output — judge decision + both prompts)
```
## Deliberate adaptations from ltx.md
1. **Single round, no retry loop.** ltx.md's reference pseudocode runs
`for iteration in range(2): judge → refine`, short-circuiting on PASS.
ComfyUI graphs are DAGs with no native conditional/loop node in this repo
(checked `circuit_breaker.py`, `random_choice.py` — neither fits), so a
real retry loop can't be expressed as a static graph. This workflow always
runs Judge once and Refiner once. The **Final Output** node (`15`) shows
the Judge's `decision` next to *both* the Director's candidate prompt and
the Refiner's patched prompt — read the decision and use the candidate
prompt on PASS, the refined prompt on FAIL. Wire a second Judge/Refiner
pair after node `14` yourself if you want the second round.
2. **Structured output carries whole objects, not just fields.**
`ChatCompletion`'s `response` output is the full JSON object
(`parsed.model_dump_json()`), and — since `structured_output=True` also
adds one extra named output per top-level schema property — a specific
nested object can be pulled out directly by name (e.g. Agent 3's
`audited_manifest` output, used instead of its `response` wrapper, which
also contains `verification`). Templates use `{{ x }}` directly rather
than ltx.md's `{{ x | tojson(indent=2) }}`, since `x` arrives already
JSON-encoded.
3. **List-valued template variables are JSON strings.** `FormatString`'s
dynamic inputs are always `STRING`; there's no native list socket. Fields
like `preservation_requirements` or `extraction_hints` are typed as JSON
arrays (e.g. `["keep hairstyle"]`) and unpacked in-template with the
`fromjson` filter your repo's `FormatString` already ships. Leave them as
empty string `""` to omit the section entirely — falsy-string `{% if %}`
checks guard every optional block, so `fromjson` is never called on an
empty value.
4. **`output_schema` is intentionally shallow.** `ChatCompletion` only
enforces *top-level* property types (see `_build_structured_model` in
`src/comfydv/ollama.py`) — it doesn't validate nested structure. The
detailed nested shape each agent must produce (e.g. every field inside
`required_camera`) is still communicated to the model via the literal
JSON example embedded in that agent's system prompt (verbatim from
ltx.md), so nothing is lost — the `output_schema` JSON here just needs to
get the top-level field list and types right, which is also all
`ChatCompletion` uses it for.
5. **Required fields exclude anything legitimately blank.** A `required`
*string* field is forced non-empty by `ChatCompletion`
(`Field(..., min_length=1)`) to catch blank-output failures. Fields that
are correctly empty on a non-nominal status — Director's `prompt` on
`UNSATISFIABLE`, Judge's `refinement_instruction` on `PASS`, Refiner's
`prompt`/`unresolvable_reason` — are deliberately left out of each
schema's `required` list so a legitimate empty string doesn't trigger a
retry loop against the model.
6. **Optional Agent 7 (Targeted Manifest Resolver) is not wired.** It only
fires on a `MANIFEST_CHALLENGE`, which this static graph can't branch on.
Add it manually if the Director or Judge start returning that status for
your inputs.
## How this was verified
Everything except the outer graph-serialization format was checked against
this repo's actual node code (not just read — executed), with mocked
`comfy`/`server`/`folder_paths` modules the way `tests/conftest.py` does:
- Every `[node_id, index]` link target resolves to a real node.
- Every `FormatString` template's variables (via the same
`jinja_env.parse` + `meta.find_undeclared_variables` AST extraction
`_extract_keys` uses) exactly match the inputs supplied in the workflow.
- Every template renders through the real `FormatString.format_string()`
with representative values, including the Director's `SHOT CONSTRAINTS`
block, which was parsed back with `json.loads` to confirm it's valid JSON
even when every optional field is left blank.
- Every `output_schema` parses through the real `_parse_output_schema`/
`_build_structured_model`, and every link that targets a *named* structured
output (e.g. `audited_manifest`, `prompt`, `decision`) was checked against
the actual computed `(response, updated_history, model_name, *properties)`
output order for that schema.
File diff suppressed because one or more lines are too long