Skip to content

ollama

Ollama model integration nodes for ComfyUI.

Client configuration, auth headers, model discovery, load/unload, chat completion, composable inference options, and history utilities.

ADR-004: aiohttp (already in ComfyUI dep tree) for all Ollama HTTP calls. ADR-005: OllamaClient node is the single source of the host URL (and, per US7, of any auth headers — every downstream node reaches the same server). ADR-007: OllamaClient now emits an OllamaProvider (comfydv._llm), the 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 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.

ChatCompletion

Source code in src/comfydv/ollama.py
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
class ChatCompletion:
    OUTPUT_NODE = True

    # "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.
    node_configs: dict = {}

    @classmethod
    def INPUT_TYPES(s):
        return {
            "required": {
                "client": ("LLM_CLIENT",),
                # Plain STRING so it can receive a wired value from LLMLoadModel
                # (or LLMModelSelector) without needing a separate model_name socket.
                "model": ("STRING", {"default": ""}),
                "prompt": ("STRING", {"multiline": True, "default": ""}),
            },
            "optional": {
                "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": (
                    "STRING",
                    {"multiline": True, "default": _DEFAULT_OUTPUT_SCHEMA},
                ),
                "max_retries": ("INT", {"default": 2, "min": 0, "max": 5}),
            },
            "hidden": {"unique_id": "UNIQUE_ID"},
        }

    RETURN_TYPES = _BASE_RETURN_TYPES
    RETURN_NAMES = _BASE_RETURN_NAMES
    FUNCTION = "chat"
    CATEGORY = "dv/ollama"

    @classmethod
    def update_outputs(
        cls, unique_id: str, structured_output: bool, schema: dict | None
    ) -> None:
        """Mutate class-level RETURN_TYPES/RETURN_NAMES for structured_output mode.

        Same class-level-shared-state pattern (and limitation) as
        FormatString.update_widget: RETURN_TYPES/RETURN_NAMES are shared
        across all instances of this node type in a graph, so the very first
        execution after toggling structured_output or editing output_schema
        may show stale downstream socket typing until it runs once.
        """
        cls.node_configs[unique_id] = {
            "structured_output": structured_output,
            "schema": schema,
        }
        if not structured_output or not schema:
            cls.RETURN_TYPES = cls._BASE_RETURN_TYPES
            cls.RETURN_NAMES = cls._BASE_RETURN_NAMES
            return
        names = tuple(schema["properties"].keys())
        # 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,
        client,
        model,
        prompt,
        system="",
        history=None,
        options=None,
        image=None,
        timeout_secs=300,
        structured_output=False,
        output_schema=_DEFAULT_OUTPUT_SCHEMA,
        max_retries=2,
        unique_id="",
    ):
        effective_model = model.strip()
        if not effective_model:
            raise ValueError("model cannot be empty — type a model name or wire one in")

        schema = None
        pydantic_model = None
        if structured_output:
            schema = _parse_output_schema(output_schema)  # fail fast, no network call
            pydantic_model = _build_structured_model(schema)

        if unique_id:
            type(self).update_outputs(unique_id, structured_output, schema)

        if history is None:
            history = []
        message_dicts = list(history)
        if system:
            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).
        # ChatCompletion never branches on which concrete provider it got.
        if not structured_output:
            parsed = None
            response_text = _run_async(
                client.chat(
                    effective_model,
                    messages,
                    llm_options,
                    timeout_secs=float(timeout_secs),
                    max_retries=max_retries,
                    attempt_info=attempt_info,
                    on_status=on_status,
                )
            )
        else:
            assert (
                pydantic_model is not None
            )  # structured_output implies this was built
            parsed = _run_async(
                client.chat_structured(
                    effective_model,
                    messages,
                    pydantic_model,
                    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"{status_line}{response_text}\n\n"
            f"── History: {n} message(s) ──\n{_history_preview(updated)}"
            if n > 2
            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
            comfy_types = _comfy_types_for_schema(schema)
            extra = tuple(
                _coerce_structured_value(getattr(parsed, name), ctype)
                for name, ctype in zip(schema["properties"].keys(), comfy_types)
            )
            result_tuple += extra
        result_tuple += (seed_used,)

        return {
            "ui": {"text": [ui_text]},
            "result": result_tuple,
        }

update_outputs(unique_id, structured_output, schema) classmethod

Mutate class-level RETURN_TYPES/RETURN_NAMES for structured_output mode.

Same class-level-shared-state pattern (and limitation) as FormatString.update_widget: RETURN_TYPES/RETURN_NAMES are shared across all instances of this node type in a graph, so the very first execution after toggling structured_output or editing output_schema may show stale downstream socket typing until it runs once.

Source code in src/comfydv/ollama.py
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
@classmethod
def update_outputs(
    cls, unique_id: str, structured_output: bool, schema: dict | None
) -> None:
    """Mutate class-level RETURN_TYPES/RETURN_NAMES for structured_output mode.

    Same class-level-shared-state pattern (and limitation) as
    FormatString.update_widget: RETURN_TYPES/RETURN_NAMES are shared
    across all instances of this node type in a graph, so the very first
    execution after toggling structured_output or editing output_schema
    may show stale downstream socket typing until it runs once.
    """
    cls.node_configs[unique_id] = {
        "structured_output": structured_output,
        "schema": schema,
    }
    if not structured_output or not schema:
        cls.RETURN_TYPES = cls._BASE_RETURN_TYPES
        cls.RETURN_NAMES = cls._BASE_RETURN_NAMES
        return
    names = tuple(schema["properties"].keys())
    # 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

LLMLoadModel

Source code in src/comfydv/ollama.py
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
class LLMLoadModel:
    @classmethod
    def INPUT_TYPES(s):
        return {
            "required": {
                "client": ("LLM_CLIENT",),
                "model": (_DEFAULT_MODELS, {}),
            }
        }

    @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"
    CATEGORY = "dv/ollama"

    def load_model(self, client, model: str):
        if not model.strip():
            raise ValueError("model name cannot be empty")
        _run_async(client.load_model(model))
        return (model,)

VALIDATE_INPUTS(model) classmethod

See LLMModelSelector.VALIDATE_INPUTS for why this bypass exists.

Source code in src/comfydv/ollama.py
343
344
345
346
@classmethod
def VALIDATE_INPUTS(cls, model):
    """See LLMModelSelector.VALIDATE_INPUTS for why this bypass exists."""
    return True

LLMModelSelector

Passes a model name through, typed for wiring/validation.

client is accepted only for typing/wiring — the COMBO dropdown is populated separately (see _load_default_models); this node never calls the provider. Behavior-identical to the pre-ADR-007 OllamaModelSelector, generalized to accept any LLM_CLIENT-typed provider.

Source code in src/comfydv/ollama.py
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
class LLMModelSelector:
    """Passes a model name through, typed for wiring/validation.

    ``client`` is accepted only for typing/wiring — the COMBO dropdown is
    populated separately (see _load_default_models); this node never calls
    the provider. Behavior-identical to the pre-ADR-007 OllamaModelSelector,
    generalized to accept any LLM_CLIENT-typed provider.
    """

    @classmethod
    def INPUT_TYPES(s):
        return {
            "required": {
                "client": ("LLM_CLIENT",),
                "model": (_DEFAULT_MODELS, {}),
            }
        }

    @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"
    CATEGORY = "dv/ollama"

    def select_model(self, client, model: str):
        return (model,)

VALIDATE_INPUTS(model) classmethod

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.

Source code in src/comfydv/ollama.py
307
308
309
310
311
312
313
314
315
316
317
@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

LLMUnloadModel

Evict a model from VRAM and pass a value through unchanged.

Wire passthrough from a downstream node (e.g. the response output of ChatCompletion) so ComfyUI executes this node after that node completes. The value is returned unchanged so the rest of the workflow can continue using it.

Source code in src/comfydv/ollama.py
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
class LLMUnloadModel:
    """Evict a model from VRAM and pass a value through unchanged.

    Wire ``passthrough`` from a downstream node (e.g. the ``response`` output
    of ChatCompletion) so ComfyUI executes this node *after* that node
    completes.  The value is returned unchanged so the rest of the workflow can
    continue using it.
    """

    @classmethod
    def INPUT_TYPES(s):
        return {
            "required": {
                "client": ("LLM_CLIENT",),
                "model": ("STRING", {"forceInput": True}),
            },
            "optional": {
                "passthrough": ("STRING", {"forceInput": True}),
            },
        }

    RETURN_TYPES = ("STRING", "STRING")
    RETURN_NAMES = ("model_name", "passthrough")
    FUNCTION = "unload_model"
    CATEGORY = "dv/ollama"

    def unload_model(self, client, model: str, passthrough: str = ""):
        if not model.strip():
            raise ValueError("model name cannot be empty")
        _run_async(client.unload_model(model))
        return (model, passthrough)

OllamaClientType

Bases: str

Typed string carrying the Ollama host URL through the node graph.

Superseded by OllamaProvider (comfydv._llm.ollama_provider) as of ADR-007 — OllamaClient.create_client() no longer constructs this. Left in place (unreferenced) rather than deleted; removing a class nothing references is a separate, lower-risk cleanup outside this cutover's scope.

Source code in src/comfydv/ollama.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
class OllamaClientType(str):
    """Typed string carrying the Ollama host URL through the node graph.

    Superseded by ``OllamaProvider`` (comfydv._llm.ollama_provider) as of
    ADR-007 — ``OllamaClient.create_client()`` no longer constructs this.
    Left in place (unreferenced) rather than deleted; removing a class
    nothing references is a separate, lower-risk cleanup outside this
    cutover's scope.
    """

    def __new__(cls, host, headers=None):
        obj = super().__new__(cls, host)
        obj.headers = dict(headers) if headers else {}
        return obj

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.

Source code in src/comfydv/ollama.py
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
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),)

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.

Source code in src/comfydv/ollama.py
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
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,
                },
            ),
        )