Model Option (moop)
This commit is contained in:
parent
1f876a00c8
commit
6cdb69336b
79
config.py
79
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@ -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"
|
||||
|
||||
13
hendrik.py
13
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)
|
||||
|
||||
|
||||
@ -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"),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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
509
interfaces/tui/moopui.py
Normal 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
665
lib/moop.py
Normal 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")
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
- llm memberi fallback
|
||||
|
||||
Jika set model `llm` belum ada di default, berikan pesan instruksi untuk setting model terlebih dahulu sebelum menggunakan.
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)}"
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user