chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,571 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, NamedTuple, TypedDict, TypeGuard
|
||||
|
||||
import regex as re
|
||||
import xgrammar as xgr
|
||||
|
||||
try:
|
||||
from cohere_melody import PyFilter, PyFilterOptions
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"The Cohere reasoning parser requires the `cohere_melody` "
|
||||
"package, which is not installed. Install it with:\n"
|
||||
" pip install cohere_melody"
|
||||
) from e
|
||||
|
||||
|
||||
from vllm.entrypoints.openai.chat_completion.protocol import (
|
||||
ChatCompletionRequest,
|
||||
)
|
||||
from vllm.entrypoints.openai.engine.protocol import (
|
||||
AnyResponseFormat,
|
||||
DeltaFunctionCall,
|
||||
DeltaMessage,
|
||||
DeltaToolCall,
|
||||
)
|
||||
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
|
||||
from vllm.reasoning import ReasoningParser
|
||||
from vllm.sampling_params import StructuredOutputsParams
|
||||
from vllm.tokenizers import TokenizerLike
|
||||
|
||||
REPLACEMENT_CHAR = "\ufffd"
|
||||
|
||||
|
||||
class CohereTagRegistry(NamedTuple):
|
||||
"""A single ``structural_tag`` trigger / end pair (``begin`` uses ``trigger``)."""
|
||||
|
||||
trigger: str
|
||||
end: str
|
||||
|
||||
|
||||
class CohereTagStyle(NamedTuple):
|
||||
"""The structural tags style for a given model architecture.
|
||||
|
||||
``json_tags`` lists every JSON-schema wrapper the model may emit (MOE uses
|
||||
both response and text delimiters). ``tools`` is the tool-call wrapper.
|
||||
"""
|
||||
|
||||
json_tags: tuple[CohereTagRegistry, ...]
|
||||
tools: CohereTagRegistry
|
||||
|
||||
|
||||
class CohereNormalizedTool(TypedDict):
|
||||
"""A tool definition normalized to the shape ``collect_tool_schema`` expects.
|
||||
|
||||
``parameters`` is a JSON Schema object (possibly empty) describing the tool's
|
||||
call signature.
|
||||
"""
|
||||
|
||||
name: str
|
||||
parameters: dict[str, Any]
|
||||
|
||||
|
||||
COMMAND_A_TOOLS_TAG = CohereTagRegistry(
|
||||
trigger="<|START_ACTION|>",
|
||||
end="<|END_ACTION|>",
|
||||
)
|
||||
COMMAND_A_JSON_TAG = CohereTagRegistry(
|
||||
trigger="<|START_RESPONSE|>",
|
||||
end="<|END_RESPONSE|>",
|
||||
)
|
||||
COMMAND_A_PLUS_JSON_TAG = CohereTagRegistry(
|
||||
trigger="<|START_TEXT|>",
|
||||
end="<|END_TEXT|>",
|
||||
)
|
||||
|
||||
MODEL_TO_TAG_STYLE: dict[str, CohereTagStyle] = {
|
||||
"Cohere2ForCausalLM": CohereTagStyle(
|
||||
json_tags=(COMMAND_A_JSON_TAG,),
|
||||
tools=COMMAND_A_TOOLS_TAG,
|
||||
),
|
||||
"Cohere2VisionForConditionalGeneration": CohereTagStyle(
|
||||
json_tags=(COMMAND_A_JSON_TAG, COMMAND_A_PLUS_JSON_TAG),
|
||||
tools=COMMAND_A_TOOLS_TAG,
|
||||
),
|
||||
"Cohere2MoeForCausalLM": CohereTagStyle(
|
||||
json_tags=(COMMAND_A_JSON_TAG, COMMAND_A_PLUS_JSON_TAG),
|
||||
tools=COMMAND_A_TOOLS_TAG,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def collect_tool_schema(tool_schema: list[CohereNormalizedTool]) -> str:
|
||||
"""Build an xgrammar EBNF grammar that matches a JSON array of tool calls.
|
||||
|
||||
The grammar shape is architecture-independent; callers are responsible for
|
||||
wrapping it in the correct structural tag (see ``CohereTagStyle.tools``).
|
||||
"""
|
||||
tool_dictionary: dict[str, str] = {}
|
||||
for tool in tool_schema:
|
||||
tool_name = tool["name"]
|
||||
tool_parameters = json.dumps(tool["parameters"])
|
||||
json_schema = f"""{{
|
||||
"type": "object",
|
||||
"properties": {{
|
||||
"tool_call_id": {{
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]+$"
|
||||
}},
|
||||
"tool_name": {{
|
||||
"type": "string",
|
||||
"const": "{tool_name}"
|
||||
}},
|
||||
"parameters": {tool_parameters}
|
||||
}}
|
||||
}}"""
|
||||
tool_grammar = str(xgr.Grammar.from_json_schema(json_schema))
|
||||
for match in re.findall(r"\b(\w+)\s*::=", tool_grammar):
|
||||
tool_grammar = re.sub(
|
||||
rf"\b{re.escape(match)}\b", tool_name + match, tool_grammar
|
||||
)
|
||||
tool_dictionary[tool_name] = f"{tool_name} ::= {tool_name}root\n{tool_grammar}"
|
||||
# Emitted grammar shape:
|
||||
# root ::= tools
|
||||
# tools ::= ws "[" ws tool ws ("," ws tool)* ws "]" ws
|
||||
# ws ::= (" " | "\t" | "\n")*
|
||||
# tool ::= <tool_a> | <tool_b> | ... (one alternative per input)
|
||||
# <tool_x> ::= <tool_x>root (per-tool xgrammar rules)
|
||||
# <tool_x>root ::= ... (from xgr.Grammar.from_json_schema)
|
||||
tool_alternatives = "tool ::= " + " | ".join(tool_dictionary.keys())
|
||||
tool_rules = "\n ".join(tool_dictionary.values())
|
||||
grammar = f"""root ::= tools
|
||||
tools ::= ws "[" ws tool ws ("," ws tool)* ws "]" ws
|
||||
ws ::= (" " | "\\t" | "\\n")*
|
||||
{tool_alternatives}
|
||||
{tool_rules}
|
||||
"""
|
||||
return grammar
|
||||
|
||||
|
||||
def _tool_definitions_to_schema_list(
|
||||
tools: str | list[Any],
|
||||
) -> list[CohereNormalizedTool]:
|
||||
"""
|
||||
Build the list of ``CohereNormalizedTool`` dicts expected by
|
||||
``collect_tool_schema``.
|
||||
|
||||
Accepts:
|
||||
- JSON string
|
||||
- list of dicts with top-level ``name`` / ``parameters``
|
||||
- list of Chat Completions-style ``{"type": "function", "function": {...}}``
|
||||
- list of Pydantic models with ``model_dump()``
|
||||
"""
|
||||
if isinstance(tools, str):
|
||||
try:
|
||||
parsed = json.loads(tools)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if not isinstance(parsed, list):
|
||||
return []
|
||||
else:
|
||||
parsed = list(tools)
|
||||
|
||||
out: list[CohereNormalizedTool] = []
|
||||
for raw in parsed:
|
||||
t = raw.model_dump() if hasattr(raw, "model_dump") else raw
|
||||
if not isinstance(t, dict):
|
||||
continue
|
||||
# Unwrap Chat Completions' ``{"type": "function", "function": {...}}``
|
||||
# shape; otherwise take the dict as-is.
|
||||
if t.get("type") == "function" and isinstance(t.get("function"), dict):
|
||||
t = t["function"]
|
||||
name = t.get("name")
|
||||
if not isinstance(name, str):
|
||||
continue
|
||||
params = t.get("parameters")
|
||||
out.append(
|
||||
CohereNormalizedTool(
|
||||
name=name,
|
||||
parameters=params if isinstance(params, dict) else {},
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _has_effective_tools(
|
||||
tools: str | list[Any] | None,
|
||||
) -> TypeGuard[str | list[Any]]:
|
||||
"""
|
||||
True when ``tools`` contains at least one tool definition to convert.
|
||||
|
||||
``ResponsesRequest`` defaults ``tools`` to ``[]``; ``ChatCompletionRequest``
|
||||
uses ``None``. Both mean "no tools" here. Strings (e.g. a JSON blob) are
|
||||
treated as effective only when non-blank.
|
||||
"""
|
||||
if tools is None:
|
||||
return False
|
||||
if isinstance(tools, str):
|
||||
return bool(tools.strip())
|
||||
return len(tools) > 0
|
||||
|
||||
|
||||
# Builder: produces vLLM response_format in xgrammar's canonical format.
|
||||
# See xgrammar docs: type "structural_tag" with "format" = triggered_tags
|
||||
# and tag content type = json_schema | grammar.
|
||||
def convert_schema_to_structural_tags(
|
||||
schema: dict | None = None,
|
||||
tools: str | list[Any] | None = None,
|
||||
model_architecture: str | None = None,
|
||||
) -> str | None:
|
||||
"""
|
||||
Returns a response_format string accepted by xgrammar's structural tag format.
|
||||
Uses the canonical shape: {"type": "structural_tag", "format": {...}} with
|
||||
format.type "triggered_tags" and tag content type "json_schema" or "grammar".
|
||||
|
||||
Callers that are not on an engine path (e.g. the reasoning parser) must pass
|
||||
``model_architecture`` explicitly.
|
||||
"""
|
||||
if model_architecture is None or model_architecture not in MODEL_TO_TAG_STYLE:
|
||||
return None
|
||||
style = MODEL_TO_TAG_STYLE[model_architecture]
|
||||
|
||||
tags: list[dict] = []
|
||||
triggers: list[str] = []
|
||||
|
||||
def _add_tag(tag: CohereTagRegistry, content: dict) -> None:
|
||||
tags.append({"begin": tag.trigger, "content": content, "end": tag.end})
|
||||
triggers.append(tag.trigger)
|
||||
|
||||
if schema is not None:
|
||||
# One structural tag per JSON wrapper (e.g. MOE: response + text).
|
||||
# Same for schema-only and "tools plus JSON mode" (North: schema when
|
||||
# the model does not call tools).
|
||||
for jt in style.json_tags:
|
||||
_add_tag(jt, {"type": "json_schema", "json_schema": schema})
|
||||
|
||||
if _has_effective_tools(tools):
|
||||
# ``tools`` may be a JSON string (poseidon / RESPONSE_FORMAT_TOOL_DEFINITIONS)
|
||||
# or a list (Chat Completions ``request.tools`` as Pydantic models or dicts).
|
||||
tool_schema_list = _tool_definitions_to_schema_list(tools)
|
||||
if not tool_schema_list:
|
||||
raise ValueError(
|
||||
"No valid tool definitions could be parsed from the request for "
|
||||
"structural tag conversion."
|
||||
)
|
||||
tool_grammar = collect_tool_schema(tool_schema_list)
|
||||
_add_tag(style.tools, {"type": "grammar", "grammar": tool_grammar})
|
||||
|
||||
if not tags:
|
||||
return None
|
||||
return json.dumps(
|
||||
{
|
||||
"type": "structural_tag",
|
||||
"format": {
|
||||
"type": "triggered_tags",
|
||||
"triggers": triggers,
|
||||
"tags": tags,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _response_format_type(
|
||||
response_format: AnyResponseFormat | dict | None,
|
||||
) -> str | None:
|
||||
if response_format is None:
|
||||
return None
|
||||
if isinstance(response_format, dict):
|
||||
t = response_format.get("type")
|
||||
return t if isinstance(t, str) else None
|
||||
return response_format.type
|
||||
|
||||
|
||||
def _maybe_parse_json_dict(value: Any) -> dict | None:
|
||||
"""If value is a JSON string, parse to dict; otherwise require dict."""
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
return None
|
||||
|
||||
|
||||
def _unwrap_nested_schema(candidate: Any) -> dict | None:
|
||||
"""Return ``candidate`` as a dict, unwrapping a nested ``schema`` if present.
|
||||
|
||||
Returns ``None`` if ``candidate`` is not (and cannot be parsed into) a dict.
|
||||
"""
|
||||
cand = _maybe_parse_json_dict(candidate)
|
||||
if not isinstance(cand, dict):
|
||||
return None
|
||||
nested = cand.get("schema")
|
||||
return nested if isinstance(nested, dict) else cand
|
||||
|
||||
|
||||
def _schema_from_json_schema_field(js_wr: Any) -> dict | None:
|
||||
"""
|
||||
Extract the JSON Schema object from Chat Completions ``json_schema`` payload.
|
||||
|
||||
Accepts:
|
||||
- ``JsonSchemaResponseFormat`` (Pydantic) with ``schema`` / ``json_schema`` field
|
||||
- dict in OpenAI shape ``{"name": ..., "schema": {...}}``
|
||||
- dict with ``json_schema`` key holding either the schema or a nested wrapper
|
||||
- dict that is already a JSON Schema document (some clients omit the wrapper)
|
||||
- JSON strings for any of the above
|
||||
"""
|
||||
if js_wr is None:
|
||||
return None
|
||||
|
||||
parsed_wr = _maybe_parse_json_dict(js_wr)
|
||||
if parsed_wr is not None:
|
||||
js_wr = parsed_wr
|
||||
|
||||
if hasattr(js_wr, "model_dump"):
|
||||
for by_alias in (True, False):
|
||||
try:
|
||||
data = js_wr.model_dump(by_alias=by_alias, exclude_none=False)
|
||||
except TypeError:
|
||||
data = js_wr.model_dump(by_alias=by_alias)
|
||||
out = _unwrap_nested_schema(data.get("schema") or data.get("json_schema"))
|
||||
if out is not None:
|
||||
return out
|
||||
inner_attr = getattr(js_wr, "json_schema", None)
|
||||
return inner_attr if isinstance(inner_attr, dict) else None
|
||||
|
||||
if isinstance(js_wr, dict):
|
||||
for key in ("schema", "json_schema"):
|
||||
out = _unwrap_nested_schema(js_wr.get(key))
|
||||
if out is not None:
|
||||
return out
|
||||
return js_wr
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _schema_dict_from_chat_response_format(
|
||||
rf: AnyResponseFormat | dict | None,
|
||||
) -> dict | None:
|
||||
"""JSON schema dict from Chat Completions ``request.response_format`` only."""
|
||||
if rf is None:
|
||||
return None
|
||||
rf_type = _response_format_type(rf)
|
||||
if rf_type == "json_object":
|
||||
return {"type": "object"}
|
||||
if rf_type != "json_schema":
|
||||
return None
|
||||
js_wr = (
|
||||
rf.get("json_schema")
|
||||
if isinstance(rf, dict)
|
||||
else getattr(rf, "json_schema", None)
|
||||
)
|
||||
return _schema_from_json_schema_field(js_wr)
|
||||
|
||||
|
||||
def _schema_dict_from_structured_outputs(
|
||||
so: StructuredOutputsParams | None,
|
||||
) -> dict | None:
|
||||
"""Schema dict from ``structured_outputs`` (``json`` / ``json_object``).
|
||||
|
||||
Same unwrapping as ``json_schema``. ``json`` is expected to be ``str`` or
|
||||
``dict`` (enforced by ``StructuredOutputsParams`` / request models); other
|
||||
types raise ``ValueError`` only if a caller bypasses that validation.
|
||||
"""
|
||||
if so is None:
|
||||
return None
|
||||
if so.json_object:
|
||||
return {"type": "object"}
|
||||
raw: Any = so.json
|
||||
if raw is None:
|
||||
return None
|
||||
|
||||
if hasattr(raw, "model_dump"):
|
||||
out = _schema_from_json_schema_field(raw)
|
||||
if out is None:
|
||||
raise ValueError(
|
||||
"structured_outputs.json model has no extractable JSON Schema."
|
||||
)
|
||||
return out
|
||||
|
||||
if isinstance(raw, str):
|
||||
if not raw.strip():
|
||||
raise ValueError("structured_outputs.json cannot be empty.")
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError("structured_outputs.json must be valid JSON.") from e
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("structured_outputs.json must decode to a JSON object.")
|
||||
|
||||
if isinstance(raw, Mapping):
|
||||
body = raw if isinstance(raw, dict) else dict(raw)
|
||||
return _schema_from_json_schema_field(body) or body
|
||||
|
||||
raise ValueError(
|
||||
f"structured_outputs.json has unsupported type {type(raw).__name__}."
|
||||
)
|
||||
|
||||
|
||||
class BaseCohereCommandReasoningParser(ReasoningParser):
|
||||
def __init__(
|
||||
self,
|
||||
tokenizer: TokenizerLike,
|
||||
*args,
|
||||
streaming_opts: PyFilterOptions,
|
||||
unary_opts: PyFilterOptions,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(tokenizer, *args, **kwargs)
|
||||
self.start_token_id = tokenizer.convert_tokens_to_ids("<|START_THINKING|>")
|
||||
self.end_token_id = tokenizer.convert_tokens_to_ids("<|END_THINKING|>")
|
||||
self.chatbot_token_id = tokenizer.convert_tokens_to_ids("<|CHATBOT_TOKEN|>")
|
||||
self.unary_opts = unary_opts
|
||||
self.melody_unary = PyFilter(unary_opts)
|
||||
self.melody_streaming = PyFilter(streaming_opts)
|
||||
|
||||
@property
|
||||
def reasoning_start_str(self) -> str | None:
|
||||
return "<|START_THINKING|>"
|
||||
|
||||
@property
|
||||
def reasoning_end_str(self) -> str | None:
|
||||
return "<|END_THINKING|>"
|
||||
|
||||
def extract_reasoning_streaming(
|
||||
self,
|
||||
previous_text: str,
|
||||
current_text: str,
|
||||
delta_text: str,
|
||||
previous_token_ids: Sequence[int],
|
||||
current_token_ids: Sequence[int],
|
||||
delta_token_ids: Sequence[int],
|
||||
) -> DeltaMessage | None:
|
||||
r = self.melody_streaming.write_decoded(delta_text)
|
||||
if r.content is None and r.reasoning is None and not r.tool_calls:
|
||||
return None
|
||||
msg = DeltaMessage()
|
||||
if r.content is not None:
|
||||
msg.content = r.content
|
||||
if r.reasoning is not None:
|
||||
msg.reasoning = r.reasoning
|
||||
if r.tool_calls:
|
||||
msg.tool_calls = [
|
||||
DeltaToolCall(
|
||||
id=tc.id,
|
||||
index=tc.index,
|
||||
type="function",
|
||||
function=DeltaFunctionCall(name=tc.name, arguments=tc.arguments),
|
||||
)
|
||||
for tc in r.tool_calls
|
||||
]
|
||||
return msg
|
||||
|
||||
def extract_reasoning(
|
||||
self, model_output: str, request: ChatCompletionRequest | ResponsesRequest
|
||||
) -> tuple[str | None, str | None]:
|
||||
result = self.melody_unary.process_full_text(model_output)
|
||||
return result.reasoning, result.content
|
||||
|
||||
def extract_content_ids(self, input_ids: list[int]) -> list[int]:
|
||||
token_buf: list[int] = []
|
||||
content_ids: list[int] = []
|
||||
content_filter = PyFilter(self.unary_opts)
|
||||
for t in input_ids:
|
||||
token_buf.append(t)
|
||||
s = self.model_tokenizer.decode(token_buf, skip_special_tokens=False)
|
||||
if s.endswith(REPLACEMENT_CHAR):
|
||||
continue
|
||||
r = content_filter.write_decoded(s)
|
||||
if r.content is not None:
|
||||
content_ids.extend(token_buf)
|
||||
token_buf = []
|
||||
return content_ids
|
||||
|
||||
def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
|
||||
chatbot = self.chatbot_token_id
|
||||
start = self.start_token_id
|
||||
end = self.end_token_id
|
||||
has_end_token = False
|
||||
|
||||
for i in reversed(range(len(input_ids))):
|
||||
tid = input_ids[i]
|
||||
if tid == start:
|
||||
return has_end_token
|
||||
if tid == chatbot:
|
||||
return False
|
||||
if tid == end:
|
||||
has_end_token = True
|
||||
|
||||
return has_end_token
|
||||
|
||||
def adjust_request(
|
||||
self, request: ChatCompletionRequest | ResponsesRequest
|
||||
) -> ChatCompletionRequest | ResponsesRequest:
|
||||
so = request.structured_outputs
|
||||
if so is not None and so.structural_tag:
|
||||
return request
|
||||
# Schema: prefer ``response_format`` (OpenAI Chat Completions), then
|
||||
# ``structured_outputs.json`` / ``json_object`` (vLLM direct). Tools stay
|
||||
# on ``request.tools``.
|
||||
rf = (
|
||||
request.response_format
|
||||
if isinstance(request, ChatCompletionRequest)
|
||||
else None
|
||||
)
|
||||
if rf is not None and _response_format_type(rf) == "structural_tag":
|
||||
return request
|
||||
model_architecture = (
|
||||
self._model_config.architecture if self._model_config is not None else None
|
||||
)
|
||||
tools = request.tools
|
||||
# ``response_format`` wins if both it and ``structured_outputs`` supply JSON.
|
||||
schema = _schema_dict_from_chat_response_format(rf)
|
||||
if schema is None:
|
||||
schema = _schema_dict_from_structured_outputs(so)
|
||||
if schema is None and not _has_effective_tools(tools):
|
||||
return request
|
||||
if model_architecture is None:
|
||||
return request
|
||||
result = convert_schema_to_structural_tags(
|
||||
schema=schema,
|
||||
tools=tools,
|
||||
model_architecture=model_architecture,
|
||||
)
|
||||
if result is None:
|
||||
# Unsupported architectures are not in ``MODEL_TO_TAG_STYLE``.
|
||||
raise ValueError(
|
||||
"Failed to build structural_tag guided decoding constraints from "
|
||||
"this request's JSON schema and/or tools. The configured model "
|
||||
f"architecture ({model_architecture!r}) does not support Cohere "
|
||||
"command structural tags, or the schema cannot be expressed in "
|
||||
"that format."
|
||||
)
|
||||
request.structured_outputs = StructuredOutputsParams(structural_tag=result)
|
||||
# Folded JSON constraints into ``structural_tag``; drop ``response_format``
|
||||
# when it was the source so ``to_sampling_params`` does not also set ``json`` /
|
||||
# ``json_object`` (mutually exclusive in ``StructuredOutputsParams``).
|
||||
if isinstance(request, ChatCompletionRequest) and rf is not None:
|
||||
rf_type = _response_format_type(rf)
|
||||
if rf_type in ("json_schema", "json_object"):
|
||||
request.response_format = None
|
||||
return request
|
||||
|
||||
|
||||
class CohereCommand3ReasoningParser(BaseCohereCommandReasoningParser):
|
||||
def __init__(self, tokenizer: TokenizerLike, *args, **kwargs):
|
||||
super().__init__(
|
||||
tokenizer,
|
||||
*args,
|
||||
streaming_opts=PyFilterOptions().cmd3(),
|
||||
unary_opts=PyFilterOptions().cmd3().no_tools(),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class CohereCommand4ReasoningParser(BaseCohereCommandReasoningParser):
|
||||
def __init__(self, tokenizer: TokenizerLike, *args, **kwargs):
|
||||
super().__init__(
|
||||
tokenizer,
|
||||
*args,
|
||||
streaming_opts=PyFilterOptions().cmd4(),
|
||||
unary_opts=PyFilterOptions().cmd4().no_tools(),
|
||||
**kwargs,
|
||||
)
|
||||
Reference in New Issue
Block a user