assistant: add local Ollama backend for the humor chatbot
Make the API base URL configurable (new global config base_url, default to the local Ollama OpenAI-compatible endpoint http://127.0.0.1:11434/v1 on gpu1). Local endpoints (127.0.0.1/localhost) need no API key: the on_message, _chat, and models guards allow a missing key when local, and the Authorization header is only sent when a key is set. Default guild model is now llama3.2:3b-instruct-q4_K_M with a humor-focused system prompt. Adds [p]assistant baseurl to switch endpoints (omit to reset to local), shows the backend in settings, and raises the request timeout to 120s for local inference. GreenPT remains usable as a fallback by setting the base URL back to it and providing a key. Note: Llama-3.2-3B-Instruct is text-only; the vision path needs a vision-capable model (e.g. a local llava/llama3.2-vision) or GreenPT.
This commit is contained in:
+65
-21
@@ -35,7 +35,11 @@ class APIKeyButton(discord.ui.View):
|
||||
class Assistant(commands.Cog):
|
||||
"""AI chatbot assistant. Set a chat channel or get responses when the bot is pinged."""
|
||||
|
||||
BASE_URL = "https://api.greenpt.ai/v1"
|
||||
# Default backend: the local Ollama server (OpenAI-compatible) on gpu1.
|
||||
# Override per deployment with [p]assistant baseurl. GreenPT
|
||||
# (https://api.greenpt.ai/v1) remains usable as a fallback by setting the
|
||||
# base URL back to it and providing an API key.
|
||||
DEFAULT_BASE_URL = "http://127.0.0.1:11434/v1"
|
||||
|
||||
def __init__(self, bot: Red):
|
||||
self.bot = bot
|
||||
@@ -44,17 +48,27 @@ class Assistant(commands.Cog):
|
||||
self.config.register_guild(
|
||||
enabled=True,
|
||||
chat_channel=None,
|
||||
model="gemma4",
|
||||
vision_model="gemma4",
|
||||
model="llama3.2:3b-instruct-q4_K_M",
|
||||
vision_model="llama3.2:3b-instruct-q4_K_M",
|
||||
temperature=0.7,
|
||||
system_prompt="You are a helpful assistant.",
|
||||
system_prompt=(
|
||||
"You are a witty, quick-humored chatbot in a Discord server. "
|
||||
"Keep replies short, punchy, and funny. Favor clever, playful "
|
||||
"banter over long explanations. Stay good-natured; never be "
|
||||
"genuinely mean or offensive."
|
||||
),
|
||||
question_mode=False,
|
||||
max_tokens=None,
|
||||
reasoning_effort=None,
|
||||
)
|
||||
self.config.register_global(api_key=None)
|
||||
self.config.register_global(api_key=None, base_url=self.DEFAULT_BASE_URL)
|
||||
self.history: dict[int, list[dict]] = {}
|
||||
|
||||
@staticmethod
|
||||
def _is_local(base_url: str) -> bool:
|
||||
"""A local endpoint (Ollama) does not require an API key."""
|
||||
return "127.0.0.1" in base_url or "localhost" in base_url
|
||||
|
||||
async def cog_load(self):
|
||||
self.session = aiohttp.ClientSession()
|
||||
|
||||
@@ -79,8 +93,10 @@ class Assistant(commands.Cog):
|
||||
if not enabled:
|
||||
return
|
||||
|
||||
# A local (Ollama) endpoint needs no API key; a remote one does.
|
||||
base_url = await self.config.base_url()
|
||||
api_key = await self.config.api_key()
|
||||
if not api_key:
|
||||
if not self._is_local(base_url) and not api_key:
|
||||
return
|
||||
|
||||
chat_channel_id = await self.config.guild(guild).chat_channel()
|
||||
@@ -124,7 +140,7 @@ class Assistant(commands.Cog):
|
||||
|
||||
if question_mode and not content.rstrip().endswith("?"):
|
||||
if is_ping:
|
||||
await message.reply("Question mode is on — messages must end with **?**.")
|
||||
await message.reply("Question mode is on - messages must end with **?**.")
|
||||
return
|
||||
|
||||
async with message.channel.typing():
|
||||
@@ -150,8 +166,11 @@ class Assistant(commands.Cog):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _chat(self, guild: discord.Guild, channel_id: int, user_msg: str, image_urls: list[str] | None = None) -> str | None:
|
||||
if not self.session:
|
||||
return None
|
||||
base_url = await self.config.base_url()
|
||||
api_key = await self.config.api_key()
|
||||
if not api_key or not self.session:
|
||||
if not self._is_local(base_url) and not api_key:
|
||||
return None
|
||||
|
||||
model = await self.config.guild(guild).model()
|
||||
@@ -196,15 +215,16 @@ class Assistant(commands.Cog):
|
||||
if reasoning_effort:
|
||||
payload["reasoning_effort"] = reasoning_effort
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
try:
|
||||
async with self.session.post(
|
||||
f"{self.BASE_URL}/chat/completions",
|
||||
f"{base_url}/chat/completions",
|
||||
json=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=aiohttp.ClientTimeout(total=60),
|
||||
headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=120),
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
@@ -246,6 +266,23 @@ class Assistant(commands.Cog):
|
||||
view = APIKeyButton(self.config)
|
||||
await ctx.send("Click below to set the API key:", view=view)
|
||||
|
||||
@assistant.command(name="baseurl")
|
||||
@checks.is_owner()
|
||||
async def assistant_baseurl(self, ctx: commands.Context, url: str | None = None):
|
||||
"""Set the API base URL. Omit to reset to the local Ollama endpoint.
|
||||
|
||||
Examples:
|
||||
[p]assistant baseurl (reset to local Ollama)
|
||||
[p]assistant baseurl https://api.greenpt.ai/v1 (use GreenPT)
|
||||
A local endpoint (127.0.0.1/localhost) needs no API key.
|
||||
"""
|
||||
if url is None:
|
||||
url = self.DEFAULT_BASE_URL
|
||||
url = url.rstrip("/")
|
||||
await self.config.base_url.set(url)
|
||||
note = " (local, no API key needed)" if self._is_local(url) else " (remote, API key required)"
|
||||
await ctx.send(f"Base URL set to `{url}`{note}.")
|
||||
|
||||
@assistant.command(name="toggle")
|
||||
async def assistant_toggle(self, ctx: commands.Context):
|
||||
"""Toggle the assistant on or off for this server."""
|
||||
@@ -266,7 +303,7 @@ class Assistant(commands.Cog):
|
||||
|
||||
@assistant.command(name="model")
|
||||
async def assistant_model(self, ctx: commands.Context, model: str):
|
||||
"""Set the model (e.g. gemma4, green-l, green-r)."""
|
||||
"""Set the model. Local (Ollama): llama3.2:3b-instruct-q4_K_M. Remote (GreenPT): gemma4, green-l."""
|
||||
await self.config.guild(ctx.guild).model.set(model)
|
||||
await ctx.send(f"Model set to **{model}**.")
|
||||
|
||||
@@ -330,16 +367,21 @@ class Assistant(commands.Cog):
|
||||
"qwen3-embedding-8b",
|
||||
}
|
||||
|
||||
base_url = await self.config.base_url()
|
||||
api_key = await self.config.api_key()
|
||||
if not api_key or not self.session:
|
||||
if not self.session or (not self._is_local(base_url) and not api_key):
|
||||
await ctx.send("API key not set. Use `[p]assistant setkey` first.")
|
||||
return
|
||||
|
||||
headers = {}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
# Fetch live model list from API
|
||||
try:
|
||||
async with self.session.get(
|
||||
f"{self.BASE_URL}/models",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
f"{base_url}/models",
|
||||
headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=15),
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
@@ -376,10 +418,10 @@ class Assistant(commands.Cog):
|
||||
marker = " ◀" if model_id == current_model else ""
|
||||
if pricing:
|
||||
lines.append(
|
||||
f"`{model_id}` — €{pricing['input']:.2f} in / €{pricing['output']:.2f} out{marker}"
|
||||
f"`{model_id}` - €{pricing['input']:.2f} in / €{pricing['output']:.2f} out{marker}"
|
||||
)
|
||||
else:
|
||||
lines.append(f"`{model_id}` — pricing unknown{marker}")
|
||||
lines.append(f"`{model_id}` - pricing unknown{marker}")
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Available Models (cheapest first)",
|
||||
@@ -420,7 +462,7 @@ class Assistant(commands.Cog):
|
||||
valid_levels = ("none", "minimal", "low", "medium", "high")
|
||||
if level is None:
|
||||
await self.config.guild(ctx.guild).reasoning_effort.set(None)
|
||||
await ctx.send("Reasoning effort cleared (model default — thinking enabled).")
|
||||
await ctx.send("Reasoning effort cleared (model default - thinking enabled).")
|
||||
elif level.lower() not in valid_levels:
|
||||
await ctx.send(f"Invalid level. Choose from: {', '.join(valid_levels)}")
|
||||
else:
|
||||
@@ -473,6 +515,7 @@ class Assistant(commands.Cog):
|
||||
q_mode = await cfg.question_mode()
|
||||
max_tokens = await cfg.max_tokens()
|
||||
reasoning_effort = await cfg.reasoning_effort()
|
||||
base_url = await self.config.base_url()
|
||||
has_key = await self.config.api_key() is not None
|
||||
|
||||
channel_str = f"<#{chat_channel}>" if chat_channel else "None"
|
||||
@@ -481,6 +524,7 @@ class Assistant(commands.Cog):
|
||||
embed.add_field(name="Enabled", value=str(enabled), inline=True)
|
||||
embed.add_field(name="Chat Channel", value=channel_str, inline=True)
|
||||
embed.add_field(name="API Key", value="Set" if has_key else "Not set", inline=True)
|
||||
embed.add_field(name="Backend", value=base_url, inline=False)
|
||||
embed.add_field(name="Model", value=model, inline=True)
|
||||
embed.add_field(name="Vision Model", value=vision_model, inline=True)
|
||||
embed.add_field(name="Temperature", value=str(temp), inline=True)
|
||||
|
||||
Reference in New Issue
Block a user