AI: add a self-hosted Ollama backend, and let the picker choose the model

The bot could talk to OpenAI or Anthropic; this adds Ollama as a third
provider so it can run against models hosted on our own box, and extends
the switch command to pick WHICH model - not just which backend.

Provider: Ollama exposes an OpenAI-compatible /v1 surface, so the client is
just openai.AsyncOpenAI(base_url=OLLAMA_URL + "/v1"). That reuses the
existing message format and the whole _map_openai_error mapping instead of
forking a second error taxonomy. There is no API key - the endpoint IS the
configuration, so the backend stays dormant (and refuses to be selected,
with a message naming the variable) until CONJURER_OLLAMA_URL is set, the
same way the Conan bridge behaves.

Model selection:
* list_provider_models() asks the SERVER for Ollama (/v1/models), so the
  picker shows what is actually pulled on the box rather than a hardcoded
  list. Hosted providers just report what they are wired to.
* set_active_model() pins the config's latest_model and persists it;
  cheap_model is left alone so the MUSIC path keeps its cheaper backend.
* $gadaj_teraz now takes "<config> [model]", and a new read-only $modele_ai
  lists what is available. Pinning an id Ollama does not have is rejected up
  front with the real list - otherwise the typo only surfaces later as a
  failed reply.

Two fixes this exposed:
* AI_CONFIGS now merges built-in defaults with the settings-file block
  instead of letting the file win outright. Every provider switch persists a
  "configs" block, so a file written by an older build would have
  permanently hidden ollama from the picker after an upgrade.
* _persist_active_ai_config assigns "configs" instead of setdefault, so a
  pinned model actually survives a restart.
* the hardcoded 120s response timeout is now CONJURER_AI_TIMEOUT_SECONDS - a
  self-hosted model on a modest GPU can legitimately need longer.

Tests cover: ollama appears in the picker, select_model maps the legacy
gpt-4o default instead of leaking it, model listing (server-queried, sorted,
de-duplicated, failure -> AIError, unconfigured -> auth), pinning (latest
only, blank/unknown rejected), and that provider_generate routes to the new
path. Suite: 68 unit + 70 integration green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 13:35:03 +02:00
committed by gitea
parent 13d2a04052
commit 6a6b821a0d
5 changed files with 366 additions and 14 deletions
+84 -9
View File
@@ -174,19 +174,57 @@ class Events(commands.Cog):
await ctx.reply("Nope. Nie wiesz jak użyć")
@commands.hybrid_command(
name="gadaj_teraz",
description="Pokaż/przełącz backend AI (bez argumentu = status). Przełączanie: Vykidailo.",
name="modele_ai",
description="Pokaż modele dostępne dla danego backendu (Ollamę pyta na żywo).",
)
async def gadaj_teraz(self, ctx, nazwa_konfigu: Optional[str] = None):
async def modele_ai(self, ctx, nazwa_konfigu: Optional[str] = None):
"""Read-only model listing. For Ollama this queries the server, so it
shows exactly what is pulled on the box right now."""
async with ctx.channel.typing():
target = nazwa_konfigu or ai_functions.get_active_ai_config()
if target not in ai_functions.list_ai_configs():
await discord_friendly_reply(
ctx,
f"Nie znam configu '{target}'. Dostępne: "
f"{', '.join(ai_functions.list_ai_configs())}",
)
return
try:
models = await ai_functions.list_provider_models(target)
except ai_functions.AIError as exc:
await discord_friendly_reply(
ctx, f"Nie mogę pobrać modeli dla '{target}': {exc}"
)
return
if not models:
await discord_friendly_reply(ctx, f"Brak modeli dla '{target}'.")
return
await discord_friendly_reply(
ctx,
f"Modele dla **{target}**: {', '.join(models)}\n"
f"Wepniesz przez `$gadaj_teraz {target} <model>` (tylko Vykidailo).",
)
@commands.hybrid_command(
name="gadaj_teraz",
description="Pokaż/przełącz backend AI i model (bez argumentu = status). Przełączanie: Vykidailo.",
)
async def gadaj_teraz(
self, ctx, nazwa_konfigu: Optional[str] = None, model: Optional[str] = None
):
async with ctx.channel.typing():
available = ai_functions.list_ai_configs()
# No argument -> report the active backend (read-only, open to all).
if not nazwa_konfigu:
active = ai_functions.get_active_ai_config()
active_cfg = ai_functions.AI_CONFIGS.get(active, {})
await discord_friendly_reply(
ctx,
f"Teraz gadam przez **{active}**. Dostępne: {', '.join(available)}. "
"Przełączysz przez `$gadaj_teraz <config>` (tylko Vykidailo).",
f"Teraz gadam przez **{active}** "
f"({active_cfg.get('provider')} / {active_cfg.get('latest_model')}). "
f"Dostępne: {', '.join(available)}. "
"Przełączysz przez `$gadaj_teraz <config> [model]` (tylko Vykidailo), "
"modele zobaczysz przez `$modele_ai`.",
)
return
is_admin = isinstance(ctx.author, discord.Member) and any(
@@ -208,13 +246,50 @@ class Events(commands.Cog):
ctx, f"Nie mogę przełączyć na '{nazwa_konfigu}': {exc}"
)
return
# Optional second argument pins the model. For a backend we can
# enumerate (Ollama), reject an unknown id up front with the list -
# otherwise the typo only surfaces later as a failed reply.
if model:
try:
known = await ai_functions.list_provider_models(nazwa_konfigu)
except ai_functions.AIError:
known = [] # cannot enumerate -> accept verbatim
if known and cfg.get("provider") == "ollama" and model not in known:
await discord_friendly_reply(
ctx,
f"Model '{model}' nie jest wgrany na Ollamie. "
f"Dostępne: {', '.join(known)}",
)
return
try:
cfg = ai_functions.set_active_model(model, nazwa_konfigu)
except (KeyError, ValueError) as exc:
await discord_friendly_reply(
ctx, f"Nie mogę wpiąć modelu '{model}': {exc}"
)
return
self.logger.info(
"Przełączono AI na config %s (%s)", nazwa_konfigu, cfg.get("provider")
"Przełączono AI na config %s (%s / %s)",
nazwa_konfigu, cfg.get("provider"), cfg.get("latest_model"),
)
await discord_friendly_reply(
ctx,
f"Teraz gadam przez **{nazwa_konfigu}** — {cfg.get('provider')} / {cfg.get('latest_model')}.",
message = (
f"Teraz gadam przez **{nazwa_konfigu}** — "
f"{cfg.get('provider')} / {cfg.get('latest_model')}."
)
# Switched without pinning a model: show what else is on offer.
if not model:
try:
others = await ai_functions.list_provider_models(nazwa_konfigu)
except ai_functions.AIError:
others = []
if len(others) > 1:
message += (
f"\nDostępne modele: {', '.join(others)} "
f"(`$gadaj_teraz {nazwa_konfigu} <model>`)."
)
await discord_friendly_reply(ctx, message)
@commands.hybrid_command(
name="armia_hammera",