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:
2026-09-14 18:30:31 -05:00
parent 164b195c2f
commit 89c96259fa
+65 -21
View File
@@ -35,7 +35,11 @@ class APIKeyButton(discord.ui.View):
class Assistant(commands.Cog): class Assistant(commands.Cog):
"""AI chatbot assistant. Set a chat channel or get responses when the bot is pinged.""" """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): def __init__(self, bot: Red):
self.bot = bot self.bot = bot
@@ -44,17 +48,27 @@ class Assistant(commands.Cog):
self.config.register_guild( self.config.register_guild(
enabled=True, enabled=True,
chat_channel=None, chat_channel=None,
model="gemma4", model="llama3.2:3b-instruct-q4_K_M",
vision_model="gemma4", vision_model="llama3.2:3b-instruct-q4_K_M",
temperature=0.7, 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, question_mode=False,
max_tokens=None, max_tokens=None,
reasoning_effort=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]] = {} 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): async def cog_load(self):
self.session = aiohttp.ClientSession() self.session = aiohttp.ClientSession()
@@ -79,8 +93,10 @@ class Assistant(commands.Cog):
if not enabled: if not enabled:
return 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() api_key = await self.config.api_key()
if not api_key: if not self._is_local(base_url) and not api_key:
return return
chat_channel_id = await self.config.guild(guild).chat_channel() 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 question_mode and not content.rstrip().endswith("?"):
if is_ping: 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 return
async with message.channel.typing(): 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: 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() 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 return None
model = await self.config.guild(guild).model() model = await self.config.guild(guild).model()
@@ -196,15 +215,16 @@ class Assistant(commands.Cog):
if reasoning_effort: if reasoning_effort:
payload["reasoning_effort"] = reasoning_effort payload["reasoning_effort"] = reasoning_effort
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
try: try:
async with self.session.post( async with self.session.post(
f"{self.BASE_URL}/chat/completions", f"{base_url}/chat/completions",
json=payload, json=payload,
headers={ headers=headers,
"Authorization": f"Bearer {api_key}", timeout=aiohttp.ClientTimeout(total=120),
"Content-Type": "application/json",
},
timeout=aiohttp.ClientTimeout(total=60),
) as resp: ) as resp:
if resp.status != 200: if resp.status != 200:
body = await resp.text() body = await resp.text()
@@ -246,6 +266,23 @@ class Assistant(commands.Cog):
view = APIKeyButton(self.config) view = APIKeyButton(self.config)
await ctx.send("Click below to set the API key:", view=view) 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") @assistant.command(name="toggle")
async def assistant_toggle(self, ctx: commands.Context): async def assistant_toggle(self, ctx: commands.Context):
"""Toggle the assistant on or off for this server.""" """Toggle the assistant on or off for this server."""
@@ -266,7 +303,7 @@ class Assistant(commands.Cog):
@assistant.command(name="model") @assistant.command(name="model")
async def assistant_model(self, ctx: commands.Context, model: str): 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 self.config.guild(ctx.guild).model.set(model)
await ctx.send(f"Model set to **{model}**.") await ctx.send(f"Model set to **{model}**.")
@@ -330,16 +367,21 @@ class Assistant(commands.Cog):
"qwen3-embedding-8b", "qwen3-embedding-8b",
} }
base_url = await self.config.base_url()
api_key = await self.config.api_key() 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.") await ctx.send("API key not set. Use `[p]assistant setkey` first.")
return return
headers = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
# Fetch live model list from API # Fetch live model list from API
try: try:
async with self.session.get( async with self.session.get(
f"{self.BASE_URL}/models", f"{base_url}/models",
headers={"Authorization": f"Bearer {api_key}"}, headers=headers,
timeout=aiohttp.ClientTimeout(total=15), timeout=aiohttp.ClientTimeout(total=15),
) as resp: ) as resp:
if resp.status != 200: if resp.status != 200:
@@ -376,10 +418,10 @@ class Assistant(commands.Cog):
marker = " ◀" if model_id == current_model else "" marker = " ◀" if model_id == current_model else ""
if pricing: if pricing:
lines.append( 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: else:
lines.append(f"`{model_id}` — pricing unknown{marker}") lines.append(f"`{model_id}` - pricing unknown{marker}")
embed = discord.Embed( embed = discord.Embed(
title="Available Models (cheapest first)", title="Available Models (cheapest first)",
@@ -420,7 +462,7 @@ class Assistant(commands.Cog):
valid_levels = ("none", "minimal", "low", "medium", "high") valid_levels = ("none", "minimal", "low", "medium", "high")
if level is None: if level is None:
await self.config.guild(ctx.guild).reasoning_effort.set(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: elif level.lower() not in valid_levels:
await ctx.send(f"Invalid level. Choose from: {', '.join(valid_levels)}") await ctx.send(f"Invalid level. Choose from: {', '.join(valid_levels)}")
else: else:
@@ -473,6 +515,7 @@ class Assistant(commands.Cog):
q_mode = await cfg.question_mode() q_mode = await cfg.question_mode()
max_tokens = await cfg.max_tokens() max_tokens = await cfg.max_tokens()
reasoning_effort = await cfg.reasoning_effort() reasoning_effort = await cfg.reasoning_effort()
base_url = await self.config.base_url()
has_key = await self.config.api_key() is not None has_key = await self.config.api_key() is not None
channel_str = f"<#{chat_channel}>" if chat_channel else "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="Enabled", value=str(enabled), inline=True)
embed.add_field(name="Chat Channel", value=channel_str, 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="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="Model", value=model, inline=True)
embed.add_field(name="Vision Model", value=vision_model, inline=True) embed.add_field(name="Vision Model", value=vision_model, inline=True)
embed.add_field(name="Temperature", value=str(temp), inline=True) embed.add_field(name="Temperature", value=str(temp), inline=True)