mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-09 17:12:01 +00:00
fix(setup): refresh LLM provider wizard defaults (#3421)
This commit is contained in:
parent
10c1d9f417
commit
3b4c9ff733
@ -7,7 +7,8 @@ Run from repo root:
|
||||
from __future__ import annotations
|
||||
|
||||
import yaml
|
||||
from wizard.providers import LLM_PROVIDERS, SEARCH_PROVIDERS, WEB_FETCH_PROVIDERS
|
||||
from wizard.providers import LLM_PROVIDERS, SEARCH_PROVIDERS, WEB_FETCH_PROVIDERS, LLMProvider
|
||||
from wizard.steps import llm as llm_step
|
||||
from wizard.steps import search as search_step
|
||||
from wizard.writer import (
|
||||
build_minimal_config,
|
||||
@ -21,6 +22,38 @@ class TestProviders:
|
||||
def test_llm_providers_not_empty(self):
|
||||
assert len(LLM_PROVIDERS) >= 8
|
||||
|
||||
def test_llm_providers_cover_config_example_families(self):
|
||||
providers = {provider.name: provider for provider in LLM_PROVIDERS}
|
||||
|
||||
expected = {
|
||||
"volcengine",
|
||||
"openai",
|
||||
"openai_responses",
|
||||
"ollama_qwen",
|
||||
"ollama_gemma",
|
||||
"anthropic",
|
||||
"google",
|
||||
"gemini_openai_gateway",
|
||||
"mimo",
|
||||
"deepseek",
|
||||
"kimi",
|
||||
"novita",
|
||||
"minimax",
|
||||
"minimax_cn",
|
||||
"openrouter",
|
||||
"vllm",
|
||||
"mindie",
|
||||
"codex",
|
||||
"claude_code",
|
||||
}
|
||||
assert expected.issubset(providers)
|
||||
|
||||
assert providers["openai_responses"].extra_config["use_responses_api"] is True
|
||||
assert providers["gemini_openai_gateway"].use == "deerflow.models.patched_openai:PatchedChatOpenAI"
|
||||
assert providers["mimo"].use == "deerflow.models.patched_mimo:PatchedChatMiMo"
|
||||
assert providers["deepseek"].use == "deerflow.models.patched_deepseek:PatchedChatDeepSeek"
|
||||
assert providers["volcengine"].extra_config["api_base"] == "https://ark.cn-beijing.volces.com/api/v3"
|
||||
|
||||
def test_llm_providers_have_required_fields(self):
|
||||
for p in LLM_PROVIDERS:
|
||||
assert p.name
|
||||
@ -236,6 +269,97 @@ class TestBuildMinimalConfig:
|
||||
model = data["models"][0]
|
||||
assert "api_key" not in model
|
||||
|
||||
def test_responses_api_provider_defaults_are_preserved(self):
|
||||
provider = next(p for p in LLM_PROVIDERS if p.name == "openai_responses")
|
||||
content = build_minimal_config(
|
||||
provider_use=provider.use,
|
||||
model_name=provider.default_model,
|
||||
display_name=provider.display_name,
|
||||
api_key_field=provider.api_key_field,
|
||||
env_var=provider.env_var,
|
||||
extra_model_config=provider.extra_config,
|
||||
)
|
||||
data = yaml.safe_load(content)
|
||||
model = data["models"][0]
|
||||
assert model["use_responses_api"] is True
|
||||
assert model["output_version"] == "responses/v1"
|
||||
assert model["supports_vision"] is True
|
||||
|
||||
def test_patched_thinking_provider_defaults_are_preserved(self):
|
||||
provider = next(p for p in LLM_PROVIDERS if p.name == "mimo")
|
||||
content = build_minimal_config(
|
||||
provider_use=provider.use,
|
||||
model_name=provider.default_model,
|
||||
display_name=provider.display_name,
|
||||
api_key_field=provider.api_key_field,
|
||||
env_var=provider.env_var,
|
||||
extra_model_config=provider.extra_config,
|
||||
)
|
||||
data = yaml.safe_load(content)
|
||||
model = data["models"][0]
|
||||
assert model["use"] == "deerflow.models.patched_mimo:PatchedChatMiMo"
|
||||
assert model["base_url"] == "https://api.xiaomimimo.com/v1"
|
||||
assert model["api_key"] == "$MIMO_API_KEY"
|
||||
assert model["supports_thinking"] is True
|
||||
assert model["when_thinking_enabled"]["extra_body"]["thinking"]["type"] == "enabled"
|
||||
assert model["when_thinking_disabled"]["extra_body"]["thinking"]["type"] == "disabled"
|
||||
|
||||
|
||||
class TestLLMStep:
|
||||
def test_model_selection_defaults_to_provider_default_model(self, monkeypatch):
|
||||
provider = LLMProvider(
|
||||
name="test",
|
||||
display_name="Test",
|
||||
description="provider",
|
||||
use="langchain_openai:ChatOpenAI",
|
||||
models=["first-model", "default-model"],
|
||||
default_model="default-model",
|
||||
env_var="TEST_API_KEY",
|
||||
package="langchain-openai",
|
||||
)
|
||||
prompts: list[tuple[str, int | None]] = []
|
||||
|
||||
def fake_choice(prompt, options, default=None):
|
||||
prompts.append((prompt, default))
|
||||
return default if default is not None else 0
|
||||
|
||||
monkeypatch.setattr(llm_step, "LLM_PROVIDERS", [provider])
|
||||
monkeypatch.setattr(llm_step, "ask_choice", fake_choice)
|
||||
monkeypatch.setattr(llm_step, "ask_secret", lambda _prompt: "key")
|
||||
monkeypatch.setattr(llm_step, "print_header", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(llm_step, "print_info", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(llm_step, "print_success", lambda *_args, **_kwargs: None)
|
||||
|
||||
result = llm_step.run_llm_step()
|
||||
|
||||
assert result.model_name == "default-model"
|
||||
assert prompts == [("Enter choice", None), ("Select model", 1)]
|
||||
|
||||
def test_base_url_prompt_is_used_for_custom_gateway(self, monkeypatch):
|
||||
provider = LLMProvider(
|
||||
name="gateway",
|
||||
display_name="Gateway",
|
||||
description="provider",
|
||||
use="langchain_openai:ChatOpenAI",
|
||||
models=["gateway/model"],
|
||||
default_model="gateway/model",
|
||||
env_var="GATEWAY_API_KEY",
|
||||
package="langchain-openai",
|
||||
base_url_prompt="Gateway URL",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(llm_step, "LLM_PROVIDERS", [provider])
|
||||
monkeypatch.setattr(llm_step, "ask_choice", lambda *_args, **_kwargs: 0)
|
||||
monkeypatch.setattr(llm_step, "ask_text", lambda *_args, **_kwargs: "https://gateway.example/v1")
|
||||
monkeypatch.setattr(llm_step, "ask_secret", lambda _prompt: "key")
|
||||
monkeypatch.setattr(llm_step, "print_header", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(llm_step, "print_info", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(llm_step, "print_success", lambda *_args, **_kwargs: None)
|
||||
|
||||
result = llm_step.run_llm_step()
|
||||
|
||||
assert result.base_url == "https://gateway.example/v1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# writer.py — env file helpers
|
||||
|
||||
@ -20,6 +20,8 @@ class LLMProvider:
|
||||
# Extra config fields beyond the common ones (merged into YAML)
|
||||
extra_config: dict = field(default_factory=dict)
|
||||
auth_hint: str | None = None
|
||||
base_url_prompt: str | None = None
|
||||
model_prompt: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -44,48 +46,292 @@ class SearchProvider:
|
||||
extra_config: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
OPENAI_COMPAT_THINKING_CONFIG = {
|
||||
"supports_thinking": True,
|
||||
"when_thinking_enabled": {
|
||||
"extra_body": {
|
||||
"thinking": {
|
||||
"type": "enabled",
|
||||
}
|
||||
}
|
||||
},
|
||||
"when_thinking_disabled": {
|
||||
"extra_body": {
|
||||
"thinking": {
|
||||
"type": "disabled",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
ANTHROPIC_THINKING_CONFIG = {
|
||||
"supports_thinking": True,
|
||||
"when_thinking_enabled": {
|
||||
"thinking": {
|
||||
"type": "enabled",
|
||||
"budget_tokens": 4096,
|
||||
}
|
||||
},
|
||||
"when_thinking_disabled": {
|
||||
"thinking": {
|
||||
"type": "disabled",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
LLM_PROVIDERS: list[LLMProvider] = [
|
||||
LLMProvider(
|
||||
name="volcengine",
|
||||
display_name="Volcengine Doubao",
|
||||
description="Doubao Seed with thinking support",
|
||||
use="deerflow.models.patched_deepseek:PatchedChatDeepSeek",
|
||||
models=["doubao-seed-1-8-251228"],
|
||||
default_model="doubao-seed-1-8-251228",
|
||||
env_var="VOLCENGINE_API_KEY",
|
||||
package="langchain-deepseek",
|
||||
extra_config={
|
||||
"api_base": "https://ark.cn-beijing.volces.com/api/v3",
|
||||
"timeout": 600.0,
|
||||
"max_retries": 2,
|
||||
"supports_vision": True,
|
||||
"supports_reasoning_effort": True,
|
||||
**OPENAI_COMPAT_THINKING_CONFIG,
|
||||
},
|
||||
),
|
||||
LLMProvider(
|
||||
name="openai",
|
||||
display_name="OpenAI",
|
||||
description="GPT-4o, GPT-4.1, o3",
|
||||
description="GPT-5, GPT-4.1, GPT-4o",
|
||||
use="langchain_openai:ChatOpenAI",
|
||||
models=["gpt-4o", "gpt-4.1", "o3"],
|
||||
default_model="gpt-4o",
|
||||
models=["gpt-5", "gpt-5-mini", "gpt-4.1", "gpt-4o"],
|
||||
default_model="gpt-5",
|
||||
env_var="OPENAI_API_KEY",
|
||||
package="langchain-openai",
|
||||
extra_config={
|
||||
"request_timeout": 600.0,
|
||||
"max_retries": 2,
|
||||
"max_tokens": 4096,
|
||||
"temperature": 0.7,
|
||||
"supports_vision": True,
|
||||
},
|
||||
),
|
||||
LLMProvider(
|
||||
name="openai_responses",
|
||||
display_name="OpenAI Responses API",
|
||||
description="GPT-5 via /v1/responses",
|
||||
use="langchain_openai:ChatOpenAI",
|
||||
models=["gpt-5", "gpt-5-mini"],
|
||||
default_model="gpt-5",
|
||||
env_var="OPENAI_API_KEY",
|
||||
package="langchain-openai",
|
||||
extra_config={
|
||||
"request_timeout": 600.0,
|
||||
"max_retries": 2,
|
||||
"use_responses_api": True,
|
||||
"output_version": "responses/v1",
|
||||
"supports_vision": True,
|
||||
},
|
||||
),
|
||||
LLMProvider(
|
||||
name="anthropic",
|
||||
display_name="Anthropic",
|
||||
description="Claude Opus 4, Sonnet 4",
|
||||
description="Claude Sonnet 4 with extended thinking",
|
||||
use="langchain_anthropic:ChatAnthropic",
|
||||
models=["claude-opus-4-5", "claude-sonnet-4-5"],
|
||||
default_model="claude-sonnet-4-5",
|
||||
models=["claude-sonnet-4-20250514", "claude-opus-4-5", "claude-sonnet-4-5"],
|
||||
default_model="claude-sonnet-4-20250514",
|
||||
env_var="ANTHROPIC_API_KEY",
|
||||
package="langchain-anthropic",
|
||||
extra_config={"max_tokens": 8192},
|
||||
extra_config={
|
||||
"default_request_timeout": 600.0,
|
||||
"max_retries": 2,
|
||||
"max_tokens": 16000,
|
||||
"supports_vision": True,
|
||||
**ANTHROPIC_THINKING_CONFIG,
|
||||
},
|
||||
),
|
||||
LLMProvider(
|
||||
name="deepseek",
|
||||
display_name="DeepSeek",
|
||||
description="V3, R1",
|
||||
use="langchain_deepseek:ChatDeepSeek",
|
||||
models=["deepseek-chat", "deepseek-reasoner"],
|
||||
default_model="deepseek-chat",
|
||||
description="DeepSeek Reasoner with thinking support",
|
||||
use="deerflow.models.patched_deepseek:PatchedChatDeepSeek",
|
||||
models=["deepseek-reasoner", "deepseek-chat"],
|
||||
default_model="deepseek-reasoner",
|
||||
env_var="DEEPSEEK_API_KEY",
|
||||
package="langchain-deepseek",
|
||||
extra_config={
|
||||
"timeout": 600.0,
|
||||
"max_retries": 2,
|
||||
"max_tokens": 8192,
|
||||
"supports_vision": False,
|
||||
**OPENAI_COMPAT_THINKING_CONFIG,
|
||||
},
|
||||
),
|
||||
LLMProvider(
|
||||
name="google",
|
||||
display_name="Google Gemini",
|
||||
description="2.0 Flash, 2.5 Pro",
|
||||
description="Native Gemini SDK, no thinking support",
|
||||
use="langchain_google_genai:ChatGoogleGenerativeAI",
|
||||
models=["gemini-2.0-flash", "gemini-2.5-pro"],
|
||||
default_model="gemini-2.0-flash",
|
||||
models=["gemini-2.5-pro", "gemini-2.0-flash"],
|
||||
default_model="gemini-2.5-pro",
|
||||
env_var="GEMINI_API_KEY",
|
||||
package="langchain-google-genai",
|
||||
api_key_field="gemini_api_key",
|
||||
extra_config={
|
||||
"timeout": 600.0,
|
||||
"max_retries": 2,
|
||||
"max_tokens": 8192,
|
||||
"supports_vision": True,
|
||||
},
|
||||
),
|
||||
LLMProvider(
|
||||
name="gemini_openai_gateway",
|
||||
display_name="Gemini OpenAI-compatible",
|
||||
description="Gemini thinking via an OpenAI-compatible gateway",
|
||||
use="deerflow.models.patched_openai:PatchedChatOpenAI",
|
||||
models=["google/gemini-2.5-pro-preview"],
|
||||
default_model="google/gemini-2.5-pro-preview",
|
||||
env_var="GEMINI_API_KEY",
|
||||
package="langchain-openai",
|
||||
extra_config={
|
||||
"request_timeout": 600.0,
|
||||
"max_retries": 2,
|
||||
"max_tokens": 16384,
|
||||
"supports_vision": True,
|
||||
**OPENAI_COMPAT_THINKING_CONFIG,
|
||||
},
|
||||
base_url_prompt="Gateway base URL (e.g. https://your-gateway.example/v1)",
|
||||
),
|
||||
LLMProvider(
|
||||
name="ollama_qwen",
|
||||
display_name="Ollama Qwen3",
|
||||
description="Native local Ollama provider with thinking support",
|
||||
use="langchain_ollama:ChatOllama",
|
||||
models=["qwen3:32b"],
|
||||
default_model="qwen3:32b",
|
||||
env_var=None,
|
||||
package="langchain-ollama",
|
||||
extra_config={
|
||||
"base_url": "http://localhost:11434",
|
||||
"num_predict": 8192,
|
||||
"temperature": 0.7,
|
||||
"reasoning": True,
|
||||
"supports_thinking": True,
|
||||
"supports_vision": False,
|
||||
},
|
||||
auth_hint="No API key is required. Ensure Ollama is running and the model is pulled.",
|
||||
),
|
||||
LLMProvider(
|
||||
name="ollama_gemma",
|
||||
display_name="Ollama Gemma",
|
||||
description="Native local Ollama provider with vision support",
|
||||
use="langchain_ollama:ChatOllama",
|
||||
models=["gemma4:27b"],
|
||||
default_model="gemma4:27b",
|
||||
env_var=None,
|
||||
package="langchain-ollama",
|
||||
extra_config={
|
||||
"base_url": "http://localhost:11434",
|
||||
"num_predict": 8192,
|
||||
"temperature": 0.7,
|
||||
"reasoning": True,
|
||||
"supports_thinking": True,
|
||||
"supports_vision": True,
|
||||
},
|
||||
auth_hint="No API key is required. Ensure Ollama is running and the model is pulled.",
|
||||
),
|
||||
LLMProvider(
|
||||
name="mimo",
|
||||
display_name="Xiaomi MiMo",
|
||||
description="MiMo thinking models with reasoning replay",
|
||||
use="deerflow.models.patched_mimo:PatchedChatMiMo",
|
||||
models=["mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "mimo-v2-omni", "mimo-v2-flash"],
|
||||
default_model="mimo-v2.5-pro",
|
||||
env_var="MIMO_API_KEY",
|
||||
package="langchain-openai",
|
||||
extra_config={
|
||||
"base_url": "https://api.xiaomimimo.com/v1",
|
||||
"request_timeout": 600.0,
|
||||
"max_retries": 2,
|
||||
"max_tokens": 8192,
|
||||
"supports_vision": False,
|
||||
**OPENAI_COMPAT_THINKING_CONFIG,
|
||||
},
|
||||
),
|
||||
LLMProvider(
|
||||
name="kimi",
|
||||
display_name="Moonshot Kimi",
|
||||
description="Kimi K2.5 with thinking support",
|
||||
use="deerflow.models.patched_deepseek:PatchedChatDeepSeek",
|
||||
models=["kimi-k2.5"],
|
||||
default_model="kimi-k2.5",
|
||||
env_var="MOONSHOT_API_KEY",
|
||||
package="langchain-deepseek",
|
||||
extra_config={
|
||||
"api_base": "https://api.moonshot.cn/v1",
|
||||
"timeout": 600.0,
|
||||
"max_retries": 2,
|
||||
"max_tokens": 32768,
|
||||
"supports_vision": True,
|
||||
**OPENAI_COMPAT_THINKING_CONFIG,
|
||||
},
|
||||
),
|
||||
LLMProvider(
|
||||
name="novita",
|
||||
display_name="Novita AI",
|
||||
description="DeepSeek V3.2 via OpenAI-compatible API",
|
||||
use="langchain_openai:ChatOpenAI",
|
||||
models=["deepseek/deepseek-v3.2"],
|
||||
default_model="deepseek/deepseek-v3.2",
|
||||
env_var="NOVITA_API_KEY",
|
||||
package="langchain-openai",
|
||||
extra_config={
|
||||
"base_url": "https://api.novita.ai/openai",
|
||||
"request_timeout": 600.0,
|
||||
"max_retries": 2,
|
||||
"max_tokens": 4096,
|
||||
"temperature": 0.7,
|
||||
"supports_vision": True,
|
||||
**OPENAI_COMPAT_THINKING_CONFIG,
|
||||
},
|
||||
),
|
||||
LLMProvider(
|
||||
name="minimax",
|
||||
display_name="MiniMax",
|
||||
description="International OpenAI-compatible endpoint",
|
||||
use="langchain_openai:ChatOpenAI",
|
||||
models=["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed"],
|
||||
default_model="MiniMax-M3",
|
||||
env_var="MINIMAX_API_KEY",
|
||||
package="langchain-openai",
|
||||
extra_config={
|
||||
"base_url": "https://api.minimax.io/v1",
|
||||
"request_timeout": 600.0,
|
||||
"max_retries": 2,
|
||||
"max_tokens": 4096,
|
||||
"temperature": 1.0,
|
||||
"supports_vision": True,
|
||||
"supports_thinking": True,
|
||||
},
|
||||
),
|
||||
LLMProvider(
|
||||
name="minimax_cn",
|
||||
display_name="MiniMax CN",
|
||||
description="China OpenAI-compatible endpoint",
|
||||
use="langchain_openai:ChatOpenAI",
|
||||
models=["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed"],
|
||||
default_model="MiniMax-M3",
|
||||
env_var="MINIMAX_API_KEY",
|
||||
package="langchain-openai",
|
||||
extra_config={
|
||||
"base_url": "https://api.minimaxi.com/v1",
|
||||
"request_timeout": 600.0,
|
||||
"max_retries": 2,
|
||||
"max_tokens": 4096,
|
||||
"temperature": 1.0,
|
||||
"supports_vision": True,
|
||||
"supports_thinking": True,
|
||||
},
|
||||
),
|
||||
LLMProvider(
|
||||
name="openrouter",
|
||||
@ -127,6 +373,35 @@ LLM_PROVIDERS: list[LLMProvider] = [
|
||||
}
|
||||
}
|
||||
},
|
||||
"when_thinking_disabled": {
|
||||
"extra_body": {
|
||||
"chat_template_kwargs": {
|
||||
"enable_thinking": False,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
LLMProvider(
|
||||
name="mindie",
|
||||
display_name="MindIE",
|
||||
description="Qwen3-Coder on MindIE Engine",
|
||||
use="deerflow.models.mindie_provider:MindIEChatModel",
|
||||
models=["Qwen3-Coder-480B-A35B-Instruct-Client"],
|
||||
default_model="Qwen3-Coder-480B-A35B-Instruct-Client",
|
||||
env_var="OPENAI_API_KEY",
|
||||
package=None,
|
||||
extra_config={
|
||||
"base_url": "http://localhost:8989/v1",
|
||||
"temperature": 0,
|
||||
"max_retries": 1,
|
||||
"supports_thinking": False,
|
||||
"supports_vision": False,
|
||||
"supports_reasoning_effort": False,
|
||||
"read_timeout": 900.0,
|
||||
"connect_timeout": 30.0,
|
||||
"write_timeout": 60.0,
|
||||
"pool_timeout": 30.0,
|
||||
},
|
||||
),
|
||||
LLMProvider(
|
||||
@ -163,6 +438,8 @@ LLM_PROVIDERS: list[LLMProvider] = [
|
||||
default_model="gpt-4o",
|
||||
env_var="OPENAI_API_KEY",
|
||||
package="langchain-openai",
|
||||
base_url_prompt="Base URL (e.g. https://api.openai.com/v1)",
|
||||
model_prompt="Model name",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@ -32,10 +32,11 @@ def run_llm_step(step_label: str = "Step 1/3") -> LLMStepResult:
|
||||
|
||||
print()
|
||||
|
||||
# Model selection (show list, default to first)
|
||||
# Model selection (show list, default to provider preference)
|
||||
if len(provider.models) > 1:
|
||||
print_info(f"Available models for {provider.display_name}:")
|
||||
model_idx = ask_choice("Select model", provider.models, default=0)
|
||||
default_model_idx = provider.models.index(provider.default_model)
|
||||
model_idx = ask_choice("Select model", provider.models, default=default_model_idx)
|
||||
model_name = provider.models[model_idx]
|
||||
else:
|
||||
model_name = provider.models[0]
|
||||
@ -44,11 +45,14 @@ def run_llm_step(step_label: str = "Step 1/3") -> LLMStepResult:
|
||||
base_url: str | None = None
|
||||
if provider.name in {"openrouter", "vllm"}:
|
||||
base_url = provider.extra_config.get("base_url")
|
||||
if provider.name == "other":
|
||||
|
||||
if provider.base_url_prompt:
|
||||
print_header(f"{step_label} · Connection details")
|
||||
base_url = ask_text("Base URL (e.g. https://api.openai.com/v1)", required=True)
|
||||
model_name = ask_text("Model name", default=provider.default_model)
|
||||
elif provider.auth_hint:
|
||||
base_url = ask_text(provider.base_url_prompt, default=base_url or "", required=True)
|
||||
if provider.model_prompt:
|
||||
model_name = ask_text(provider.model_prompt, default=model_name)
|
||||
|
||||
if provider.auth_hint:
|
||||
print_header(f"{step_label} · Authentication")
|
||||
print_info(provider.auth_hint)
|
||||
api_key = None
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user