hendrik/lib/moop.py

666 lines
20 KiB
Python

# 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")