llms-py 3.0.0b6__py3-none-any.whl → 3.0.0b7__py3-none-any.whl

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.
Files changed (55) hide show
  1. llms/__pycache__/main.cpython-314.pyc +0 -0
  2. llms/{ui/modules/analytics.mjs → extensions/analytics/ui/index.mjs} +4 -2
  3. llms/extensions/core_tools/__init__.py +358 -0
  4. llms/extensions/core_tools/__pycache__/__init__.cpython-314.pyc +0 -0
  5. llms/extensions/gallery/__init__.py +61 -0
  6. llms/extensions/gallery/__pycache__/__init__.cpython-314.pyc +0 -0
  7. llms/extensions/gallery/__pycache__/db.cpython-314.pyc +0 -0
  8. llms/extensions/gallery/db.py +298 -0
  9. llms/extensions/gallery/ui/index.mjs +480 -0
  10. llms/extensions/providers/__init__.py +18 -0
  11. llms/extensions/providers/__pycache__/__init__.cpython-314.pyc +0 -0
  12. llms/{providers → extensions/providers}/__pycache__/anthropic.cpython-314.pyc +0 -0
  13. llms/extensions/providers/__pycache__/chutes.cpython-314.pyc +0 -0
  14. llms/extensions/providers/__pycache__/google.cpython-314.pyc +0 -0
  15. llms/{providers → extensions/providers}/__pycache__/nvidia.cpython-314.pyc +0 -0
  16. llms/{providers → extensions/providers}/__pycache__/openai.cpython-314.pyc +0 -0
  17. llms/extensions/providers/__pycache__/openrouter.cpython-314.pyc +0 -0
  18. llms/{providers → extensions/providers}/anthropic.py +1 -4
  19. llms/{providers → extensions/providers}/chutes.py +21 -18
  20. llms/{providers → extensions/providers}/google.py +99 -27
  21. llms/{providers → extensions/providers}/nvidia.py +6 -8
  22. llms/{providers → extensions/providers}/openai.py +3 -6
  23. llms/{providers → extensions/providers}/openrouter.py +12 -10
  24. llms/extensions/system_prompts/__init__.py +45 -0
  25. llms/extensions/system_prompts/__pycache__/__init__.cpython-314.pyc +0 -0
  26. llms/extensions/system_prompts/ui/index.mjs +284 -0
  27. llms/extensions/system_prompts/ui/prompts.json +1067 -0
  28. llms/{ui/modules/tools.mjs → extensions/tools/ui/index.mjs} +4 -2
  29. llms/llms.json +17 -1
  30. llms/main.py +381 -170
  31. llms/providers-extra.json +0 -32
  32. llms/ui/App.mjs +17 -18
  33. llms/ui/ai.mjs +10 -3
  34. llms/ui/app.css +1553 -24
  35. llms/ui/ctx.mjs +70 -12
  36. llms/ui/index.mjs +13 -8
  37. llms/ui/modules/chat/ChatBody.mjs +11 -248
  38. llms/ui/modules/chat/HomeTools.mjs +254 -0
  39. llms/ui/modules/chat/SettingsDialog.mjs +1 -1
  40. llms/ui/modules/chat/index.mjs +278 -174
  41. llms/ui/modules/layout.mjs +2 -26
  42. llms/ui/modules/model-selector.mjs +1 -1
  43. llms/ui/modules/threads/index.mjs +5 -11
  44. llms/ui/modules/threads/threadStore.mjs +56 -2
  45. llms/ui/utils.mjs +21 -3
  46. {llms_py-3.0.0b6.dist-info → llms_py-3.0.0b7.dist-info}/METADATA +1 -1
  47. llms_py-3.0.0b7.dist-info/RECORD +80 -0
  48. llms/providers/__pycache__/chutes.cpython-314.pyc +0 -0
  49. llms/providers/__pycache__/google.cpython-314.pyc +0 -0
  50. llms/providers/__pycache__/openrouter.cpython-314.pyc +0 -0
  51. llms_py-3.0.0b6.dist-info/RECORD +0 -66
  52. {llms_py-3.0.0b6.dist-info → llms_py-3.0.0b7.dist-info}/WHEEL +0 -0
  53. {llms_py-3.0.0b6.dist-info → llms_py-3.0.0b7.dist-info}/entry_points.txt +0 -0
  54. {llms_py-3.0.0b6.dist-info → llms_py-3.0.0b7.dist-info}/licenses/LICENSE +0 -0
  55. {llms_py-3.0.0b6.dist-info → llms_py-3.0.0b7.dist-info}/top_level.txt +0 -0
llms/main.py CHANGED
@@ -40,12 +40,12 @@ try:
40
40
  except ImportError:
41
41
  HAS_PIL = False
42
42
 
43
- VERSION = "3.0.0b6"
43
+ VERSION = "3.0.0b7"
44
44
  _ROOT = None
45
- DEBUG = True # os.getenv("PYPI_SERVICESTACK") is not None
46
- MOCK = False
47
- MOCK_DIR = os.getenv("MOCK_DIR")
45
+ DEBUG = os.getenv("DEBUG") == "1"
48
46
  MOCK = os.getenv("MOCK") == "1"
47
+ MOCK_DIR = os.getenv("MOCK_DIR")
48
+ DISABLE_EXTENSIONS = (os.getenv("LLMS_DISABLE") or "").split(",")
49
49
  g_config_path = None
50
50
  g_config = None
51
51
  g_providers = None
@@ -493,7 +493,7 @@ class HTTPError(Exception):
493
493
  super().__init__(f"HTTP {status} {reason}")
494
494
 
495
495
 
496
- def save_image_to_cache(base64_data, filename, image_info):
496
+ def save_bytes_to_cache(base64_data, filename, file_info):
497
497
  ext = filename.split(".")[-1]
498
498
  mimetype = get_file_mime_type(filename)
499
499
  content = base64.b64decode(base64_data) if isinstance(base64_data, str) else base64_data
@@ -505,8 +505,39 @@ def save_image_to_cache(base64_data, filename, image_info):
505
505
  subdir = sha256_hash[:2]
506
506
  relative_path = f"{subdir}/{save_filename}"
507
507
  full_path = get_cache_path(relative_path)
508
+ url = f"/~cache/{relative_path}"
508
509
 
509
- url = f"~cache/{relative_path}"
510
+ os.makedirs(os.path.dirname(full_path), exist_ok=True)
511
+
512
+ with open(full_path, "wb") as f:
513
+ f.write(content)
514
+ info = {
515
+ "date": int(time.time()),
516
+ "url": url,
517
+ "size": len(content),
518
+ "type": mimetype,
519
+ "name": filename,
520
+ }
521
+ info.update(file_info)
522
+
523
+ g_app.on_cache_saved_filters({"url": url, "info": info})
524
+
525
+ return url, info
526
+
527
+
528
+ def save_image_to_cache(base64_data, filename, image_info):
529
+ ext = filename.split(".")[-1]
530
+ mimetype = get_file_mime_type(filename)
531
+ content = base64.b64decode(base64_data) if isinstance(base64_data, str) else base64_data
532
+ sha256_hash = hashlib.sha256(content).hexdigest()
533
+
534
+ save_filename = f"{sha256_hash}.{ext}" if ext else sha256_hash
535
+
536
+ # Use first 2 chars for subdir to avoid too many files in one dir
537
+ subdir = sha256_hash[:2]
538
+ relative_path = f"{subdir}/{save_filename}"
539
+ full_path = get_cache_path(relative_path)
540
+ url = f"/~cache/{relative_path}"
510
541
 
511
542
  # if file and its .info.json already exists, return it
512
543
  info_path = os.path.splitext(full_path)[0] + ".info.json"
@@ -545,6 +576,8 @@ def save_image_to_cache(base64_data, filename, image_info):
545
576
  with open(info_path, "w") as f:
546
577
  json.dump(info, f)
547
578
 
579
+ g_app.on_cache_saved_filters({"url": url, "info": info})
580
+
548
581
  return url, info
549
582
 
550
583
 
@@ -577,6 +610,12 @@ def chat_to_prompt(chat):
577
610
  return prompt
578
611
 
579
612
 
613
+ def chat_to_username(chat):
614
+ if "metadata" in chat and "user" in chat["metadata"]:
615
+ return chat["metadata"]["user"]
616
+ return None
617
+
618
+
580
619
  def last_user_prompt(chat):
581
620
  prompt = ""
582
621
  if "messages" in chat:
@@ -593,6 +632,21 @@ def last_user_prompt(chat):
593
632
  return prompt
594
633
 
595
634
 
635
+ def to_file_info(chat, info=None, response=None):
636
+ prompt = last_user_prompt(chat)
637
+ ret = info or {}
638
+ if chat["model"] and "model" not in ret:
639
+ ret["model"] = chat["model"]
640
+ if prompt and "prompt" not in ret:
641
+ ret["prompt"] = prompt
642
+ if "image_config" in chat:
643
+ ret.update(chat["image_config"])
644
+ user = chat_to_username(chat)
645
+ if user:
646
+ ret["user"] = user
647
+ return ret
648
+
649
+
596
650
  # Image Generator Providers
597
651
  class GeneratorBase:
598
652
  def __init__(self, **kwargs):
@@ -875,13 +929,14 @@ class OpenAiCompatible:
875
929
  _log(f"POST {self.chat_url}")
876
930
  _log(chat_summary(chat))
877
931
  # remove metadata if any (conflicts with some providers, e.g. Z.ai)
878
- chat.pop("metadata", None)
932
+ metadata = chat.pop("metadata", None)
879
933
 
880
934
  async with aiohttp.ClientSession() as session:
881
935
  started_at = time.time()
882
936
  async with session.post(
883
937
  self.chat_url, headers=self.headers, data=json.dumps(chat), timeout=aiohttp.ClientTimeout(total=120)
884
938
  ) as response:
939
+ chat["metadata"] = metadata
885
940
  return self.to_response(await response_json(response), chat, started_at)
886
941
 
887
942
 
@@ -1053,7 +1108,48 @@ def api_providers():
1053
1108
  return ret
1054
1109
 
1055
1110
 
1056
- async def chat_completion(chat):
1111
+ def to_error_response(e, stacktrace=False):
1112
+ status = {"errorCode": "Error", "message": str(e)}
1113
+ if stacktrace:
1114
+ status["stackTrace"] = traceback.format_exc()
1115
+ return {"responseStatus": status}
1116
+
1117
+
1118
+ def create_error_response(message, error_code="Error", stack_trace=None):
1119
+ ret = {"responseStatus": {"errorCode": error_code, "message": message}}
1120
+ if stack_trace:
1121
+ ret["responseStatus"]["stackTrace"] = stack_trace
1122
+ return ret
1123
+
1124
+
1125
+ def g_chat_request(template=None, text=None, model=None, system_prompt=None):
1126
+ chat_template = g_config["defaults"].get(template or "text")
1127
+ if not chat_template:
1128
+ raise Exception(f"Chat template '{template}' not found")
1129
+
1130
+ chat = chat_template.copy()
1131
+ if model:
1132
+ chat["model"] = model
1133
+ if system_prompt is not None:
1134
+ chat["messages"].insert(0, {"role": "system", "content": system_prompt})
1135
+ if text is not None:
1136
+ if not chat["messages"] or len(chat["messages"]) == 0:
1137
+ chat["messages"] = [{"role": "user", "content": [{"type": "text", "text": ""}]}]
1138
+
1139
+ # replace content of last message if exists, else add
1140
+ last_msg = chat["messages"][-1] if "messages" in chat else None
1141
+ if last_msg and last_msg["role"] == "user":
1142
+ if isinstance(last_msg["content"], list):
1143
+ last_msg["content"][-1]["text"] = text
1144
+ else:
1145
+ last_msg["content"] = text
1146
+ else:
1147
+ chat["messages"].append({"role": "user", "content": text})
1148
+
1149
+ return chat
1150
+
1151
+
1152
+ async def g_chat_completion(chat, context=None):
1057
1153
  model = chat["model"]
1058
1154
  # get first provider that has the model
1059
1155
  candidate_providers = [name for name, provider in g_handlers.items() if provider.provider_model(model)]
@@ -1218,16 +1314,8 @@ async def cli_chat(chat, image=None, audio=None, file=None, args=None, raw=False
1218
1314
  printdump(chat)
1219
1315
 
1220
1316
  try:
1221
- # Apply pre-chat filters
1222
- context = {"chat": chat}
1223
- for filter_func in g_app.chat_request_filters:
1224
- chat = await filter_func(chat, context)
1317
+ response = await g_app.chat_completion(chat)
1225
1318
 
1226
- response = await chat_completion(chat)
1227
-
1228
- # Apply post-chat filters
1229
- for filter_func in g_app.chat_response_filters:
1230
- response = await filter_func(response, context)
1231
1319
  if raw:
1232
1320
  print(json.dumps(response, indent=2))
1233
1321
  exit(0)
@@ -1244,13 +1332,17 @@ async def cli_chat(chat, image=None, audio=None, file=None, args=None, raw=False
1244
1332
  for image in msg["images"]:
1245
1333
  image_url = image["image_url"]["url"]
1246
1334
  generated_files.append(image_url)
1335
+ if "audios" in msg:
1336
+ for audio in msg["audios"]:
1337
+ audio_url = audio["audio_url"]["url"]
1338
+ generated_files.append(audio_url)
1247
1339
 
1248
1340
  if len(generated_files) > 0:
1249
1341
  print("\nSaved files:")
1250
1342
  for file in generated_files:
1251
- if file.startswith("~cache"):
1252
- print(get_cache_path(file[7:]))
1253
- _log(f"http://localhost:8000/{file}")
1343
+ if file.startswith("/~cache"):
1344
+ print(get_cache_path(file[8:]))
1345
+ print(f"http://localhost:8000/{file}")
1254
1346
  else:
1255
1347
  print(file)
1256
1348
 
@@ -1288,7 +1380,7 @@ def init_llms(config, providers):
1288
1380
  # iterate over config and replace $ENV with env value
1289
1381
  for key, value in g_config.items():
1290
1382
  if isinstance(value, str) and value.startswith("$"):
1291
- g_config[key] = os.environ.get(value[1:], "")
1383
+ g_config[key] = os.getenv(value[1:], "")
1292
1384
 
1293
1385
  # if g_verbose:
1294
1386
  # printdump(g_config)
@@ -1326,11 +1418,11 @@ def create_provider_kwargs(definition, provider=None):
1326
1418
  if "api_key" in provider:
1327
1419
  value = provider["api_key"]
1328
1420
  if isinstance(value, str) and value.startswith("$"):
1329
- provider["api_key"] = os.environ.get(value[1:], "")
1421
+ provider["api_key"] = os.getenv(value[1:], "")
1330
1422
 
1331
1423
  if "api_key" not in provider and "env" in provider:
1332
1424
  for env_var in provider["env"]:
1333
- val = os.environ.get(env_var)
1425
+ val = os.getenv(env_var)
1334
1426
  if val:
1335
1427
  provider["api_key"] = val
1336
1428
  break
@@ -1467,11 +1559,11 @@ def print_status():
1467
1559
 
1468
1560
 
1469
1561
  def home_llms_path(filename):
1470
- return f"{os.environ.get('HOME')}/.llms/{filename}"
1562
+ return f"{os.getenv('HOME')}/.llms/{filename}"
1471
1563
 
1472
1564
 
1473
- def get_cache_path(filename):
1474
- return home_llms_path(f"cache/{filename}")
1565
+ def get_cache_path(path=""):
1566
+ return home_llms_path(f"cache/{path}") if path else home_llms_path("cache")
1475
1567
 
1476
1568
 
1477
1569
  def get_config_path():
@@ -1480,8 +1572,8 @@ def get_config_path():
1480
1572
  "./llms.json",
1481
1573
  home_config_path,
1482
1574
  ]
1483
- if os.environ.get("LLMS_CONFIG_PATH"):
1484
- check_paths.insert(0, os.environ.get("LLMS_CONFIG_PATH"))
1575
+ if os.getenv("LLMS_CONFIG_PATH"):
1576
+ check_paths.insert(0, os.getenv("LLMS_CONFIG_PATH"))
1485
1577
 
1486
1578
  for check_path in check_paths:
1487
1579
  g_config_path = os.path.normpath(os.path.join(os.path.dirname(__file__), check_path))
@@ -1956,7 +2048,10 @@ class AppExtensions:
1956
2048
  self.chat_response_filters = []
1957
2049
  self.server_add_get = []
1958
2050
  self.server_add_post = []
1959
- self.server_add_post = []
2051
+ self.server_add_put = []
2052
+ self.server_add_delete = []
2053
+ self.server_add_patch = []
2054
+ self.cache_saved_filters = []
1960
2055
  self.tools = {}
1961
2056
  self.tool_definitions = []
1962
2057
  self.all_providers = [
@@ -1981,6 +2076,62 @@ class AppExtensions:
1981
2076
  "21:9": "1536×672",
1982
2077
  }
1983
2078
 
2079
+ def get_session(self, request):
2080
+ session_token = get_session_token(request)
2081
+
2082
+ if not session_token or session_token not in g_sessions:
2083
+ return None
2084
+
2085
+ session_data = g_sessions[session_token]
2086
+ return session_data
2087
+
2088
+ def get_username(self, request):
2089
+ session = self.get_session(request)
2090
+ if session:
2091
+ return session.get("userName")
2092
+ return None
2093
+
2094
+ def get_user_path(self, username=None):
2095
+ if username:
2096
+ return home_llms_path(os.path.join("user", username))
2097
+ return home_llms_path(os.path.join("user", "default"))
2098
+
2099
+ def chat_request(self, template=None, text=None, model=None, system_prompt=None):
2100
+ return g_chat_request(template=template, text=text, model=model, system_prompt=system_prompt)
2101
+
2102
+ async def chat_completion(self, chat, context=None):
2103
+ # Apply pre-chat filters
2104
+ if context is None:
2105
+ context = {"chat": chat}
2106
+ elif "request" in context:
2107
+ username = self.get_username(context["request"])
2108
+ if username:
2109
+ if "metadata" not in chat:
2110
+ chat["metadata"] = {}
2111
+ chat["metadata"]["user"] = username
2112
+
2113
+ for filter_func in self.chat_request_filters:
2114
+ chat = await filter_func(chat, context)
2115
+
2116
+ response = await g_chat_completion(chat, context)
2117
+
2118
+ # Apply post-chat filters
2119
+ for filter_func in self.chat_response_filters:
2120
+ response = await filter_func(response, context)
2121
+
2122
+ return response
2123
+
2124
+ def on_cache_saved_filters(self, context):
2125
+ # _log(f"on_cache_saved_filters {len(self.cache_saved_filters)}: {context['url']}")
2126
+ for filter_func in self.cache_saved_filters:
2127
+ filter_func(context)
2128
+
2129
+
2130
+ def handler_name(handler):
2131
+ if hasattr(handler, "__name__"):
2132
+ return handler.__name__
2133
+ return "unknown"
2134
+
1984
2135
 
1985
2136
  class ExtensionContext:
1986
2137
  def __init__(self, app, path):
@@ -1994,6 +2145,7 @@ class ExtensionContext:
1994
2145
  self.MOCK_DIR = MOCK_DIR
1995
2146
  self.debug = DEBUG
1996
2147
  self.verbose = g_verbose
2148
+ self.aspect_ratios = app.aspect_ratios
1997
2149
 
1998
2150
  def chat_to_prompt(self, chat):
1999
2151
  return chat_to_prompt(chat)
@@ -2001,9 +2153,15 @@ class ExtensionContext:
2001
2153
  def last_user_prompt(self, chat):
2002
2154
  return last_user_prompt(chat)
2003
2155
 
2156
+ def to_file_info(self, chat, info=None, response=None):
2157
+ return to_file_info(chat, info=info, response=response)
2158
+
2004
2159
  def save_image_to_cache(self, base64_data, filename, image_info):
2005
2160
  return save_image_to_cache(base64_data, filename, image_info)
2006
2161
 
2162
+ def save_bytes_to_cache(self, bytes_data, filename, file_info):
2163
+ return save_bytes_to_cache(bytes_data, filename, file_info)
2164
+
2007
2165
  def text_from_file(self, path):
2008
2166
  return text_from_file(path)
2009
2167
 
@@ -2027,7 +2185,7 @@ class ExtensionContext:
2027
2185
  print(traceback.format_exc(), flush=True)
2028
2186
 
2029
2187
  def add_provider(self, provider):
2030
- self.log(f"Registered provider: {provider}")
2188
+ self.log(f"Registered provider: {provider.__name__}")
2031
2189
  self.app.all_providers.append(provider)
2032
2190
 
2033
2191
  def register_ui_extension(self, index):
@@ -2036,13 +2194,17 @@ class ExtensionContext:
2036
2194
  self.app.ui_extensions.append({"id": self.name, "path": path})
2037
2195
 
2038
2196
  def register_chat_request_filter(self, handler):
2039
- self.log(f"Registered chat request filter: {handler}")
2197
+ self.log(f"Registered chat request filter: {handler_name(handler)}")
2040
2198
  self.app.chat_request_filters.append(handler)
2041
2199
 
2042
2200
  def register_chat_response_filter(self, handler):
2043
- self.log(f"Registered chat response filter: {handler}")
2201
+ self.log(f"Registered chat response filter: {handler_name(handler)}")
2044
2202
  self.app.chat_response_filters.append(handler)
2045
2203
 
2204
+ def register_cache_saved_filter(self, handler):
2205
+ self.log(f"Registered cache saved filter: {handler_name(handler)}")
2206
+ self.app.cache_saved_filters.append(handler)
2207
+
2046
2208
  def add_static_files(self, ext_dir):
2047
2209
  self.log(f"Registered static files: {ext_dir}")
2048
2210
 
@@ -2063,11 +2225,29 @@ class ExtensionContext:
2063
2225
  self.dbg(f"Registered POST: {os.path.join(self.ext_prefix, path)}")
2064
2226
  self.app.server_add_post.append((os.path.join(self.ext_prefix, path), handler, kwargs))
2065
2227
 
2228
+ def add_put(self, path, handler, **kwargs):
2229
+ self.dbg(f"Registered PUT: {os.path.join(self.ext_prefix, path)}")
2230
+ self.app.server_add_put.append((os.path.join(self.ext_prefix, path), handler, kwargs))
2231
+
2232
+ def add_delete(self, path, handler, **kwargs):
2233
+ self.dbg(f"Registered DELETE: {os.path.join(self.ext_prefix, path)}")
2234
+ self.app.server_add_delete.append((os.path.join(self.ext_prefix, path), handler, kwargs))
2235
+
2236
+ def add_patch(self, path, handler, **kwargs):
2237
+ self.dbg(f"Registered PATCH: {os.path.join(self.ext_prefix, path)}")
2238
+ self.app.server_add_patch.append((os.path.join(self.ext_prefix, path), handler, kwargs))
2239
+
2066
2240
  def get_config(self):
2067
2241
  return g_config
2068
2242
 
2069
- def chat_completion(self, chat):
2070
- return chat_completion(chat)
2243
+ def get_cache_path(self, path=""):
2244
+ return get_cache_path(path)
2245
+
2246
+ def chat_request(self, template=None, text=None, model=None, system_prompt=None):
2247
+ return self.app.chat_request(template=template, text=text, model=model, system_prompt=system_prompt)
2248
+
2249
+ def chat_completion(self, chat, context=None):
2250
+ return self.app.chat_completion(chat, context=context)
2071
2251
 
2072
2252
  def get_providers(self):
2073
2253
  return g_handlers
@@ -2075,21 +2255,6 @@ class ExtensionContext:
2075
2255
  def get_provider(self, name):
2076
2256
  return g_handlers.get(name)
2077
2257
 
2078
- def get_session(self, request):
2079
- session_token = get_session_token(request)
2080
-
2081
- if not session_token or session_token not in g_sessions:
2082
- return None
2083
-
2084
- session_data = g_sessions[session_token]
2085
- return session_data
2086
-
2087
- def get_username(self, request):
2088
- session = self.get_session(request)
2089
- if session:
2090
- return session.get("userName")
2091
- return None
2092
-
2093
2258
  def register_tool(self, func, tool_def=None):
2094
2259
  if tool_def is None:
2095
2260
  tool_def = function_to_tool_definition(func)
@@ -2099,44 +2264,71 @@ class ExtensionContext:
2099
2264
  self.app.tools[name] = func
2100
2265
  self.app.tool_definitions.append(tool_def)
2101
2266
 
2267
+ def get_session(self, request):
2268
+ return self.app.get_session(request)
2102
2269
 
2103
- def load_builtin_extensions():
2104
- providers_path = _ROOT / "providers"
2105
- if not providers_path.exists():
2106
- return
2270
+ def get_username(self, request):
2271
+ return self.app.get_username(request)
2107
2272
 
2108
- for item in os.listdir(providers_path):
2109
- if not item.endswith(".py") or item == "__init__.py":
2110
- continue
2273
+ def get_user_path(self, username=None):
2274
+ return self.app.get_user_path(username)
2111
2275
 
2112
- item_path = providers_path / item
2113
- module_name = item[:-3]
2114
2276
 
2115
- try:
2116
- spec = importlib.util.spec_from_file_location(module_name, item_path)
2117
- if spec and spec.loader:
2118
- module = importlib.util.module_from_spec(spec)
2119
- sys.modules[f"llms.providers.{module_name}"] = module
2120
- spec.loader.exec_module(module)
2121
-
2122
- install_func = getattr(module, "__install__", None)
2123
- if callable(install_func):
2124
- install_func(ExtensionContext(g_app, item_path))
2125
- _log(f"Loaded builtin extension: {module_name}")
2126
- except Exception as e:
2127
- _err(f"Failed to load builtin extension {module_name}", e)
2277
+ def get_extensions_path():
2278
+ return os.getenv("LLMS_EXTENSIONS_DIR", os.path.join(Path.home(), ".llms", "extensions"))
2128
2279
 
2129
2280
 
2130
- def get_extensions_path():
2131
- return os.environ.get("LLMS_EXTENSIONS_DIR", os.path.join(Path.home(), ".llms", "extensions"))
2281
+ def get_disabled_extensions():
2282
+ ret = DISABLE_EXTENSIONS.copy()
2283
+ if g_config:
2284
+ for ext in g_config.get("disable_extensions", []):
2285
+ if ext not in ret:
2286
+ ret.append(ext)
2287
+ return ret
2132
2288
 
2133
2289
 
2134
- def init_extensions(parser):
2290
+ def get_extensions_dirs():
2291
+ """
2292
+ Returns a list of extension directories.
2293
+ """
2135
2294
  extensions_path = get_extensions_path()
2136
2295
  os.makedirs(extensions_path, exist_ok=True)
2137
2296
 
2138
- for item in os.listdir(extensions_path):
2139
- item_path = os.path.join(extensions_path, item)
2297
+ # allow overriding builtin extensions
2298
+ override_extensions = []
2299
+ if os.path.exists(extensions_path):
2300
+ override_extensions = os.listdir(extensions_path)
2301
+
2302
+ ret = []
2303
+ disabled_extensions = get_disabled_extensions()
2304
+
2305
+ builtin_extensions_dir = _ROOT / "extensions"
2306
+ if os.path.exists(builtin_extensions_dir):
2307
+ for item in os.listdir(builtin_extensions_dir):
2308
+ if os.path.isdir(os.path.join(builtin_extensions_dir, item)):
2309
+ if item in override_extensions:
2310
+ continue
2311
+ if item in disabled_extensions:
2312
+ continue
2313
+ ret.append(os.path.join(builtin_extensions_dir, item))
2314
+
2315
+ if os.path.exists(extensions_path):
2316
+ for item in os.listdir(extensions_path):
2317
+ if os.path.isdir(os.path.join(extensions_path, item)):
2318
+ if item in disabled_extensions:
2319
+ continue
2320
+ ret.append(os.path.join(extensions_path, item))
2321
+
2322
+ return ret
2323
+
2324
+
2325
+ def init_extensions(parser):
2326
+ """
2327
+ Initializes extensions by loading their __init__.py files and calling the __parser__ function if it exists.
2328
+ """
2329
+ for item_path in get_extensions_dirs():
2330
+ item = os.path.basename(item_path)
2331
+
2140
2332
  if os.path.isdir(item_path):
2141
2333
  try:
2142
2334
  # check for __parser__ function if exists in __init.__.py and call it with parser
@@ -2161,25 +2353,28 @@ def install_extensions():
2161
2353
  Scans ensure ~/.llms/extensions/ for directories with __init__.py and loads them as extensions.
2162
2354
  Calls the `__install__(ctx)` function in the extension module.
2163
2355
  """
2164
- extensions_path = get_extensions_path()
2165
- os.makedirs(extensions_path, exist_ok=True)
2166
2356
 
2167
- ext_count = len(os.listdir(extensions_path))
2357
+ extension_dirs = get_extensions_dirs()
2358
+ ext_count = len(list(extension_dirs))
2168
2359
  if ext_count == 0:
2169
2360
  _log("No extensions found")
2170
2361
  return
2171
2362
 
2363
+ disabled_extensions = get_disabled_extensions()
2364
+ if len(disabled_extensions) > 0:
2365
+ _log(f"Disabled extensions: {', '.join(disabled_extensions)}")
2366
+
2172
2367
  _log(f"Installing {ext_count} extension{'' if ext_count == 1 else 's'}...")
2173
2368
 
2174
- sys.path.append(extensions_path)
2369
+ for item_path in extension_dirs:
2370
+ item = os.path.basename(item_path)
2175
2371
 
2176
- for item in os.listdir(extensions_path):
2177
- item_path = os.path.join(extensions_path, item)
2178
2372
  if os.path.isdir(item_path):
2179
- init_file = os.path.join(item_path, "__init__.py")
2180
- if os.path.exists(init_file):
2373
+ sys.path.append(item_path)
2374
+ try:
2181
2375
  ctx = ExtensionContext(g_app, item_path)
2182
- try:
2376
+ init_file = os.path.join(item_path, "__init__.py")
2377
+ if os.path.exists(init_file):
2183
2378
  spec = importlib.util.spec_from_file_location(item, init_file)
2184
2379
  if spec and spec.loader:
2185
2380
  module = importlib.util.module_from_spec(spec)
@@ -2194,20 +2389,20 @@ def install_extensions():
2194
2389
  _dbg(f"Extension {item} has no __install__ function")
2195
2390
  else:
2196
2391
  _dbg(f"Extension {item} has no __init__.py")
2392
+ else:
2393
+ _dbg(f"Extension {init_file} not found")
2197
2394
 
2198
- # if ui folder exists, serve as static files at /ext/{item}/
2199
- ui_path = os.path.join(item_path, "ui")
2200
- if os.path.exists(ui_path):
2201
- ctx.add_static_files(ui_path)
2395
+ # if ui folder exists, serve as static files at /ext/{item}/
2396
+ ui_path = os.path.join(item_path, "ui")
2397
+ if os.path.exists(ui_path):
2398
+ ctx.add_static_files(ui_path)
2202
2399
 
2203
- # Register UI extension if index.mjs exists (/ext/{item}/index.mjs)
2204
- if os.path.exists(os.path.join(ui_path, "index.mjs")):
2205
- ctx.register_ui_extension("index.mjs")
2400
+ # Register UI extension if index.mjs exists (/ext/{item}/index.mjs)
2401
+ if os.path.exists(os.path.join(ui_path, "index.mjs")):
2402
+ ctx.register_ui_extension("index.mjs")
2206
2403
 
2207
- except Exception as e:
2208
- _err(f"Failed to install extension {item}", e)
2209
- else:
2210
- _dbg(f"Extension {init_file} not found")
2404
+ except Exception as e:
2405
+ _err(f"Failed to install extension {item}", e)
2211
2406
  else:
2212
2407
  _dbg(f"Extension {item} not found: {item_path} is not a directory {os.path.exists(item_path)}")
2213
2408
 
@@ -2216,11 +2411,9 @@ def run_extension_cli():
2216
2411
  """
2217
2412
  Run the CLI for an extension.
2218
2413
  """
2219
- extensions_path = get_extensions_path()
2220
- os.makedirs(extensions_path, exist_ok=True)
2414
+ for item_path in get_extensions_dirs():
2415
+ item = os.path.basename(item_path)
2221
2416
 
2222
- for item in os.listdir(extensions_path):
2223
- item_path = os.path.join(extensions_path, item)
2224
2417
  if os.path.isdir(item_path):
2225
2418
  init_file = os.path.join(item_path, "__init__.py")
2226
2419
  if os.path.exists(init_file):
@@ -2235,8 +2428,8 @@ def run_extension_cli():
2235
2428
  # Check for __run__ function if exists in __init__.py and call it with ctx
2236
2429
  run_func = getattr(module, "__run__", None)
2237
2430
  if callable(run_func):
2431
+ _log(f"Running extension {item}...")
2238
2432
  handled = run_func(ctx)
2239
- _log(f"Extension {item} was run")
2240
2433
  return handled
2241
2434
 
2242
2435
  except Exception as e:
@@ -2247,6 +2440,11 @@ def run_extension_cli():
2247
2440
  def main():
2248
2441
  global _ROOT, g_verbose, g_default_model, g_logprefix, g_providers, g_config, g_config_path, g_app
2249
2442
 
2443
+ _ROOT = os.getenv("LLMS_ROOT", resolve_root())
2444
+ if not _ROOT:
2445
+ print("Resource root not found")
2446
+ exit(1)
2447
+
2250
2448
  parser = argparse.ArgumentParser(description=f"llms v{VERSION}")
2251
2449
  parser.add_argument("--config", default=None, help="Path to config file", metavar="FILE")
2252
2450
  parser.add_argument("--providers", default=None, help="Path to models.dev providers file", metavar="FILE")
@@ -2283,9 +2481,7 @@ def main():
2283
2481
 
2284
2482
  parser.add_argument("--init", action="store_true", help="Create a default llms.json")
2285
2483
  parser.add_argument("--update-providers", action="store_true", help="Update local models.dev providers.json")
2286
- parser.add_argument("--update-extensions", action="store_true", help="Update installed extensions")
2287
2484
 
2288
- parser.add_argument("--root", default=None, help="Change root directory for UI files", metavar="PATH")
2289
2485
  parser.add_argument("--logprefix", default="", help="Prefix used in log messages", metavar="PREFIX")
2290
2486
  parser.add_argument("--verbose", action="store_true", help="Verbose output")
2291
2487
 
@@ -2323,7 +2519,7 @@ def main():
2323
2519
  g_app = AppExtensions(cli_args, extra_args)
2324
2520
 
2325
2521
  # Check for verbose mode from CLI argument or environment variables
2326
- verbose_env = os.environ.get("VERBOSE", "").lower()
2522
+ verbose_env = os.getenv("VERBOSE", "").lower()
2327
2523
  if cli_args.verbose or verbose_env in ("1", "true"):
2328
2524
  g_verbose = True
2329
2525
  # printdump(cli_args)
@@ -2332,11 +2528,6 @@ def main():
2332
2528
  if cli_args.logprefix:
2333
2529
  g_logprefix = cli_args.logprefix
2334
2530
 
2335
- _ROOT = Path(cli_args.root) if cli_args.root else resolve_root()
2336
- if not _ROOT:
2337
- print("Resource root not found")
2338
- exit(1)
2339
-
2340
2531
  home_config_path = home_llms_path("llms.json")
2341
2532
  home_providers_path = home_llms_path("providers.json")
2342
2533
  home_providers_extra_path = home_llms_path("providers-extra.json")
@@ -2397,7 +2588,7 @@ def main():
2397
2588
  if (
2398
2589
  os.path.exists(home_providers_path)
2399
2590
  and (time.time() - os.path.getmtime(home_providers_path)) > 86400
2400
- and os.environ.get("LLMS_DISABLE_UPDATE", "") != "1"
2591
+ and os.getenv("LLMS_DISABLE_UPDATE", "") != "1"
2401
2592
  ):
2402
2593
  try:
2403
2594
  asyncio.run(update_providers(home_providers_path))
@@ -2554,12 +2745,10 @@ def main():
2554
2745
  asyncio.run(update_extensions(cli_args.update))
2555
2746
  exit(0)
2556
2747
 
2557
- load_builtin_extensions()
2748
+ install_extensions()
2558
2749
 
2559
2750
  asyncio.run(reload_providers())
2560
2751
 
2561
- install_extensions()
2562
-
2563
2752
  # print names
2564
2753
  _log(f"enabled providers: {', '.join(g_handlers.keys())}")
2565
2754
 
@@ -2615,6 +2804,7 @@ def main():
2615
2804
  exit(0)
2616
2805
 
2617
2806
  if cli_args.serve is not None:
2807
+ error_auth_required = create_error_response("Authentication required", "Unauthorized")
2618
2808
  # Disable inactive providers and save to config before starting server
2619
2809
  all_providers = g_config["providers"].keys()
2620
2810
  enabled_providers = list(g_handlers.keys())
@@ -2645,8 +2835,8 @@ def main():
2645
2835
  if client_secret.startswith("$"):
2646
2836
  client_secret = client_secret[1:]
2647
2837
 
2648
- client_id = os.environ.get(client_id, client_id)
2649
- client_secret = os.environ.get(client_secret, client_secret)
2838
+ client_id = os.getenv(client_id, client_id)
2839
+ client_secret = os.getenv(client_secret, client_secret)
2650
2840
 
2651
2841
  if (
2652
2842
  not client_id
@@ -2691,35 +2881,15 @@ def main():
2691
2881
  # Check authentication if enabled
2692
2882
  is_authenticated, user_data = check_auth(request)
2693
2883
  if not is_authenticated:
2694
- return web.json_response(
2695
- {
2696
- "error": {
2697
- "message": "Authentication required",
2698
- "type": "authentication_error",
2699
- "code": "unauthorized",
2700
- }
2701
- },
2702
- status=401,
2703
- )
2884
+ return web.json_response(error_auth_required, status=401)
2704
2885
 
2705
2886
  try:
2706
2887
  chat = await request.json()
2707
-
2708
- # Apply pre-chat filters
2709
2888
  context = {"request": request, "chat": chat}
2710
- for filter_func in g_app.chat_request_filters:
2711
- chat = await filter_func(chat, context)
2712
-
2713
- response = await chat_completion(chat)
2714
-
2715
- # Apply post-chat filters
2716
- # Apply post-chat filters
2717
- for filter_func in g_app.chat_response_filters:
2718
- response = await filter_func(response, context)
2719
-
2889
+ response = await g_app.chat_completion(chat, context)
2720
2890
  return web.json_response(response)
2721
2891
  except Exception as e:
2722
- return web.json_response({"error": str(e)}, status=500)
2892
+ return web.json_response(to_error_response(e), status=500)
2723
2893
 
2724
2894
  app.router.add_post("/v1/chat/completions", chat_handler)
2725
2895
 
@@ -2773,16 +2943,7 @@ def main():
2773
2943
  # Check authentication if enabled
2774
2944
  is_authenticated, user_data = check_auth(request)
2775
2945
  if not is_authenticated:
2776
- return web.json_response(
2777
- {
2778
- "error": {
2779
- "message": "Authentication required",
2780
- "type": "authentication_error",
2781
- "code": "unauthorized",
2782
- }
2783
- },
2784
- status=401,
2785
- )
2946
+ return web.json_response(error_auth_required, status=401)
2786
2947
 
2787
2948
  reader = await request.multipart()
2788
2949
 
@@ -2792,7 +2953,7 @@ def main():
2792
2953
  field = await reader.next()
2793
2954
 
2794
2955
  if not field:
2795
- return web.json_response({"error": "No file provided"}, status=400)
2956
+ return web.json_response(create_error_response("No file provided"), status=400)
2796
2957
 
2797
2958
  filename = field.filename or "file"
2798
2959
  content = await field.read()
@@ -2830,9 +2991,10 @@ def main():
2830
2991
  with open(full_path, "wb") as f:
2831
2992
  f.write(content)
2832
2993
 
2994
+ url = f"/~cache/{relative_path}"
2833
2995
  response_data = {
2834
2996
  "date": int(time.time()),
2835
- "url": f"/~cache/{relative_path}",
2997
+ "url": url,
2836
2998
  "size": len(content),
2837
2999
  "type": mimetype,
2838
3000
  "name": filename,
@@ -2852,6 +3014,8 @@ def main():
2852
3014
  with open(info_path, "w") as f:
2853
3015
  json.dump(response_data, f)
2854
3016
 
3017
+ g_app.on_cache_saved_filters({"url": url, "info": response_data})
3018
+
2855
3019
  return web.json_response(response_data)
2856
3020
 
2857
3021
  app.router.add_post("/upload", upload_handler)
@@ -2877,7 +3041,7 @@ def main():
2877
3041
 
2878
3042
  # Check for directory traversal for info path
2879
3043
  try:
2880
- cache_root = Path(get_cache_path(""))
3044
+ cache_root = Path(get_cache_path())
2881
3045
  requested_path = Path(info_path).resolve()
2882
3046
  if not str(requested_path).startswith(str(cache_root)):
2883
3047
  return web.Response(text="403: Forbidden", status=403)
@@ -2893,7 +3057,7 @@ def main():
2893
3057
 
2894
3058
  # Check for directory traversal
2895
3059
  try:
2896
- cache_root = Path(get_cache_path(""))
3060
+ cache_root = Path(get_cache_path())
2897
3061
  requested_path = Path(full_path).resolve()
2898
3062
  if not str(requested_path).startswith(str(cache_root)):
2899
3063
  return web.Response(text="403: Forbidden", status=403)
@@ -2912,7 +3076,7 @@ def main():
2912
3076
  async def github_auth_handler(request):
2913
3077
  """Initiate GitHub OAuth flow"""
2914
3078
  if "auth" not in g_config or "github" not in g_config["auth"]:
2915
- return web.json_response({"error": "GitHub OAuth not configured"}, status=500)
3079
+ return web.json_response(create_error_response("GitHub OAuth not configured"), status=500)
2916
3080
 
2917
3081
  auth_config = g_config["auth"]["github"]
2918
3082
  client_id = auth_config.get("client_id", "")
@@ -2924,11 +3088,11 @@ def main():
2924
3088
  if redirect_uri.startswith("$"):
2925
3089
  redirect_uri = redirect_uri[1:]
2926
3090
 
2927
- client_id = os.environ.get(client_id, client_id)
2928
- redirect_uri = os.environ.get(redirect_uri, redirect_uri)
3091
+ client_id = os.getenv(client_id, client_id)
3092
+ redirect_uri = os.getenv(redirect_uri, redirect_uri)
2929
3093
 
2930
3094
  if not client_id:
2931
- return web.json_response({"error": "GitHub client_id not configured"}, status=500)
3095
+ return web.json_response(create_error_response("GitHub client_id not configured"), status=500)
2932
3096
 
2933
3097
  # Generate CSRF state token
2934
3098
  state = secrets.token_urlsafe(32)
@@ -2960,7 +3124,7 @@ def main():
2960
3124
  if restrict_to.startswith("$"):
2961
3125
  restrict_to = restrict_to[1:]
2962
3126
 
2963
- restrict_to = os.environ.get(restrict_to, None if restrict_to == "GITHUB_USERS" else restrict_to)
3127
+ restrict_to = os.getenv(restrict_to, None if restrict_to == "GITHUB_USERS" else restrict_to)
2964
3128
 
2965
3129
  # If restrict_to is configured, validate the user
2966
3130
  if restrict_to:
@@ -2999,7 +3163,7 @@ def main():
2999
3163
  g_oauth_states.pop(state)
3000
3164
 
3001
3165
  if "auth" not in g_config or "github" not in g_config["auth"]:
3002
- return web.json_response({"error": "GitHub OAuth not configured"}, status=500)
3166
+ return web.json_response(create_error_response("GitHub OAuth not configured"), status=500)
3003
3167
 
3004
3168
  auth_config = g_config["auth"]["github"]
3005
3169
  client_id = auth_config.get("client_id", "")
@@ -3014,12 +3178,12 @@ def main():
3014
3178
  if redirect_uri.startswith("$"):
3015
3179
  redirect_uri = redirect_uri[1:]
3016
3180
 
3017
- client_id = os.environ.get(client_id, client_id)
3018
- client_secret = os.environ.get(client_secret, client_secret)
3019
- redirect_uri = os.environ.get(redirect_uri, redirect_uri)
3181
+ client_id = os.getenv(client_id, client_id)
3182
+ client_secret = os.getenv(client_secret, client_secret)
3183
+ redirect_uri = os.getenv(redirect_uri, redirect_uri)
3020
3184
 
3021
3185
  if not client_id or not client_secret:
3022
- return web.json_response({"error": "GitHub OAuth credentials not configured"}, status=500)
3186
+ return web.json_response(create_error_response("GitHub OAuth credentials not configured"), status=500)
3023
3187
 
3024
3188
  # Exchange code for access token
3025
3189
  async with aiohttp.ClientSession() as session:
@@ -3038,7 +3202,7 @@ def main():
3038
3202
 
3039
3203
  if not access_token:
3040
3204
  error = token_response.get("error_description", "Failed to get access token")
3041
- return web.Response(text=f"OAuth error: {error}", status=400)
3205
+ return web.json_response(create_error_response(f"OAuth error: {error}"), status=400)
3042
3206
 
3043
3207
  # Fetch user info
3044
3208
  user_url = "https://api.github.com/user"
@@ -3073,7 +3237,7 @@ def main():
3073
3237
  session_token = get_session_token(request)
3074
3238
 
3075
3239
  if not session_token or session_token not in g_sessions:
3076
- return web.json_response({"error": "Invalid or expired session"}, status=401)
3240
+ return web.json_response(create_error_response("Invalid or expired session"), status=401)
3077
3241
 
3078
3242
  session_data = g_sessions[session_token]
3079
3243
 
@@ -3129,9 +3293,7 @@ def main():
3129
3293
  # })
3130
3294
 
3131
3295
  # Not authenticated - return error in expected format
3132
- return web.json_response(
3133
- {"responseStatus": {"errorCode": "Unauthorized", "message": "Not authenticated"}}, status=401
3134
- )
3296
+ return web.json_response(error_auth_required, status=401)
3135
3297
 
3136
3298
  app.router.add_get("/auth", auth_handler)
3137
3299
  app.router.add_get("/auth/github", github_auth_handler)
@@ -3191,9 +3353,55 @@ def main():
3191
3353
 
3192
3354
  # go through and register all g_app extensions
3193
3355
  for handler in g_app.server_add_get:
3194
- app.router.add_get(handler[0], handler[1], **handler[2])
3356
+ handler_fn = handler[1]
3357
+
3358
+ async def managed_handler(request, handler_fn=handler_fn):
3359
+ try:
3360
+ return await handler_fn(request)
3361
+ except Exception as e:
3362
+ return web.json_response(to_error_response(e, stacktrace=g_verbose), status=500)
3363
+
3364
+ app.router.add_get(handler[0], managed_handler, **handler[2])
3195
3365
  for handler in g_app.server_add_post:
3196
- app.router.add_post(handler[0], handler[1], **handler[2])
3366
+ handler_fn = handler[1]
3367
+
3368
+ async def managed_handler(request, handler_fn=handler_fn):
3369
+ try:
3370
+ return await handler_fn(request)
3371
+ except Exception as e:
3372
+ return web.json_response(to_error_response(e, stacktrace=g_verbose), status=500)
3373
+
3374
+ app.router.add_post(handler[0], managed_handler, **handler[2])
3375
+ for handler in g_app.server_add_put:
3376
+ handler_fn = handler[1]
3377
+
3378
+ async def managed_handler(request, handler_fn=handler_fn):
3379
+ try:
3380
+ return await handler_fn(request)
3381
+ except Exception as e:
3382
+ return web.json_response(to_error_response(e, stacktrace=g_verbose), status=500)
3383
+
3384
+ app.router.add_put(handler[0], managed_handler, **handler[2])
3385
+ for handler in g_app.server_add_delete:
3386
+ handler_fn = handler[1]
3387
+
3388
+ async def managed_handler(request, handler_fn=handler_fn):
3389
+ try:
3390
+ return await handler_fn(request)
3391
+ except Exception as e:
3392
+ return web.json_response(to_error_response(e, stacktrace=g_verbose), status=500)
3393
+
3394
+ app.router.add_delete(handler[0], managed_handler, **handler[2])
3395
+ for handler in g_app.server_add_patch:
3396
+ handler_fn = handler[1]
3397
+
3398
+ async def managed_handler(request, handler_fn=handler_fn):
3399
+ try:
3400
+ return await handler_fn(request)
3401
+ except Exception as e:
3402
+ return web.json_response(to_error_response(e, stacktrace=g_verbose), status=500)
3403
+
3404
+ app.router.add_patch(handler[0], managed_handler, **handler[2])
3197
3405
 
3198
3406
  # Serve index.html from root
3199
3407
  async def index_handler(request):
@@ -3236,7 +3444,7 @@ def main():
3236
3444
 
3237
3445
  for provider in enable_providers:
3238
3446
  if provider not in g_config["providers"]:
3239
- print(f"Provider {provider} not found")
3447
+ print(f"Provider '{provider}' not found")
3240
3448
  print(f"Available providers: {', '.join(g_config['providers'].keys())}")
3241
3449
  exit(1)
3242
3450
  if provider in g_config["providers"]:
@@ -3324,6 +3532,9 @@ def main():
3324
3532
 
3325
3533
  if len(extra_args) > 0:
3326
3534
  prompt = " ".join(extra_args)
3535
+ if not chat["messages"] or len(chat["messages"]) == 0:
3536
+ chat["messages"] = [{"role": "user", "content": [{"type": "text", "text": ""}]}]
3537
+
3327
3538
  # replace content of last message if exists, else add
3328
3539
  last_msg = chat["messages"][-1] if "messages" in chat else None
3329
3540
  if last_msg and last_msg["role"] == "user":