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:
+84
-9
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user