owui-cli 0.5.1__tar.gz → 0.5.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: owui-cli
3
- Version: 0.5.1
3
+ Version: 0.5.3
4
4
  Summary: Admin CLI for Open WebUI instances
5
5
  Project-URL: Homepage, https://github.com/rndmcnlly/owui-cli
6
6
  Project-URL: Repository, https://github.com/rndmcnlly/owui-cli
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "owui-cli"
3
- version = "0.5.1"
3
+ version = "0.5.3"
4
4
  description = "Admin CLI for Open WebUI instances"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1 @@
1
+ __version__ = "0.5.3"
@@ -15,6 +15,7 @@ import json
15
15
  import os
16
16
  import re
17
17
  import sys
18
+ import time
18
19
  from importlib.resources import files
19
20
 
20
21
  import httpx
@@ -602,20 +603,56 @@ def models_show(url, token, model_id):
602
603
  ("grants", str(len(m.get("access_grants") or [])))]
603
604
  out_kv(pairs)
604
605
 
606
+ _MIME_BY_EXT = {
607
+ "png": "image/png",
608
+ "jpg": "image/jpeg",
609
+ "jpeg": "image/jpeg",
610
+ "gif": "image/gif",
611
+ "webp": "image/webp",
612
+ }
613
+
614
+
615
+ def _inline_sibling_images(json_path: str, payload: dict) -> list[str]:
616
+ """Replace bare sibling-image references in meta.profile_image_url with data URIs.
617
+
618
+ The repo convention stores the icon as a sibling file referenced by bare
619
+ filename (e.g. "profile.png"). OWUI's stored-model validator (utils/validate.py)
620
+ rejects bare filenames and silently clears them to null, so push must inline
621
+ the sibling image instead — the mirror of models_pull_all's extraction.
622
+ Returns the list of inlined filenames.
623
+ """
624
+ inlined = []
625
+ meta = payload.get("meta") if isinstance(payload, dict) else None
626
+ ref = (meta or {}).get("profile_image_url")
627
+ if not isinstance(ref, str) or not ref or ref.startswith(("data:", "http://", "https://", "/")):
628
+ return inlined
629
+ image_path = os.path.join(os.path.dirname(os.path.abspath(json_path)), ref)
630
+ ext = os.path.splitext(ref)[1].lstrip(".").lower()
631
+ mime = _MIME_BY_EXT.get(ext)
632
+ if mime and os.path.isfile(image_path):
633
+ with open(image_path, "rb") as f:
634
+ b64 = base64.b64encode(f.read()).decode()
635
+ payload["meta"]["profile_image_url"] = f"data:{mime};base64,{b64}"
636
+ inlined.append(ref)
637
+ return inlined
638
+
639
+
605
640
  def models_create(url, token, json_path):
606
641
  with open(json_path) as f:
607
642
  payload = json.load(f)
643
+ inlined = _inline_sibling_images(json_path, payload)
608
644
  with httpx.Client(timeout=TIMEOUT) as c:
609
645
  r = _post(c, url, "/api/v1/models/create", token, payload)
610
646
  m = r.json()
611
- out(f"created {m.get('id')}")
647
+ out(f"created {m.get('id')}" + (f" (inlined {', '.join(inlined)})" if inlined else ""))
612
648
 
613
649
  def models_update(url, token, json_path):
614
650
  with open(json_path) as f:
615
651
  payload = json.load(f)
652
+ inlined = _inline_sibling_images(json_path, payload)
616
653
  with httpx.Client(timeout=TIMEOUT) as c:
617
654
  r = _post(c, url, "/api/v1/models/model/update", token, payload)
618
- out(f"updated {r.json().get('id')}")
655
+ out(f"updated {r.json().get('id')}" + (f" (inlined {', '.join(inlined)})" if inlined else ""))
619
656
 
620
657
  def models_delete(url, token, model_id):
621
658
  with httpx.Client(timeout=TIMEOUT) as c:
@@ -1038,6 +1075,90 @@ def chats_delete(url, token, chat_id):
1038
1075
  out(f"deleted {chat_id}")
1039
1076
 
1040
1077
 
1078
+ _STREAM_TIMEOUT = httpx.Timeout(TIMEOUT, read=3600.0)
1079
+
1080
+
1081
+ def _iter_chats_all(url, token):
1082
+ """Stream GET /api/v1/chats/all as NDJSON, yielding one chat per line.
1083
+
1084
+ On v0.11.x the endpoint returns an application/x-ndjson stream whose
1085
+ export can run to hundreds of MB — never buffered server-side. Parse
1086
+ incrementally so clients (and pull-all) don't need the whole thing in
1087
+ memory.
1088
+
1089
+ Decode-buffer manually over raw bytes: httpx's iter_lines can truncate a
1090
+ long line at a decode-chunk boundary (observed with model_dump_json
1091
+ exports whose message trees run large), so split b'\n' ourselves and
1092
+ decode each complete line.
1093
+ """
1094
+ with httpx.Client(timeout=_STREAM_TIMEOUT) as c:
1095
+ with c.stream("GET", _api(url, "/api/v1/chats/all"), headers=_headers(token)) as r:
1096
+ r.raise_for_status()
1097
+ buf = b""
1098
+ for chunk in r.iter_raw():
1099
+ buf += chunk
1100
+ while True:
1101
+ idx = buf.find(b"\n")
1102
+ if idx < 0:
1103
+ break
1104
+ line = buf[:idx]
1105
+ buf = buf[idx + 1:]
1106
+ if line.strip():
1107
+ yield json.loads(line.decode("utf-8"))
1108
+
1109
+
1110
+ def chats_all(url, token):
1111
+ count = 0
1112
+ chats = []
1113
+ for chat in _iter_chats_all(url, token):
1114
+ count += 1
1115
+ if JSON_OUTPUT:
1116
+ chats.append(chat)
1117
+ else:
1118
+ title = chat.get("chat", {}).get("title") or chat.get("title") or "(untitled)"
1119
+ print(f"{count}\t{chat.get('id','')}\t{str(title)[:60]}")
1120
+ if JSON_OUTPUT:
1121
+ out(chats)
1122
+ else:
1123
+ print(f"-- {count} chats", file=sys.stderr)
1124
+
1125
+ def chats_pull_all(url, token, out_dir="chats"):
1126
+ count = 0
1127
+ os.makedirs(out_dir, exist_ok=True)
1128
+ for chat in _iter_chats_all(url, token):
1129
+ chat_id = chat.get("id", "")
1130
+ if not chat_id:
1131
+ continue
1132
+ _write_json(os.path.join(out_dir, f"{chat_id}.json"), chat)
1133
+ count += 1
1134
+ if count % 100 == 0:
1135
+ print(f"... {count}", file=sys.stderr)
1136
+ out(f"pulled {count} chats into {out_dir}")
1137
+
1138
+ def chats_stats(url, token, page="1"):
1139
+ with httpx.Client(timeout=_STREAM_TIMEOUT) as c:
1140
+ r = _get(c, url, f"/api/v1/chats/stats/export?page={page}", token)
1141
+ data = r.json()
1142
+ if JSON_OUTPUT:
1143
+ out(data)
1144
+ return
1145
+ items = data.get("items", data) if isinstance(data, dict) else data
1146
+ total = data.get("total") if isinstance(data, dict) else None
1147
+ rows = []
1148
+ for s in items:
1149
+ st = s.get("stats") or {}
1150
+ models = st.get("models") or st.get("history_models") or {}
1151
+ rows.append({
1152
+ "id": s.get("id", s.get("chat_id", "")),
1153
+ "msgs": st.get("message_count", st.get("history_message_count", "?")),
1154
+ "models": ", ".join(models.keys())[:30],
1155
+ "updated": time.strftime("%Y-%m-%d", time.gmtime(s.get("updated_at"))) if s.get("updated_at") else "",
1156
+ })
1157
+ out_table(rows, [("CHAT_ID","id",36), ("MSGS","msgs",6), ("MODELS","models",30), ("UPDATED","updated",10)])
1158
+ if total is not None:
1159
+ print(f"-- page {page}: {len(items)} chats, total {total}")
1160
+
1161
+
1041
1162
  # ── configs ──────────────────────────────────────────────────────────
1042
1163
 
1043
1164
  def configs_show(url, token):
@@ -1219,6 +1340,9 @@ COMMANDS.update({
1219
1340
  ("chats", "list"): (chats_list, "[page]", (0, 1)),
1220
1341
  ("chats", "search"): (chats_search, "<query> [page]", (1, 2)),
1221
1342
  ("chats", "show"): (chats_show, "<id>", (1, 1)),
1343
+ ("chats", "all"): (chats_all, "", (0, 0)),
1344
+ ("chats", "pull-all"): (chats_pull_all, "[dir]", (0, 1)),
1345
+ ("chats", "stats"): (chats_stats, "[page]", (0, 1)),
1222
1346
  ("chats", "delete"): (chats_delete, "<id>", (1, 1)),
1223
1347
  ("users", "list"): (users_list, "", (0, 0)),
1224
1348
  ("users", "find"): (users_find, "<query>", (1, 1)),
@@ -72,7 +72,7 @@ wheels = [
72
72
 
73
73
  [[package]]
74
74
  name = "owui-cli"
75
- version = "0.2.0"
75
+ version = "0.5.3"
76
76
  source = { editable = "." }
77
77
  dependencies = [
78
78
  { name = "httpx" },
@@ -1 +0,0 @@
1
- __version__ = "0.5.1"
File without changes
File without changes
File without changes
File without changes
File without changes