Model Option (moop)

This commit is contained in:
Dita Aji Pratama 2026-08-13 21:06:42 +07:00
parent 1f876a00c8
commit 6cdb69336b
14 changed files with 1547 additions and 343 deletions

View File

@ -26,50 +26,12 @@ ragroleplay_vector_size = _yaml_get("ragroleplay", "vector_size", default=768
ragroleplay_model_url = _yaml_get("ragroleplay", "model_url", default="http://localhost:11434/api/embed" )
ragroleplay_model_name = _yaml_get("ragroleplay", "model_name", default="nomic-embed-text" ) # Need to download model from local ollama
# Model Option (moop) — penyimpanan model/provider/key pindah ke sqlite.
moop_db_path = os.path.expanduser(
_yaml_get("moop", "db_path", default="~/.config/hendrik/moop.sqlite3")
)
llm_timeout = int(_yaml_get("llm", "timeout", default=600))
_providers = _yaml_get("llm", "providers", default=[])
MODELS_ITEMS = []
for prov in _providers:
pname = prov.get("name", "")
base_url = prov.get("base_url", "").rstrip("/")
api_key = prov.get("api_key", "")
models = prov.get("models", [])
for m in models:
model_name = m.get("name", "")
is_default = m.get("default", False)
MODELS_ITEMS.append({
"model" : model_name,
"provider" : pname,
"base_url" : base_url,
"api_key" : api_key,
"default" : is_default,
"vision" : m.get("vision", False),
})
def resolve_provider(base_url: str, model: str) -> str | None:
base_url = base_url.rstrip("/")
for item in MODELS_ITEMS:
if item["base_url"] == base_url and item["model"] == model:
return item["provider"]
return None
# Cari model default — pertama yg marked default: true, fallback ke yg pertama
llm_baseurl = ""
llm_model = ""
llm_api_key = ""
for item in MODELS_ITEMS:
if item.get("default"):
llm_baseurl = item["base_url" ]
llm_model = item["model" ]
llm_api_key = item["api_key" ]
break
if not llm_model and MODELS_ITEMS:
llm_baseurl = MODELS_ITEMS[0]["base_url" ]
llm_model = MODELS_ITEMS[0]["model" ]
llm_api_key = MODELS_ITEMS[0]["api_key" ]
XMPP_USERNAME = _yaml_get("xmpp", "username", default="")
@ -128,36 +90,7 @@ TYPING_MAX = float( _yaml_get("delay", "typing_max", default="10.0" ) )
# ─── Image Generation (OpenRouter) ──────────────────────────────────────────────
_imagegen_providers = _yaml_get("imagegen", "providers", default=[])
IMAGEGEN_MODELS_ITEMS = []
for prov in _imagegen_providers:
pname = prov.get("name", "")
base_url = prov.get("base_url", "").rstrip("/")
api_key = prov.get("api_key", "")
models = prov.get("models", [])
for m in models:
model_name = m.get("name", "")
is_default = m.get("default", False)
IMAGEGEN_MODELS_ITEMS.append({
"model" : model_name,
"provider" : pname,
"base_url" : base_url,
"api_key" : api_key,
"default" : is_default,
})
IMAGEGEN_MODEL = ""
IMAGEGEN_API_KEY = ""
for item in IMAGEGEN_MODELS_ITEMS:
if item.get("default"):
IMAGEGEN_MODEL = item["model"]
IMAGEGEN_API_KEY = item["api_key"]
break
if not IMAGEGEN_MODEL and IMAGEGEN_MODELS_ITEMS:
IMAGEGEN_MODEL = IMAGEGEN_MODELS_ITEMS[0]["model"]
IMAGEGEN_API_KEY = IMAGEGEN_MODELS_ITEMS[0]["api_key"]
# IMAGEGEN model kini dikelola via moop (sqlite), bukan config.yaml.
# ─── Character Loader ──────────────────────────────────────────────────────────

View File

@ -9,59 +9,22 @@ agent:
user:
id: # Fill your user UUID here
moop:
db_path: "~/.config/hendrik/moop.sqlite3" # optional; Model Option database
llm:
timeout: 3000 # second
providers:
- name : "Ollama Local"
base_url : "http://localhost:11434/v1"
api_key : "ollama"
models :
- name : "granite4.1:8b"
- name : "Transformers API Local"
base_url : "http://localhost:12345/v1"
api_key : "sk-not-needed"
models :
- name : "granite4.1:8b"
- name : "Ollama Cloud"
base_url : "https://ollama.com/v1"
api_key : ""
models :
- name : "gemma4:31b-cloud"
default : true
- name : "ministral-3:14b-cloud"
- name : "OpenRouter"
base_url : "https://openrouter.ai/api/v1"
api_key : ""
models :
- name : "moonshotai/kimi-k3"
- name : "openrouter/owl-alpha"
- name : "nex-agi/nex-n2-pro:free"
- name : "z-ai/glm-5"
- name : "z-ai/glm-4.7"
- name : "ChatGPT"
base_url : "https://api.openai.com/v1"
api_key : "" # isi access token ChatGPT
models :
- name : "gpt-4o"
# Model/provider/API key kini dikelola via Model Option (moop) — database sqlite.
# Atur melalui TUI: Model > Manage Providers & Manage Sets.
imagegen:
providers:
- name : "OpenRouter Image Gen"
base_url : "https://openrouter.ai/api/v1"
api_key : ""
models :
- name : "bytedance-seed/seedream-4.5"
default : true
- name : "google/gemini-3.1-flash-image"
- name : "google/gemini-3-pro-image"
- name : "openai/gpt-5.4-image-2"
- name : "x-ai/grok-imagine-image-quality"
# Image generation kini dikelola via Model Option (moop), tipe 'imagegen'.
ragroleplay:
db_path : "~/.config/hendrik/ragroleplay"
vector_size : 768 # used on table create only
model_url : "http://localhost:11434/api/embed"
model_name : "nomic-embed-text" # Need to download model from local ollama
model_name : "nomic-embed-text" # fallback jika default chain 'embedding' belum diatur
session:
db_path: "~/.config/hendrik/sessions.json"

View File

@ -11,7 +11,7 @@ from services.xmpp_client import XMPPClient
from services.telegram_client import TelegramClient
from services.llm_client import LLMClient
from lib import gadget, personality
from lib import gadget, personality, moop
import lib.ragroleplay as ragroleplay_lib
from interfaces.tui import HendrikTUI
@ -99,7 +99,16 @@ def main():
sys.exit(1)
coder.set_current_workspace(resolved)
llm_client = LLMClient(config.llm_baseurl, config.llm_model, config.llm_api_key, config.llm_timeout)
llm_client = LLMClient("", "", "", config.llm_timeout)
if not moop.configure_client(llm_client):
if config.XMPP_ENABLED or config.TELEGRAM_ENABLED:
print(
"\n[Model Option] Tidak ada default model set untuk tipe 'llm'.\n"
" Silakan atur model set terlebih dahulu (TUI: Model > Manage Sets)\n"
" atau isi database moop sebelum menjalankan service.\n",
flush=True,
)
sys.exit(1)
ragroleplay_lib.init_db(config.ragroleplay_db_path, config.ragroleplay_vector_size)

View File

@ -21,9 +21,13 @@ from .input import (
new_session_popup,
session_browser_popup,
rename_popup,
model_selector_popup,
workspace_popup,
)
from .moopui import (
model_selector_popup,
sets_manage_popup,
providers_manage_popup,
)
from .agent import submit, log
@ -85,6 +89,16 @@ ACTIONS = {
"label": "Select Model", "mnemonic": "S", "shortcut": "F4",
"handler": model_selector_popup, "enabled": _ready,
},
"manage_sets": {
"key": None,
"label": "Manage Sets", "mnemonic": "S", "shortcut": "",
"handler": sets_manage_popup, "enabled": _ready,
},
"manage_providers": {
"key": None,
"label": "Manage Providers", "mnemonic": "P", "shortcut": "",
"handler": providers_manage_popup, "enabled": _ready,
},
"change_workspace": {
"key": KEY_F6,
"label": "Change Workspace", "mnemonic": "C", "shortcut": "F6",
@ -127,6 +141,9 @@ MENUS = [
"mnemonic": "M",
"items": [
_item("select_model"),
"sep",
_item("manage_sets"),
_item("manage_providers"),
],
},
{

View File

@ -2,6 +2,7 @@ import json
import threading
from datetime import datetime
import config
from lib import moop
from lib import ntro, agent_loop
from tools.vision import ImagePayload
@ -59,7 +60,7 @@ def submit(app, stdscr):
log(app, "user", query)
model_info = (
config.resolve_provider(app.llm.base_url, app.llm.model),
moop.resolve_provider(None, app.llm.base_url, app.llm.model),
app.llm.model
)
if app.log:

View File

@ -9,7 +9,7 @@ from .input import handle_key
from . import keycodes, menubar
from .agent import log, WELCOME_ART
from services.session_manager_neo import NeoSessionManager, NeoSession
from lib import ragroleplay
from lib import ragroleplay, moop
from lib import personality as personality_mod
@ -47,18 +47,29 @@ class HendrikTUI:
self.session_mgr = NeoSessionManager()
self.current_session: NeoSession | None = None
def switch_model(self, item: dict):
self.llm.base_url = item["base_url"]
self.llm.model = item["model"]
self.llm.api_key = item["api_key"]
def switch_model_set(self, set_id):
default_chain = moop.default_chain(None, "llm")
target = moop.get_set(None, set_id)
if not target:
return
chain = moop.resolve_chain(None, set_id)
if not chain:
log(self, "error", "Model set tidak memiliki model yang aktif.")
return
if not default_chain:
moop.set_default(None, "llm", set_id)
self.llm.set_chains({"llm": chain})
if self.current_session:
self.session_mgr.update_model_info(
self.current_session.doc_id, self._model_info()
)
candidate = chain[0]
log(self, "system",
f"Model \u2192 {candidate['provider']} / {candidate['model']}")
def _model_info(self) -> dict:
return {
"provider": config.resolve_provider(self.llm.base_url, self.llm.model),
"provider": moop.resolve_provider(None, self.llm.base_url, self.llm.model),
"base_url": self.llm.base_url,
"model": self.llm.model,
}

View File

@ -11,6 +11,11 @@ import config
from .agent import log
from tools.coder import set_current_workspace
from . import keycodes
from .moopui import (
model_selector_popup,
sets_manage_popup,
providers_manage_popup,
)
def _build_visual(buffer, max_chars):
@ -158,112 +163,6 @@ def workspace_popup(app, stdscr):
stdscr.touchwin()
stdscr.refresh()
def model_selector_popup(app, stdscr):
current_base = app.llm.base_url.rstrip("/")
current_model = app.llm.model
# Group by provider
providers: list[tuple[str, list[dict]]] = []
seen = {}
for item in config.MODELS_ITEMS:
p = item["provider"]
if p not in seen:
seen[p] = []
providers.append((p, seen[p]))
seen[p].append(item)
items = [] # (type, data, label)
selectable = [] # indices into items for model entries
current_idx = 0
for pname, plist in providers:
items.append(("header", None, pname))
for entry in plist:
idx = len(items)
items.append(("model", entry, entry["model"]))
selectable.append(idx)
if entry["base_url"] == current_base and entry["model"] == current_model:
current_idx = len(selectable) - 1
if not selectable:
return
pw = min(50, app.w - 4)
ph = min(len(items) + 4, app.h - 4)
px = (app.w - pw) // 2
py = (app.h - ph) // 2
if ph < 6:
return
win = curses.newwin(ph, pw, py, px)
win.keypad(True)
while True:
win.erase()
win.box()
win.addstr(0, 2, " Pilih Model (Ctrl+E) ", curses.A_BOLD)
visible_h = ph - 2
total = len(items)
scroll = max(0, min(selectable[current_idx] - visible_h // 2, total - visible_h))
for i in range(visible_h):
idx = scroll + i
if idx >= total:
break
typ, data, label = items[idx]
y = 1 + i
if typ == "header":
win.addstr(y, 2, f" {label} ", curses.A_BOLD | curses.A_UNDERLINE)
else:
is_cur = (idx == selectable[current_idx])
is_active = (data["base_url"] == current_base and data["model"] == current_model)
if is_cur:
prefix = " \u25b6 " if is_active else " > "
elif is_active:
prefix = " \u2192 "
else:
prefix = " "
display = prefix + label
if len(display) > pw - 4:
display = display[:pw - 4]
attr = curses.A_REVERSE if is_cur else curses.A_NORMAL
win.addstr(y, 2, display.ljust(pw - 4), attr)
footer = " \u2191\u2193 nav \u21b5 select esc/q close "
win.addstr(ph - 1, 2, footer[:pw - 4], curses.A_DIM)
try:
target_y = selectable[current_idx] - scroll + 1
if 0 <= target_y < ph - 1:
win.move(target_y, 4)
except curses.error:
pass
win.refresh()
key = keycodes.read_key(stdscr, -1)
if key in (27, ord("q"), ord("Q")):
break
elif key in (curses.KEY_ENTER, 10, 13):
sel_item = items[selectable[current_idx]]
if sel_item[0] == "model":
app.switch_model(sel_item[1])
break
elif key == curses.KEY_UP:
if current_idx > 0:
current_idx -= 1
elif key == curses.KEY_DOWN:
if current_idx < len(selectable) - 1:
current_idx += 1
del win
stdscr.touchwin()
stdscr.refresh()
def new_session_popup(app, stdscr):
if not app.current_session:
app.new_session()

509
interfaces/tui/moopui.py Normal file
View File

@ -0,0 +1,509 @@
# moopui.py — Popup manajemen Model Option (moop) untuk TUI.
#
# Menyediakan:
# * model_selector_popup — pilih default model set tipe 'llm'
# * sets_manage_popup — CRUD model set
# * set_detail_popup — kelola type dalam sebuah set
# * set_models_popup — kelola model (provider/model) dalam sebuah set
# * providers_manage_popup — CRUD provider (moop_api)
# * provider_detail_popup — kelola API key per provider
import curses
from . import keycodes, theme
from .agent import log
from lib import moop
MODEL_TYPES = moop.MODEL_TYPES
def _mask_key(k):
if not k:
return "(empty)"
if len(k) <= 8:
return "*" * len(k)
return k[:4] + "..." + k[-4:]
def _parse_priority(text):
if text is None or text.strip() == "":
return None
try:
return int(text.strip())
except ValueError:
return None
def _text_input(app, stdscr, title, initial="", width=60):
pw = min(width, app.w - 4)
ph = 3
px = (app.w - pw) // 2
py = app.h // 2 - 1
win = curses.newwin(ph, pw, py, px)
win.keypad(True)
buf = initial
pos = len(buf)
max_chars = pw - 4
result = None
while True:
win.erase()
win.box()
try:
win.addstr(0, 2, f" {title} ", curses.A_BOLD)
except curses.error:
pass
start = max(0, pos - (max_chars - 1)) if pos > max_chars - 1 else 0
shown = buf[start:start + max_chars]
try:
win.addstr(1, 2, shown.ljust(max_chars))
except curses.error:
pass
try:
win.move(1, 2 + (pos - start))
except curses.error:
pass
win.refresh()
key = keycodes.read_key(stdscr, -1)
if key in (curses.KEY_ENTER, 10, 13):
result = buf
break
elif key in (27,):
break
elif key in (curses.KEY_BACKSPACE, 127):
if pos > 0:
buf = buf[:pos - 1] + buf[pos:]
pos -= 1
elif key == curses.KEY_DC:
if pos < len(buf):
buf = buf[:pos] + buf[pos + 1:]
elif key == curses.KEY_LEFT:
pos = max(0, pos - 1)
elif key == curses.KEY_RIGHT:
pos = min(len(buf), pos + 1)
elif key == curses.KEY_HOME:
pos = 0
elif key == curses.KEY_END:
pos = len(buf)
elif 32 <= key <= 255:
ch = chr(key)
buf = buf[:pos] + ch + buf[pos:]
pos += 1
del win
stdscr.touchwin()
stdscr.refresh()
if result is None:
return None
return result.strip()
def _confirm(app, stdscr, message, width=60):
pw = min(width, app.w - 4)
ph = 4
px = (app.w - pw) // 2
py = app.h // 2 - 1
win = curses.newwin(ph, pw, py, px)
win.box()
try:
win.addstr(0, 2, " Konfirmasi ", curses.A_BOLD)
except curses.error:
pass
display = message if len(message) <= pw - 4 else message[:pw - 7] + "..."
try:
win.addstr(1, 2, display)
except curses.error:
pass
try:
win.addstr(2, 2, " [Y]es [N]o ")
except curses.error:
pass
win.refresh()
result = False
while True:
key = win.getch()
if key in (ord("y"), ord("Y")):
result = True
break
elif key in (ord("n"), ord("N"), 27):
break
del win
stdscr.touchwin()
stdscr.refresh()
return result
def _run_list_popup(app, stdscr, title, items, footer="", current_idx=0, active_flags=None):
"""Popup daftar generik. Mengembalikan (idx, key).
key: KEY_ENTER / 10/13 (pilih), 27 (tutup), atau kode char aksi (a/d/e/r/dst).
items: list label; gunakan "---" untuk separator.
active_flags: list bool sejajar items untuk memberi gaya A_DIM."""
if not items:
items = ["(kosong)"]
pw = min(60, app.w - 4)
ph = min(len(items) + 4, app.h - 4)
if ph < 5:
return -1, 27
px = (app.w - pw) // 2
py = (app.h - ph) // 2
win = curses.newwin(ph, pw, py, px)
win.keypad(True)
while True:
win.erase()
win.box()
try:
win.addstr(0, 2, f" {title} ", curses.A_BOLD)
except curses.error:
pass
visible_h = ph - 2
total = len(items)
scroll = max(0, min(current_idx - visible_h // 2, total - visible_h))
for i in range(visible_h):
idx = scroll + i
if idx >= total:
break
y = 1 + i
label = items[idx]
if label == "---":
try:
win.addstr(y, 2, "\u2500" * max(0, pw - 4),
curses.color_pair(theme.C_MENU_DISABLED))
except curses.error:
pass
continue
is_cur = (idx == current_idx)
active = True
if active_flags and idx < len(active_flags):
active = bool(active_flags[idx])
if is_cur:
prefix = " \u25b6 " if active else " > "
else:
prefix = " "
if is_cur:
attr = curses.A_REVERSE
elif not active:
attr = curses.A_DIM
else:
attr = curses.A_NORMAL
display = prefix + label
if len(display) > pw - 4:
display = display[:pw - 4]
try:
win.addstr(y, 2, display.ljust(pw - 4), attr)
except curses.error:
pass
if footer:
try:
win.addstr(ph - 1, 2, footer[:pw - 4], curses.A_DIM)
except curses.error:
pass
win.refresh()
key = keycodes.read_key(stdscr, -1)
if key in (curses.KEY_UP,):
current_idx = max(0, current_idx - 1)
elif key in (curses.KEY_DOWN,):
current_idx = min(total - 1, current_idx + 1)
elif key in (27,):
del win
stdscr.touchwin()
stdscr.refresh()
return current_idx, 27
elif key in (curses.KEY_ENTER, 10, 13):
del win
stdscr.touchwin()
stdscr.refresh()
return current_idx, key
elif 32 <= key <= 255:
del win
stdscr.touchwin()
stdscr.refresh()
return current_idx, key
def _first_model_desc(set_id):
models = moop.list_models(None, set_id)
for m in models:
if m["priority"] is not None:
return f"{m['provider']} / {m['model']}"
return None
# ---------------------------------------------------------- select model ---
def model_selector_popup(app, stdscr):
while True:
sets = moop.list_sets(None)
ids = []
items = []
flags = []
current = 0
default_id = moop.get_default_set_id(None, "llm")
for s in sets:
if "llm" not in s["types"]:
continue
ids.append(s["id"])
label = s["name"]
if s["id"] == default_id:
label += " [default]"
first = _first_model_desc(s["id"])
if first:
label += f" ({first})"
items.append(label)
flags.append(s["id"] == default_id)
if s["id"] == default_id:
current = len(items) - 1
if not items:
log(app, "error", "Tidak ada model set tipe 'llm'. Atur via Model > Manage Sets.")
return
idx, key = _run_list_popup(
app, stdscr, "Pilih Model Set (LLM)", items,
footer=" \u2191\u2193 nav \u21b5 select esc close ",
current_idx=current, active_flags=flags,
)
if key == 27:
return
elif key in (10, 13, curses.KEY_ENTER):
app.switch_model_set(ids[idx])
return
# ------------------------------------------------------------ manage sets ---
def sets_manage_popup(app, stdscr):
while True:
sets = moop.list_sets(None)
ids = [s["id"] for s in sets]
items = []
for s in sets:
t = ",".join(s["types"]) if s["types"] else "-"
d = " \u2605" if s["default_for"] else ""
items.append(f"{s['name']} [{t}]{d}")
idx, key = _run_list_popup(
app, stdscr, "Model Sets", items,
footer=" \u2191\u2193 nav \u21b5 detail a add r rename d delete esc close ",
)
if key == 27:
return
elif key == ord("a"):
name = _text_input(app, stdscr, "Nama model set:")
if name:
sid = moop.create_set(None, name)
log(app, "system", f"Model set '{name}' dibuat.")
if sid is not None:
set_detail_popup(app, stdscr, sid)
elif key == ord("r") and ids:
name = _text_input(app, stdscr, "Rename model set:", initial=sets[idx]["name"])
if name:
moop.rename_set(None, ids[idx], name)
log(app, "system", "Model set di-rename.")
elif key == ord("d") and ids:
if _confirm(app, stdscr, f"Hapus model set '{sets[idx]['name']}'?"):
moop.delete_set(None, ids[idx])
log(app, "system", "Model set dihapus.")
elif key in (10, 13, curses.KEY_ENTER) and ids:
set_detail_popup(app, stdscr, ids[idx])
def set_detail_popup(app, stdscr, set_id):
while True:
s = moop.get_set(None, set_id)
if not s:
return
items = []
vals = []
for t in MODEL_TYPES:
if t in s["types"]:
star = " \u2605 default" if t in s["default_for"] else ""
n = len(moop.list_models(None, set_id))
items.append(f"{t}{star} ({n} model)")
vals.append(t)
idx, key = _run_list_popup(
app, stdscr, f"Set: {s['name']}", items,
footer=" \u2191\u2193 \u21b5 models a add type d del type s default r rename esc close ",
)
if key == 27:
return
elif key == ord("a"):
available = [t for t in MODEL_TYPES if t not in s["types"]]
if not available:
log(app, "system", "Semua tipe sudah ada di set ini.")
continue
tidx, tkey = _run_list_popup(
app, stdscr, "Tambah Tipe", list(available),
footer=" \u21b5 pilih esc batal ",
)
if tkey in (10, 13, curses.KEY_ENTER):
tname = available[tidx]
moop.add_type(None, set_id, tname)
log(app, "system", f"Tipe '{tname}' ditambahkan.")
elif key == ord("s") and vals:
moop.set_default(None, vals[idx], set_id)
log(app, "system", f"Set ini menjadi default untuk '{vals[idx]}'.")
elif key == ord("d") and vals:
if _confirm(app, stdscr, f"Hapus tipe '{vals[idx]}' dari set ini?"):
moop.remove_type(None, set_id, vals[idx])
log(app, "system", f"Tipe '{vals[idx]}' dihapus dari set.")
elif key == ord("r"):
newname = _text_input(app, stdscr, "Rename model set:", initial=s["name"])
if newname:
moop.rename_set(None, set_id, newname)
elif key in (10, 13, curses.KEY_ENTER) and vals:
set_models_popup(app, stdscr, set_id, vals[idx])
def _pick_provider(app, stdscr):
apis = moop.list_apis(None)
if not apis:
log(app, "system", "Belum ada provider. Tambah via Model > Manage Providers.")
return None
items = [f"{a['name']} ({a['baseurl']})" for a in apis]
idx, key = _run_list_popup(
app, stdscr, "Pilih Provider", items,
footer=" \u2191\u2193 nav \u21b5 pilih esc batal ",
)
if key in (10, 13, curses.KEY_ENTER):
return apis[idx]["id"]
return None
def _add_model_popup(app, stdscr, set_id):
api_id = _pick_provider(app, stdscr)
if api_id is None:
return
model = _text_input(app, stdscr, "Nama model:")
if not model:
return
pri = _text_input(app, stdscr, "Priority (kosong = disabled):")
moop.add_model(None, set_id, api_id, model, _parse_priority(pri))
log(app, "system", f"Model '{model}' ditambahkan.")
def _edit_model_popup(app, stdscr, m):
model = _text_input(app, stdscr, "Nama model:", initial=m["model"])
if not model:
return
pri = _text_input(
app, stdscr, "Priority (kosong = disabled):",
initial="" if m["priority"] is None else str(m["priority"]),
)
moop.update_model(None, m["model_id"], model, _parse_priority(pri))
log(app, "system", "Model di-update.")
def set_models_popup(app, stdscr, set_id, type_name):
while True:
models = moop.list_models(None, set_id)
ids = [m["model_id"] for m in models]
items = []
for m in models:
pri = str(m["priority"]) if m["priority"] is not None else "off"
items.append(f"{m['provider']} / {m['model']} (pri {pri})")
idx, key = _run_list_popup(
app, stdscr, f"Models ({type_name})", items,
footer=" \u2191\u2193 a add e edit d delete esc close ",
)
if key == 27:
return
elif key == ord("a"):
_add_model_popup(app, stdscr, set_id)
elif key == ord("e") and ids:
_edit_model_popup(app, stdscr, models[idx])
elif key == ord("d") and ids:
if _confirm(app, stdscr, f"Hapus model '{models[idx]['model']}'?"):
moop.delete_model(None, ids[idx])
log(app, "system", "Model dihapus.")
# ---------------------------------------------------------- manage provider ---
def providers_manage_popup(app, stdscr):
while True:
apis = moop.list_apis(None)
ids = [a["id"] for a in apis]
items = [f"{a['name']} ({a['baseurl']}) keys:{a['key_count']}" for a in apis]
idx, key = _run_list_popup(
app, stdscr, "Providers (API)", items,
footer=" \u2191\u2193 \u21b5 keys a add e edit d delete esc close ",
)
if key == 27:
return
elif key == ord("a"):
name = _text_input(app, stdscr, "Nama provider:")
if not name:
continue
baseurl = _text_input(app, stdscr, "Base URL:")
if baseurl:
moop.create_api(None, name, baseurl)
log(app, "system", f"Provider '{name}' ditambahkan.")
elif key == ord("e") and ids:
name = _text_input(app, stdscr, "Nama provider:", initial=apis[idx]["name"])
baseurl = _text_input(app, stdscr, "Base URL:", initial=apis[idx]["baseurl"])
if name and baseurl:
moop.update_api(None, ids[idx], name, baseurl)
log(app, "system", "Provider di-update.")
elif key == ord("d") and ids:
if _confirm(app, stdscr,
f"Hapus provider '{apis[idx]['name']}'?\n(keys & model terkait ikut terhapus)"):
moop.delete_api(None, ids[idx])
log(app, "system", "Provider dihapus.")
elif key in (10, 13, curses.KEY_ENTER) and ids:
provider_detail_popup(app, stdscr, ids[idx])
def provider_detail_popup(app, stdscr, api_id):
api = moop.get_api(None, api_id)
if not api:
return
while True:
keys = moop.list_keys(None, api_id)
ids = [k["id"] for k in keys]
items = []
for k in keys:
pri = str(k["priority"]) if k["priority"] is not None else "off"
items.append(f"{_mask_key(k['key'])} (pri {pri})")
idx, key = _run_list_popup(
app, stdscr, f"API Keys: {api['name']}", items,
footer=" \u2191\u2193 a add e edit d delete esc close ",
)
if key == 27:
return
elif key == ord("a"):
k = _text_input(app, stdscr, "API Key:")
if k:
pri = _text_input(app, stdscr, "Priority (kosong = disabled):")
moop.add_key(None, api_id, k, _parse_priority(pri))
log(app, "system", "API key ditambahkan.")
elif key == ord("e") and ids:
k = _text_input(app, stdscr, "API Key:", initial=keys[idx]["key"])
pri = _text_input(
app, stdscr, "Priority (kosong = disabled):",
initial="" if keys[idx]["priority"] is None else str(keys[idx]["priority"]),
)
if k:
moop.update_key(None, ids[idx], k, _parse_priority(pri))
log(app, "system", "API key di-update.")
elif key == ord("d") and ids:
if _confirm(app, stdscr, "Hapus API key ini?"):
moop.delete_key(None, ids[idx])

665
lib/moop.py Normal file
View File

@ -0,0 +1,665 @@
# moop.py — Model Option (moop).
#
# Penyimpanan model/API key/provider dipindah dari config.yaml ke database
# sqlite. Modul ini bertanggung jawab atas:
# * inisialisasi schema (referensi: plan/moop.sql)
# * CRUD model set, provider (moop_api), key, dan model
# * default model set per tipe (llm/embedding/imagegen/imagevision)
# * resolve "chain" kandidat (base_url, model, api_key) untuk auto-switch
#
# Prioritas auto-switch di dalam satu model set:
# 1. Key (urut berdasarkan priority, NULL = disabled)
# 2. Setelah semua key gagal baru pindah Model/Provider berikutnya.
# Setiap kandidat dalam chain membawa (api_id, model_id) agar pemanggil
# bisa membedakan rotasi key vs pindah provider.
import os
import sqlite3
import uuid
import config
MODEL_TYPES = ("llm", "embedding", "imagegen", "imagevision")
_SCHEMA = """
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS moop_model_set (
id BLOB PRIMARY KEY NOT NULL,
name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS moop_model_type (
id BLOB PRIMARY KEY NOT NULL,
type TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS moop_model_set_type (
id BLOB PRIMARY KEY NOT NULL,
"set" BLOB NOT NULL,
type BLOB NOT NULL,
FOREIGN KEY ("set") REFERENCES moop_model_set(id) ON UPDATE CASCADE ON DELETE CASCADE,
FOREIGN KEY (type) REFERENCES moop_model_type(id) ON UPDATE CASCADE ON DELETE CASCADE,
UNIQUE ("set", type)
);
CREATE TABLE IF NOT EXISTS moop_model_type_default (
id BLOB PRIMARY KEY NOT NULL,
type BLOB NOT NULL UNIQUE,
set_type BLOB NOT NULL UNIQUE,
FOREIGN KEY (type) REFERENCES moop_model_type(id) ON UPDATE CASCADE ON DELETE CASCADE,
FOREIGN KEY (set_type) REFERENCES moop_model_set_type(id) ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS moop_api (
id BLOB PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
baseurl TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS moop_key (
id BLOB PRIMARY KEY NOT NULL,
key TEXT NOT NULL,
priority INTEGER NULL,
api BLOB NOT NULL,
FOREIGN KEY (api) REFERENCES moop_api(id) ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS moop_model (
id BLOB PRIMARY KEY NOT NULL,
api BLOB NOT NULL,
"set" BLOB NOT NULL,
model TEXT NOT NULL,
priority INTEGER NULL,
FOREIGN KEY (api) REFERENCES moop_api(id) ON UPDATE CASCADE ON DELETE CASCADE,
FOREIGN KEY ("set") REFERENCES moop_model_set(id) ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_moop_key_api ON moop_key(api);
CREATE INDEX IF NOT EXISTS idx_moop_key_api_priority ON moop_key(api, priority);
CREATE INDEX IF NOT EXISTS idx_moop_model_api ON moop_model(api);
CREATE INDEX IF NOT EXISTS idx_moop_model_set ON moop_model("set");
CREATE INDEX IF NOT EXISTS idx_moop_model_api_priority ON moop_model(api, priority);
CREATE INDEX IF NOT EXISTS idx_moop_model_set_type_set ON moop_model_set_type("set");
CREATE INDEX IF NOT EXISTS idx_moop_model_set_type_type ON moop_model_set_type(type);
"""
def moop_db_path():
return os.path.expanduser(config.moop_db_path)
def _id():
return uuid.uuid4().bytes
def _connect(path=None):
p = os.path.expanduser(path or moop_db_path())
parent = os.path.dirname(p)
if parent:
os.makedirs(parent, exist_ok=True)
conn = sqlite3.connect(p, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
try:
# Self-healing: buat schema + seed tipe kalau DB belum pernah dibuat.
if not conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='moop_model_type'"
).fetchone():
conn.executescript(_SCHEMA)
for t in MODEL_TYPES:
conn.execute(
"INSERT INTO moop_model_type (id, type) VALUES (?, ?)",
(_id(), t),
)
conn.commit()
except sqlite3.OperationalError:
pass
return conn
# ---------------------------------------------------------------- init ---
def init_db(path=None):
"""Buat schema kalau belum ada, lalu seed tipe model yang didukung."""
conn = _connect(path)
try:
conn.executescript(_SCHEMA)
for t in MODEL_TYPES:
conn.execute(
"INSERT OR IGNORE INTO moop_model_type (id, type) VALUES (?, ?)",
(_id(), t),
)
conn.commit()
finally:
conn.close()
def ensure_defaults(path=None):
"""Untuk tipe yang belum punya default set, ambil set+type pertama."""
conn = _connect(path)
try:
_ensure_defaults_conn(conn)
conn.commit()
finally:
conn.close()
def _ensure_defaults_conn(conn):
types = conn.execute("SELECT id FROM moop_model_type").fetchall()
for t in types:
tid = t["id"]
has = conn.execute(
"SELECT id FROM moop_model_type_default WHERE type = ?", (tid,)
).fetchone()
if has:
continue
st = conn.execute(
'SELECT st.id FROM moop_model_set_type st '
'WHERE st.type = ? ORDER BY st.rowid LIMIT 1',
(tid,),
).fetchone()
if st:
conn.execute(
"INSERT OR IGNORE INTO moop_model_type_default (id, type, set_type) "
"VALUES (?, ?, ?)",
(_id(), tid, st["id"]),
)
# ---------------------------------------------------------------- sets ---
def _set_types_conn(conn, set_id):
return [
r["type"]
for r in conn.execute(
'SELECT mt.type AS type FROM moop_model_set_type st '
'JOIN moop_model_type mt ON mt.id = st.type '
'WHERE st."set" = ? ORDER BY mt.type',
(set_id,),
).fetchall()
]
def _set_defaults_conn(conn, set_id):
return [
r["type_name"]
for r in conn.execute(
'SELECT mt.type AS type_name FROM moop_model_type_default d '
'JOIN moop_model_type mt ON mt.id = d.type '
'JOIN moop_model_set_type st ON st.id = d.set_type '
'WHERE st."set" = ?',
(set_id,),
).fetchall()
]
def list_sets(path=None):
conn = _connect(path)
try:
result = []
for r in conn.execute(
"SELECT id, name FROM moop_model_set ORDER BY name COLLATE NOCASE"
).fetchall():
set_id = r["id"]
result.append({
"id": set_id,
"name": r["name"],
"types": _set_types_conn(conn, set_id),
"default_for": _set_defaults_conn(conn, set_id),
})
return result
finally:
conn.close()
def get_set(path=None, set_id=None):
if set_id is None:
return None
conn = _connect(path)
try:
r = conn.execute(
"SELECT id, name FROM moop_model_set WHERE id = ?", (set_id,)
).fetchone()
if not r:
return None
return {
"id": r["id"],
"name": r["name"],
"types": _set_types_conn(conn, set_id),
"default_for": _set_defaults_conn(conn, set_id),
}
finally:
conn.close()
def create_set(path=None, name=""):
conn = _connect(path)
try:
set_id = _id()
conn.execute(
"INSERT INTO moop_model_set (id, name) VALUES (?, ?)",
(set_id, (name or "").strip()),
)
conn.commit()
return set_id
finally:
conn.close()
def rename_set(path=None, set_id=None, name=""):
if set_id is None:
return
conn = _connect(path)
try:
conn.execute(
"UPDATE moop_model_set SET name = ? WHERE id = ?",
((name or "").strip(), set_id),
)
conn.commit()
finally:
conn.close()
def delete_set(path=None, set_id=None):
if set_id is None:
return
conn = _connect(path)
try:
conn.execute("DELETE FROM moop_model_set WHERE id = ?", (set_id,))
conn.commit()
_ensure_defaults_conn(conn)
conn.commit()
finally:
conn.close()
# --------------------------------------------------------------- types ---
def _type_id(conn, type_name):
r = conn.execute(
"SELECT id FROM moop_model_type WHERE type = ?", (type_name,)
).fetchone()
if r:
return r["id"]
tid = _id()
conn.execute(
"INSERT INTO moop_model_type (id, type) VALUES (?, ?)", (tid, type_name)
)
return tid
def add_type(path=None, set_id=None, type_name=""):
if set_id is None:
return
conn = _connect(path)
try:
tid = _type_id(conn, type_name)
exists = conn.execute(
'SELECT id FROM moop_model_set_type WHERE "set" = ? AND type = ?',
(set_id, tid),
).fetchone()
if not exists:
conn.execute(
'INSERT INTO moop_model_set_type (id, "set", type) VALUES (?, ?, ?)',
(_id(), set_id, tid),
)
conn.commit()
_ensure_defaults_conn(conn)
conn.commit()
finally:
conn.close()
def remove_type(path=None, set_id=None, type_name=""):
if set_id is None:
return
conn = _connect(path)
try:
tid = conn.execute(
"SELECT id FROM moop_model_type WHERE type = ?", (type_name,)
).fetchone()
if not tid:
return
conn.execute(
'DELETE FROM moop_model_set_type WHERE "set" = ? AND type = ?',
(set_id, tid["id"]),
)
conn.commit()
_ensure_defaults_conn(conn)
conn.commit()
finally:
conn.close()
# -------------------------------------------------------------- default ---
def set_default(path=None, type_name="", set_id=None):
"""Set `set_id` sebagai default untuk `type_name`. Jika set belum punya
type tersebut, type otomatis ditambahkan."""
if set_id is None or not type_name:
return
conn = _connect(path)
try:
tid = _type_id(conn, type_name)
st = conn.execute(
'SELECT id FROM moop_model_set_type WHERE "set" = ? AND type = ?',
(set_id, tid),
).fetchone()
if not st:
st_id = _id()
conn.execute(
'INSERT INTO moop_model_set_type (id, "set", type) VALUES (?, ?, ?)',
(st_id, set_id, tid),
)
else:
st_id = st["id"]
conn.execute("DELETE FROM moop_model_type_default WHERE type = ?", (tid,))
conn.execute(
"INSERT INTO moop_model_type_default (id, type, set_type) VALUES (?, ?, ?)",
(_id(), tid, st_id),
)
conn.commit()
finally:
conn.close()
def get_default_set_id(path=None, type_name=""):
conn = _connect(path)
try:
r = conn.execute(
'SELECT s.id AS id FROM moop_model_type_default d '
'JOIN moop_model_type mt ON mt.id = d.type '
'JOIN moop_model_set_type st ON st.id = d.set_type '
'JOIN moop_model_set s ON s.id = st."set" '
'WHERE mt.type = ?',
(type_name,),
).fetchone()
return r["id"] if r else None
finally:
conn.close()
# -------------------------------------------------------------- provider ---
def list_apis(path=None):
conn = _connect(path)
try:
rows = conn.execute(
"SELECT a.id, a.name, a.baseurl, "
"(SELECT COUNT(*) FROM moop_key k WHERE k.api = a.id) AS key_count, "
"(SELECT COUNT(*) FROM moop_model m WHERE m.api = a.id) AS model_count "
"FROM moop_api a ORDER BY a.name COLLATE NOCASE"
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
def get_api(path=None, api_id=None):
if api_id is None:
return None
conn = _connect(path)
try:
r = conn.execute(
"SELECT id, name, baseurl FROM moop_api WHERE id = ?", (api_id,)
).fetchone()
return dict(r) if r else None
finally:
conn.close()
def create_api(path=None, name="", baseurl=""):
conn = _connect(path)
try:
api_id = _id()
conn.execute(
"INSERT INTO moop_api (id, name, baseurl) VALUES (?, ?, ?)",
(api_id, (name or "").strip(), (baseurl or "").strip()),
)
conn.commit()
return api_id
finally:
conn.close()
def update_api(path=None, api_id=None, name="", baseurl=""):
if api_id is None:
return
conn = _connect(path)
try:
conn.execute(
"UPDATE moop_api SET name = ?, baseurl = ? WHERE id = ?",
((name or "").strip(), (baseurl or "").strip(), api_id),
)
conn.commit()
finally:
conn.close()
def delete_api(path=None, api_id=None):
if api_id is None:
return
conn = _connect(path)
try:
conn.execute("DELETE FROM moop_api WHERE id = ?", (api_id,))
conn.commit()
finally:
conn.close()
# ------------------------------------------------------------------ keys ---
def list_keys(path=None, api_id=None):
conn = _connect(path)
try:
rows = conn.execute(
"SELECT id, key, priority FROM moop_key WHERE api = ? "
"ORDER BY priority IS NULL ASC, priority ASC, rowid ASC",
(api_id,),
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
def add_key(path=None, api_id=None, key="", priority=None):
if api_id is None:
return
conn = _connect(path)
try:
conn.execute(
"INSERT INTO moop_key (id, key, priority, api) VALUES (?, ?, ?, ?)",
(_id(), key or "", priority, api_id),
)
conn.commit()
finally:
conn.close()
def update_key(path=None, key_id=None, key="", priority=None):
if key_id is None:
return
conn = _connect(path)
try:
conn.execute(
"UPDATE moop_key SET key = ?, priority = ? WHERE id = ?",
(key or "", priority, key_id),
)
conn.commit()
finally:
conn.close()
def delete_key(path=None, key_id=None):
if key_id is None:
return
conn = _connect(path)
try:
conn.execute("DELETE FROM moop_key WHERE id = ?", (key_id,))
conn.commit()
finally:
conn.close()
# ----------------------------------------------------------------- models ---
def list_models(path=None, set_id=None):
conn = _connect(path)
try:
rows = conn.execute(
'SELECT m.id AS model_id, m.model, m.priority, '
'a.id AS api_id, a.name AS provider, a.baseurl AS baseurl '
'FROM moop_model m JOIN moop_api a ON a.id = m.api '
'WHERE m."set" = ? '
'ORDER BY m.priority IS NULL ASC, m.priority ASC, m.rowid ASC',
(set_id,),
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
def add_model(path=None, set_id=None, api_id=None, model="", priority=None):
if set_id is None or api_id is None:
return
conn = _connect(path)
try:
conn.execute(
'INSERT INTO moop_model (id, api, "set", model, priority) '
"VALUES (?, ?, ?, ?, ?)",
(_id(), api_id, set_id, model or "", priority),
)
conn.commit()
finally:
conn.close()
def update_model(path=None, model_id=None, model="", priority=None):
if model_id is None:
return
conn = _connect(path)
try:
conn.execute(
"UPDATE moop_model SET model = ?, priority = ? WHERE id = ?",
(model or "", priority, model_id),
)
conn.commit()
finally:
conn.close()
def delete_model(path=None, model_id=None):
if model_id is None:
return
conn = _connect(path)
try:
conn.execute("DELETE FROM moop_model WHERE id = ?", (model_id,))
conn.commit()
finally:
conn.close()
# ----------------------------------------------------------------- chain ---
def resolve_chain(path=None, set_id=None):
"""Bangun chain kandidat untuk satu model set.
Urutan: model diurutkan berdasarkan priority (NULL = disabled), lalu untuk
tiap model, semua key provider-nya (priority, NULL = disabled). Provider
tanpa key tetap jadi 1 kandidat dengan api_key kosong (= no bearer).
Setiap kandidat membawa (api_id, model_id) untuk membedakan rotasi key."""
if set_id is None:
return []
conn = _connect(path)
try:
models = conn.execute(
'SELECT m.id AS model_id, m.model, '
'a.id AS api_id, a.name AS provider, a.baseurl AS base_url '
'FROM moop_model m JOIN moop_api a ON a.id = m.api '
'WHERE m."set" = ? AND m.priority IS NOT NULL '
'ORDER BY m.priority ASC, m.rowid ASC',
(set_id,),
).fetchall()
candidates = []
for m in models:
keys = conn.execute(
"SELECT key FROM moop_key WHERE api = ? AND priority IS NOT NULL "
"ORDER BY priority ASC, rowid ASC",
(m["api_id"],),
).fetchall()
if keys:
for k in keys:
candidates.append({
"base_url": m["base_url"],
"model": m["model"],
"api_key": k["key"],
"api_id": m["api_id"],
"model_id": m["model_id"],
"provider": m["provider"],
})
else:
candidates.append({
"base_url": m["base_url"],
"model": m["model"],
"api_key": "",
"api_id": m["api_id"],
"model_id": m["model_id"],
"provider": m["provider"],
})
return candidates
finally:
conn.close()
def default_chain(path=None, type_name=""):
"""Chain untuk default model set dari tipe tertentu."""
set_id = get_default_set_id(path, type_name)
if not set_id:
return []
return resolve_chain(path, set_id)
def resolve_provider(path=None, base_url="", model=""):
"""Cari nama provider untuk (base_url, model) di chain default semua tipe."""
base_url = (base_url or "").rstrip("/")
for t in MODEL_TYPES:
for cand in default_chain(path, t):
if (cand["base_url"] or "").rstrip("/") == base_url and cand["model"] == model:
return cand["provider"]
return None
def configure_client(llm_client, path=None):
"""Isi chain llm + imagevision ke LLMClient berdasarkan default set.
Mengembalikan True jika chain default 'llm' tersedia."""
p = os.path.expanduser(path or moop_db_path())
init_db(p)
chains = {
"llm": default_chain(p, "llm"),
"imagevision": default_chain(p, "imagevision"),
}
llm_client.set_chains(chains)
return bool(chains["llm"])
def first_endpoint(path=None, type_name=""):
"""Kandidat pertama (base_url, model, api_key) dari chain default tipe.
Mengembalikan None jika tidak ada."""
chain = default_chain(path, type_name)
if not chain:
return None
c = chain[0]
return c["base_url"], c["model"], c.get("api_key")
def embedding_endpoint(path=None):
"""Endpoint embedding (url, model, api_key) dari default chain 'embedding'."""
return first_endpoint(path, "embedding")
def imagegen_endpoint(path=None):
"""Endpoint image generation (url, model, api_key) dari default chain 'imagegen'."""
return first_endpoint(path, "imagegen")

View File

@ -1,5 +1,7 @@
import requests, gc, lancedb, pyarrow, uuid
from datetime import datetime
from lib import moop
import config
def _schema_user(vector_size):
return pyarrow.schema([
@ -118,6 +120,13 @@ def ensure_table(db_path, table_name, vector_size): # table_ensure seharusnya
def _uuid(val):
return uuid.UUID(val) if isinstance(val, str) else val
def embedding_endpoint():
"""Endpoint embedding: default chain moop ('embedding') > fallback yaml."""
ep = moop.embedding_endpoint()
if ep:
return ep[0], ep[1]
return config.ragroleplay_model_url, config.ragroleplay_model_name
def embed_text(url, model, text):
response = requests.post(url=url, json={"model": model, "input": text} )
data = response.json()

View File

@ -42,3 +42,5 @@ Karena imagevision bisa termasuk di llm umum atau bisa tidak termasuk di llm umu
- result dapat dari tools tanpa fallback dari model imagevision
- switch ke model llm umum
- llm memberi fallback
Jika set model `llm` belum ada di default, berikan pesan instruksi untuk setting model terlebih dahulu sebelum menggunakan.

View File

@ -1,8 +1,22 @@
import json
import socket
from lib import gadget
import urllib.request
import urllib.error
class _LLMFail(Exception):
"""Kegagalan network dari satu kandidat chain.
kind:
- 'auth' : 401/403 rotasi key (coba key berikutnya di provider yang sama)
- 'unreachable' : koneksi gagal / timeout / 5xx / 429 / 404 pindah model/provider
"""
def __init__(self, kind, message):
super().__init__(message)
self.kind = kind
self.message = message
class LLMClient:
class Message:
def __init__(self, msg):
@ -12,16 +26,160 @@ class LLMClient:
self.warning = None
def __init__(self, base_url, model, api_key, timeout=600):
self.base_url = base_url.rstrip('/')
self.base_url = (base_url or "").rstrip('/')
self.model = model
self.api_key = api_key
self.timeout = timeout
self.cancel_requested = False
# Chain kandidat per tipe ('llm', 'imagevision', ...)
self._chains: dict[str, list[dict]] = {}
self._chain: list[dict] | None = None
self._chain_key: str | None = None
self._chain_index = 0
# ------------------------------------------------------------ config ---
def set_chains(self, chains):
"""Set chain kandidat per tipe. Memperbarui base_url/model/api_key dari
kandidat pertama chain 'llm' (atau 'imagevision' sebagai fallback)."""
self._chains = chains or {}
self._chain = None
self._chain_key = None
self._chain_index = 0
llm = self._chains.get("llm") or []
vision = self._chains.get("imagevision") or []
if llm:
self._apply_candidate(llm[0])
elif vision:
self._apply_candidate(vision[0])
else:
self.base_url = ""
self.model = ""
self.api_key = ""
def has_chain(self, type_name="llm"):
return bool(self._chains.get(type_name))
def _apply_candidate(self, cand):
self.base_url = (cand.get("base_url") or "").rstrip('/')
self.model = cand.get("model", "")
self.api_key = cand.get("api_key", "")
# ----------------------------------------------------- chain selection ---
def _pick_chain_type(self, messages):
"""Kalau ada konten gambar (image_url), pakai chain imagevision."""
for m in messages or []:
content = m.get("content")
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "image_url":
return "imagevision"
return "llm"
def chat(self, messages, tools=None, on_stream_chunk=None, disable_reasoning=False, tool_reminder=None):
url = f"{self.base_url}/chat/completions"
if not self._chains:
return self._chat_once(
self.base_url, self.model, self.api_key, messages,
tools=tools, on_stream_chunk=on_stream_chunk,
disable_reasoning=disable_reasoning, tool_reminder=tool_reminder,
)
chain_type = self._pick_chain_type(messages)
if chain_type != self._chain_key:
self._chain_key = chain_type
self._chain_index = 0
chain = self._chains.get(chain_type) or []
self._chain = chain
if not chain:
# Vision diminta tapi tidak ada chain imagevision → pakai llm.
if chain_type == "imagevision" and self._chains.get("llm"):
chain = self._chains["llm"]
self._chain = chain
self._chain_key = "llm"
else:
return self.Message({
'content': "Error: Tidak ada model yang dikonfigurasi untuk permintaan ini.",
'tool_calls': None,
})
return self._chat_chain(
messages, tools=tools, on_stream_chunk=on_stream_chunk,
disable_reasoning=disable_reasoning, tool_reminder=tool_reminder,
)
# ---------------------------------------------------------- chain loop ---
@staticmethod
def _same_target(a, b):
return a.get("api_id") == b.get("api_id") and a.get("model_id") == b.get("model_id")
@staticmethod
def _next_target(chain, idx):
"""Lompat ke kandidat model/provider berikutnya (skip sisa key)."""
api = chain[idx].get("api_id")
model = chain[idx].get("model_id")
j = idx + 1
while j < len(chain) and chain[j].get("api_id") == api and chain[j].get("model_id") == model:
j += 1
return j
def _chat_chain(self, messages, tools=None, on_stream_chunk=None,
disable_reasoning=False, tool_reminder=None):
chain = self._chain or []
if not chain:
return self.Message({'content': "Error: Tidak ada model yang dikonfigurasi.", 'tool_calls': None})
idx = min(max(self._chain_index, 0), len(chain) - 1)
last = None
while idx < len(chain):
cand = chain[idx]
self._apply_candidate(cand)
try:
result = self._chat_once(
cand["base_url"], cand["model"], cand["api_key"], messages,
tools=tools, on_stream_chunk=on_stream_chunk,
disable_reasoning=disable_reasoning, tool_reminder=tool_reminder,
)
self._chain_index = idx
return result
except _LLMFail as e:
last = e
if e.kind == "auth" and idx + 1 < len(chain) and self._same_target(chain[idx + 1], cand):
idx += 1 # rotasi key
else:
idx = self._next_target(chain, idx) # pindah model/provider
# Image vision gagal total → fallback ke model llm umum.
if self._chain_key == "imagevision":
fb = self._chains.get("llm") or []
if fb:
self._chain = fb
self._chain_key = "llm"
self._chain_index = 0
result = self._chat_chain(
messages, tools=tools, on_stream_chunk=on_stream_chunk,
disable_reasoning=disable_reasoning, tool_reminder=tool_reminder,
)
result.warning = (f"{result.warning} " if result.warning else "") + \
"Image vision model gagal, fallback ke LLM umum."
return result
msg = "Error: Semua opsi model gagal."
if last:
msg += f"\n{last.message}"
return self.Message({'content': msg, 'tool_calls': None})
# ------------------------------------------------------- single attempt ---
def _chat_once(self, base_url, model, api_key, messages, tools=None,
on_stream_chunk=None, disable_reasoning=False, tool_reminder=None):
url = f"{base_url.rstrip('/')}/chat/completions"
payload = {
"model": self.model,
"model": model,
"messages": messages,
"stream": True # Enable streaming
}
@ -45,13 +203,15 @@ class LLMClient:
data = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(url, data=data, method='POST')
req.add_header('Content-Type', 'application/json')
req.add_header('Authorization', f'Bearer {self.api_key}')
if api_key:
req.add_header('Authorization', f'Bearer {api_key}')
# Variabel untuk mengumpulkan hasil
full_content = ""
full_tool_calls = []
reasoning_content = ""
full_multimodal_content = []
stream_started = False
try:
self.cancel_requested = False
@ -114,6 +274,8 @@ class LLMClient:
chunk = delta['content']
if isinstance(chunk, str):
full_content += chunk or ""
if chunk:
stream_started = True
if on_stream_chunk and chunk:
on_stream_chunk(chunk)
elif isinstance(chunk, list):
@ -123,6 +285,7 @@ class LLMClient:
t = block.get('text', '')
if t:
full_content += t
stream_started = True
if on_stream_chunk:
on_stream_chunk(t)
@ -171,57 +334,62 @@ class LLMClient:
try:
body = json.loads(body_text) if body_text else {}
if 'tool use' in body.get('error', {}).get('message', '').lower():
result = self.chat(messages, tools=None)
result = self._chat_once(
base_url, model, api_key, messages, tools=None,
on_stream_chunk=on_stream_chunk,
disable_reasoning=disable_reasoning,
)
result.warning = "Tool calling not supported by this model. Running in chat-only mode."
return result
except Exception:
pass
detail = f" - {body_text[:500]}" if body_text else ""
return self.Message({'content': f"HTTP Error: {e.code} {e.reason}{detail}", 'tool_calls': None})
if e.code in (401, 403):
raise _LLMFail("auth", f"HTTP {e.code} {e.reason}{detail}")
raise _LLMFail("unreachable", f"HTTP {e.code} {e.reason}{detail}")
except urllib.error.URLError as e:
if stream_started:
return self.Message({'content': f"Error: {e.reason}", 'tool_calls': None})
raise _LLMFail("unreachable", f"Connection error: {e.reason}")
except (TimeoutError, socket.timeout) as e:
if stream_started:
return self.Message({'content': f"Error: Timeout", 'tool_calls': None})
raise _LLMFail("unreachable", f"Timeout")
except Exception as e:
return self.Message({'content': f"Error: {str(e)}", 'tool_calls': None})
if stream_started:
return self.Message({'content': f"Error: {str(e)}", 'tool_calls': None})
raise _LLMFail("unreachable", f"Error: {str(e)}")
if 'choices' not in response:
raw_preview = json.dumps(response)[:500]
return self.Message({
'content': (
f"Error: Unexpected response — 'choices' key missing.\n"
f" URL : {url}\n"
f" Model : {self.model}\n"
f" Response: {raw_preview}"
),
'tool_calls': None
})
raise _LLMFail("unreachable", (
f"Unexpected response — 'choices' key missing.\n"
f" URL : {url}\n"
f" Model : {model}\n"
f" Response: {raw_preview}"
))
if not response['choices']:
raw_preview = json.dumps(response)[:500]
return self.Message({
'content': (
f"Error: 'choices' is empty in the response.\n"
f" URL : {url}\n"
f" Model : {self.model}\n"
f" Response: {raw_preview}"
),
'tool_calls': None
})
raise _LLMFail("unreachable", (
f"'choices' is empty in the response.\n"
f" URL : {url}\n"
f" Model : {model}\n"
f" Response: {raw_preview}"
))
if 'message' not in response['choices'][0]:
raw_preview = json.dumps(response['choices'][0])[:500]
return self.Message({
'content': (
f"Error: 'message' key missing in first choice.\n"
f" URL : {url}\n"
f" Model : {self.model}\n"
f" Choice : {raw_preview}"
),
'tool_calls': None
})
raise _LLMFail("unreachable", (
f"'message' key missing in first choice.\n"
f" URL : {url}\n"
f" Model : {model}\n"
f" Choice : {raw_preview}"
))
message = response['choices'][0]['message']
# Handle reasoning_content field dari OpenRouter/models yang support thinking
# Pindahkan ke content jangan sampai keluar
reasoning_content = message.pop('reasoning_content', None)
reasoning_field = message.pop('reasoning', None)
# Jangan inject reasoning ke content — buang saja
# (kita sudah strip via _strip_thinking di Message.__init__)
message.pop('reasoning_content', None)
message.pop('reasoning', None)
return self.Message(message)

View File

@ -4,7 +4,7 @@ import time
import base64
import urllib.request
import urllib.error
import config
from lib import moop
schema_generate_image = {
"type": "function",
@ -46,64 +46,80 @@ schema_generate_image = {
}
def generate_image(prompt, output_path, model=None, aspect_ratio=None, resolution=None, input_images=None):
if not config.IMAGEGEN_API_KEY:
return "Error: No OpenRouter API key configured. Set imagegen.provider in config.yaml"
if not config.IMAGEGEN_MODEL and not model:
return "Error: No image model configured. Set imagegen.default_model in config.yaml or specify model in tool call"
def _images_url(base_url):
base_url = (base_url or "").rstrip("/")
if base_url.endswith("/images"):
return base_url
return base_url + "/images"
model = model or config.IMAGEGEN_MODEL
payload = {"model": model, "prompt": prompt}
if aspect_ratio:
payload["aspect_ratio"] = aspect_ratio
if resolution:
payload["resolution"] = resolution
# Build input_references dari paths/URLs
if input_images:
references = []
for img_ref in input_images:
img_ref = img_ref.strip()
if not img_ref:
continue
if img_ref.startswith("http://") or img_ref.startswith("https://"):
references.append({"type": "image_url", "image_url": {"url": img_ref}})
else:
full = os.path.expanduser(img_ref)
if not os.path.isfile(full):
return f"Error: reference image not found: {full}"
with open(full, "rb") as f:
b64 = base64.b64encode(f.read()).decode("ascii")
ref_ext = os.path.splitext(full)[1].lower()
ref_mime = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp"}.get(ref_ext, "image/png")
references.append({"type": "image_url", "image_url": {"url": f"data:{ref_mime};base64,{b64}"}})
if references:
payload["input_references"] = references
def _post_images(url, api_key, payload):
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
"https://openrouter.ai/api/v1/images",
data=data,
method="POST",
)
req = urllib.request.Request(url, data=data, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", f"Bearer {config.IMAGEGEN_API_KEY}")
req.add_header("Authorization", f"Bearer {api_key}")
with urllib.request.urlopen(req, timeout=120) as resp:
return json.loads(resp.read().decode("utf-8"))
try:
with urllib.request.urlopen(req, timeout=120) as resp:
result = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
return f"Error: HTTP {e.code} - {body[:500]}"
except Exception as e:
return f"Error: {e}"
images = result.get("data", [])
if not images:
return f"Error: No images in response - {json.dumps(result)[:500]}"
def generate_image(prompt, output_path, model=None, aspect_ratio=None, resolution=None, input_images=None):
chain = moop.default_chain(None, "imagegen")
if not chain:
return "Error: No imagegen model set configured. Atur via Model > Manage Sets (Model Option)."
img = images[0]
candidates = chain
if model:
candidates = [c for c in chain if c["model"] == model] or [chain[0]]
last_err = ""
for cand in candidates:
url = _images_url(cand["base_url"])
key = cand.get("api_key") or ""
payload = {"model": cand["model"], "prompt": prompt}
if aspect_ratio:
payload["aspect_ratio"] = aspect_ratio
if resolution:
payload["resolution"] = resolution
# Build input_references dari paths/URLs
if input_images:
references = []
for img_ref in input_images:
img_ref = img_ref.strip()
if not img_ref:
continue
if img_ref.startswith("http://") or img_ref.startswith("https://"):
references.append({"type": "image_url", "image_url": {"url": img_ref}})
else:
full = os.path.expanduser(img_ref)
if not os.path.isfile(full):
return f"Error: reference image not found: {full}"
with open(full, "rb") as f:
b64 = base64.b64encode(f.read()).decode("ascii")
ref_ext = os.path.splitext(full)[1].lower()
ref_mime = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp"}.get(ref_ext, "image/png")
references.append({"type": "image_url", "image_url": {"url": f"data:{ref_mime};base64,{b64}"}})
if references:
payload["input_references"] = references
try:
result = _post_images(url, key, payload)
images = result.get("data", [])
if not images:
last_err = f"Error: No images in response - {json.dumps(result)[:500]}"
continue
return _save_image(result, images[0], output_path)
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
last_err = f"Error: HTTP {e.code} - {body[:500]}"
except Exception as e:
last_err = f"Error: {e}"
return last_err
def _save_image(result, img, output_path):
img_bytes = base64.b64decode(img["b64_json"])
# Detect actual format from magic bytes

View File

@ -2,6 +2,8 @@ import gc, sys, uuid, lancedb
import config, lib.ragroleplay as ragroleplay_lib
from lib import personality
_emb_url, _emb_model = ragroleplay_lib.embedding_endpoint()
def _uuid(val):
if isinstance(val, str):
return uuid.UUID(val)
@ -35,8 +37,8 @@ def users_store(fullname, nickname, character, id=None, alias=None, salutation=N
try:
success = ragroleplay_lib.users_store(
config.ragroleplay_db_path,
config.ragroleplay_model_url,
config.ragroleplay_model_name,
_emb_url,
_emb_model,
fullname,
nickname,
alias,
@ -531,7 +533,7 @@ def memories_check(prompt_text, search_limit):
try:
db = lancedb.connect(config.ragroleplay_db_path)
table = db.open_table("knowledge_memories")
query_vector = ragroleplay_lib.embed_text(config.ragroleplay_model_url, config.ragroleplay_model_name, prompt_text)
query_vector = ragroleplay_lib.embed_text(_emb_url, _emb_model, prompt_text)
results = table.search(query_vector, vector_column_name="vector_context").limit(search_limit).to_list()
if not results:
@ -566,8 +568,8 @@ def memories_store(character, user, event, category, detail, physical, emotional
try:
success = ragroleplay_lib.memories_store(
config.ragroleplay_db_path,
config.ragroleplay_model_url,
config.ragroleplay_model_name,
_emb_url,
_emb_model,
character,
_uuid(user),
event,
@ -609,8 +611,8 @@ def memories_summarize(prompt_text, search_limit=5):
result = ragroleplay_lib.memories_summarize(
config.ragroleplay_db_path,
prompt_text,
config.ragroleplay_model_url,
config.ragroleplay_model_name,
_emb_url,
_emb_model,
search_limit
)
return result
@ -644,8 +646,8 @@ def memories_update(memory_id, **updates):
try:
success = ragroleplay_lib.memories_update(
config.ragroleplay_db_path,
config.ragroleplay_model_url,
config.ragroleplay_model_name,
_emb_url,
_emb_model,
_uuid(memory_id),
**updates
)
@ -700,8 +702,8 @@ def users_update(user_id, **updates):
try:
success = ragroleplay_lib.users_update(
config.ragroleplay_db_path,
config.ragroleplay_model_url,
config.ragroleplay_model_name,
_emb_url,
_emb_model,
_uuid(user_id),
**updates
)
@ -725,7 +727,7 @@ def users_delete(user_id):
# --- OBJECTS IMPLEMENTATION ---
def objects_store(keyword, when, where, name, description, shape, usage):
try:
success = ragroleplay_lib.objects_store(config.ragroleplay_db_path, config.ragroleplay_model_url, config.ragroleplay_model_name, keyword, when, where, name, description, shape, usage)
success = ragroleplay_lib.objects_store(config.ragroleplay_db_path, _emb_url, _emb_model, keyword, when, where, name, description, shape, usage)
return "Berhasil menyimpan objek baru." if success else "Gagal menyimpan objek."
except Exception as e: return f"Error: {str(e)}"
@ -741,7 +743,7 @@ def objects_filter(keyword=None, object_id=None):
def objects_update(object_id, **updates):
try:
success = ragroleplay_lib.objects_update(config.ragroleplay_db_path, config.ragroleplay_model_url, config.ragroleplay_model_name, _uuid(object_id), **updates)
success = ragroleplay_lib.objects_update(config.ragroleplay_db_path, _emb_url, _emb_model, _uuid(object_id), **updates)
return "Berhasil memperbarui objek." if success else "Gagal memperbarui objek."
except Exception as e: return f"Error: {str(e)}"
@ -754,7 +756,7 @@ def objects_delete(object_id):
# --- OUTFIT IMPLEMENTATION ---
def outfits_store(keyword, when, outfit):
try:
success = ragroleplay_lib.outfits_store(config.ragroleplay_db_path, config.ragroleplay_model_url, config.ragroleplay_model_name, keyword, when, outfit)
success = ragroleplay_lib.outfits_store(config.ragroleplay_db_path, _emb_url, _emb_model, keyword, when, outfit)
return "Berhasil menyimpan outfit baru." if success else "Gagal menyimpan outfit."
except Exception as e: return f"Error: {str(e)}"
@ -770,7 +772,7 @@ def outfits_filter(keyword=None, outfit_id=None):
def outfits_update(outfit_id, **updates):
try:
success = ragroleplay_lib.outfits_update(config.ragroleplay_db_path, config.ragroleplay_model_url, config.ragroleplay_model_name, _uuid(outfit_id), **updates)
success = ragroleplay_lib.outfits_update(config.ragroleplay_db_path, _emb_url, _emb_model, _uuid(outfit_id), **updates)
return "Berhasil memperbarui outfit." if success else "Gagal memperbarui outfit."
except Exception as e: return f"Error: {str(e)}"
@ -783,7 +785,7 @@ def outfits_delete(outfit_id):
# --- TODO IMPLEMENTATION ---
def todos_store(keyword, when, do):
try:
success = ragroleplay_lib.todos_store(config.ragroleplay_db_path, config.ragroleplay_model_url, config.ragroleplay_model_name, keyword, when, do)
success = ragroleplay_lib.todos_store(config.ragroleplay_db_path, _emb_url, _emb_model, keyword, when, do)
return "Berhasil menyimpan to-do baru." if success else "Gagal menyimpan to-do."
except Exception as e: return f"Error: {str(e)}"
@ -799,7 +801,7 @@ def todos_filter(keyword=None, todo_id=None):
def todos_update(todo_id, **updates):
try:
success = ragroleplay_lib.todos_update(config.ragroleplay_db_path, config.ragroleplay_model_url, config.ragroleplay_model_name, _uuid(todo_id), **updates)
success = ragroleplay_lib.todos_update(config.ragroleplay_db_path, _emb_url, _emb_model, _uuid(todo_id), **updates)
return "Berhasil memperbarui to-do." if success else "Gagal memperbarui to-do."
except Exception as e: return f"Error: {str(e)}"
@ -812,7 +814,7 @@ def todos_delete(todo_id):
# --- WORLD IMPLEMENTATION ---
def worlds_store(category, location, description):
try:
success = ragroleplay_lib.worlds_store(config.ragroleplay_db_path, config.ragroleplay_model_url, config.ragroleplay_model_name, category, location, description)
success = ragroleplay_lib.worlds_store(config.ragroleplay_db_path, _emb_url, _emb_model, category, location, description)
return "Berhasil menyimpan lokasi dunia baru." if success else "Gagal menyimpan lokasi dunia."
except Exception as e: return f"Error: {str(e)}"
@ -828,7 +830,7 @@ def worlds_filter(category=None, location=None, world_id=None):
def worlds_update(world_id, **updates):
try:
success = ragroleplay_lib.worlds_update(config.ragroleplay_db_path, config.ragroleplay_model_url, config.ragroleplay_model_name, _uuid(world_id), **updates)
success = ragroleplay_lib.worlds_update(config.ragroleplay_db_path, _emb_url, _emb_model, _uuid(world_id), **updates)
return "Berhasil memperbarui lokasi dunia." if success else "Gagal memperbarui lokasi dunia."
except Exception as e: return f"Error: {str(e)}"