From 6cdb69336ba16fdf093783fde95396a5c838e7b2 Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Thu, 13 Aug 2026 21:06:42 +0700 Subject: [PATCH] Model Option (moop) --- config.py | 79 +---- default-config.yaml | 51 +-- hendrik.py | 13 +- interfaces/tui/actions.py | 19 +- interfaces/tui/agent.py | 3 +- interfaces/tui/app.py | 23 +- interfaces/tui/input.py | 111 +------ interfaces/tui/moopui.py | 509 +++++++++++++++++++++++++++++ lib/moop.py | 665 ++++++++++++++++++++++++++++++++++++++ lib/ragroleplay.py | 9 + plan/{ => done}/moop.md | 4 +- services/llm_client.py | 244 +++++++++++--- tools/imagegen.py | 120 ++++--- tools/ragroleplay.py | 40 +-- 14 files changed, 1547 insertions(+), 343 deletions(-) create mode 100644 interfaces/tui/moopui.py create mode 100644 lib/moop.py rename plan/{ => done}/moop.md (93%) diff --git a/config.py b/config.py index 6a6d5dc..6e24bce 100644 --- a/config.py +++ b/config.py @@ -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 ────────────────────────────────────────────────────────── diff --git a/default-config.yaml b/default-config.yaml index c272266..55441d1 100644 --- a/default-config.yaml +++ b/default-config.yaml @@ -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" diff --git a/hendrik.py b/hendrik.py index 3cc341a..fc9fe96 100644 --- a/hendrik.py +++ b/hendrik.py @@ -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) diff --git a/interfaces/tui/actions.py b/interfaces/tui/actions.py index 0afee3e..73c11e5 100644 --- a/interfaces/tui/actions.py +++ b/interfaces/tui/actions.py @@ -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"), ], }, { diff --git a/interfaces/tui/agent.py b/interfaces/tui/agent.py index c5ac602..2220350 100644 --- a/interfaces/tui/agent.py +++ b/interfaces/tui/agent.py @@ -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: diff --git a/interfaces/tui/app.py b/interfaces/tui/app.py index ce49523..16be608 100644 --- a/interfaces/tui/app.py +++ b/interfaces/tui/app.py @@ -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, } diff --git a/interfaces/tui/input.py b/interfaces/tui/input.py index fac6ae0..2e12f7f 100644 --- a/interfaces/tui/input.py +++ b/interfaces/tui/input.py @@ -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() diff --git a/interfaces/tui/moopui.py b/interfaces/tui/moopui.py new file mode 100644 index 0000000..c457166 --- /dev/null +++ b/interfaces/tui/moopui.py @@ -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]) diff --git a/lib/moop.py b/lib/moop.py new file mode 100644 index 0000000..ba38c26 --- /dev/null +++ b/lib/moop.py @@ -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") diff --git a/lib/ragroleplay.py b/lib/ragroleplay.py index 326f33a..0302697 100644 --- a/lib/ragroleplay.py +++ b/lib/ragroleplay.py @@ -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() diff --git a/plan/moop.md b/plan/done/moop.md similarity index 93% rename from plan/moop.md rename to plan/done/moop.md index ca1c150..0b31e1e 100644 --- a/plan/moop.md +++ b/plan/done/moop.md @@ -41,4 +41,6 @@ Karena imagevision bisa termasuk di llm umum atau bisa tidak termasuk di llm umu - pas panggil tools vision (imagevision model) - result dapat dari tools tanpa fallback dari model imagevision - switch ke model llm umum -- llm memberi fallback \ No newline at end of file +- llm memberi fallback + +Jika set model `llm` belum ada di default, berikan pesan instruksi untuk setting model terlebih dahulu sebelum menggunakan. diff --git a/services/llm_client.py b/services/llm_client.py index dfecc64..9971ebd 100644 --- a/services/llm_client.py +++ b/services/llm_client.py @@ -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) diff --git a/tools/imagegen.py b/tools/imagegen.py index 1e34883..091f1aa 100644 --- a/tools/imagegen.py +++ b/tools/imagegen.py @@ -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 diff --git a/tools/ragroleplay.py b/tools/ragroleplay.py index 291f70d..30011a1 100644 --- a/tools/ragroleplay.py +++ b/tools/ragroleplay.py @@ -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)}"