Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f16da579bc | ||
|
|
7a40d22230 | ||
|
|
42c614d5f8 | ||
|
|
a0f391f212 | ||
|
|
3c5136af70 | ||
|
|
97249ee281 | ||
|
|
1f138e5991 | ||
|
|
2724f535f5 | ||
|
|
a613006f5a | ||
|
|
56c7d4ca56 | ||
|
|
cf7cfeb6aa | ||
|
|
c9aefef915 | ||
|
|
f060698e7f | ||
|
|
0f4b2234db | ||
|
|
ecba52999d | ||
|
|
24119e3ab6 | ||
|
|
1f30c2ff2f | ||
|
|
ecfc7eca59 | ||
|
|
e534fac121 | ||
|
|
6c3c341913 | ||
|
|
8cb9a74462 | ||
|
|
5e678c2700 | ||
|
|
3854b27cba | ||
|
|
e432054b47 | ||
|
|
09fe013ff9 | ||
|
|
0f73f3c2cc | ||
|
|
24917be802 | ||
|
|
46c14a872d | ||
|
|
0cfe0d22ff | ||
|
|
bc7b33922b | ||
|
|
1028315d37 | ||
|
|
a2f2464fdb | ||
|
|
4a631cc8dc | ||
|
|
fd9054c03f | ||
|
|
23d96f1559 | ||
|
|
9ece5c0efa | ||
|
|
e225dcaaa3 | ||
|
|
1c72e0b2ea | ||
|
|
7e56f2e5bf | ||
|
|
f4a3390f13 | ||
|
|
a40c1ba373 | ||
|
|
eab86b0324 | ||
|
|
45e49a98eb | ||
|
|
87ec79be1b | ||
|
|
1dcef77a2b | ||
|
|
09a173e552 | ||
|
|
92da4fc117 | ||
|
|
1fe3bef663 | ||
|
|
3bd857f0ab | ||
|
|
834ae3e9e3 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"feature_directory": "specs/008-llamacpp-integration"
|
||||
"feature_directory": "specs/009-vlm-image-input"
|
||||
}
|
||||
|
||||
@@ -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,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 -->
|
||||
|
||||
@@ -114,6 +114,15 @@ A complete graph looks like this:
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
+158
@@ -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`
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
@@ -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: "
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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="],
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+1035
-20
File diff suppressed because it is too large
Load Diff
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user