llms-py 3.0.0b6__py3-none-any.whl → 3.0.0b8__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 (181) hide show
  1. llms/__pycache__/main.cpython-314.pyc +0 -0
  2. llms/{ui/modules/analytics.mjs → extensions/analytics/ui/index.mjs} +55 -164
  3. llms/extensions/app/__init__.py +519 -0
  4. llms/extensions/app/__pycache__/__init__.cpython-314.pyc +0 -0
  5. llms/extensions/app/__pycache__/db.cpython-314.pyc +0 -0
  6. llms/extensions/app/__pycache__/db_manager.cpython-314.pyc +0 -0
  7. llms/extensions/app/db.py +641 -0
  8. llms/extensions/app/db_manager.py +195 -0
  9. llms/extensions/app/requests.json +9073 -0
  10. llms/extensions/app/threads.json +15290 -0
  11. llms/{ui/modules/threads → extensions/app/ui}/Recents.mjs +82 -55
  12. llms/{ui/modules/threads → extensions/app/ui}/index.mjs +83 -20
  13. llms/extensions/app/ui/threadStore.mjs +407 -0
  14. llms/extensions/core_tools/__init__.py +598 -0
  15. llms/extensions/core_tools/__pycache__/__init__.cpython-314.pyc +0 -0
  16. llms/extensions/core_tools/ui/codemirror/addon/edit/closebrackets.js +201 -0
  17. llms/extensions/core_tools/ui/codemirror/addon/edit/closetag.js +185 -0
  18. llms/extensions/core_tools/ui/codemirror/addon/edit/continuelist.js +101 -0
  19. llms/extensions/core_tools/ui/codemirror/addon/edit/matchbrackets.js +160 -0
  20. llms/extensions/core_tools/ui/codemirror/addon/edit/matchtags.js +66 -0
  21. llms/extensions/core_tools/ui/codemirror/addon/edit/trailingspace.js +27 -0
  22. llms/extensions/core_tools/ui/codemirror/addon/selection/active-line.js +72 -0
  23. llms/extensions/core_tools/ui/codemirror/addon/selection/mark-selection.js +119 -0
  24. llms/extensions/core_tools/ui/codemirror/addon/selection/selection-pointer.js +98 -0
  25. llms/extensions/core_tools/ui/codemirror/doc/docs.css +225 -0
  26. llms/extensions/core_tools/ui/codemirror/doc/source_sans.woff +0 -0
  27. llms/extensions/core_tools/ui/codemirror/lib/codemirror.css +344 -0
  28. llms/extensions/core_tools/ui/codemirror/lib/codemirror.js +9884 -0
  29. llms/extensions/core_tools/ui/codemirror/mode/clike/clike.js +942 -0
  30. llms/extensions/core_tools/ui/codemirror/mode/javascript/index.html +118 -0
  31. llms/extensions/core_tools/ui/codemirror/mode/javascript/javascript.js +962 -0
  32. llms/extensions/core_tools/ui/codemirror/mode/javascript/typescript.html +62 -0
  33. llms/extensions/core_tools/ui/codemirror/mode/python/python.js +402 -0
  34. llms/extensions/core_tools/ui/codemirror/theme/dracula.css +40 -0
  35. llms/extensions/core_tools/ui/codemirror/theme/mocha.css +135 -0
  36. llms/extensions/core_tools/ui/index.mjs +650 -0
  37. llms/extensions/gallery/__init__.py +61 -0
  38. llms/extensions/gallery/__pycache__/__init__.cpython-314.pyc +0 -0
  39. llms/extensions/gallery/__pycache__/db.cpython-314.pyc +0 -0
  40. llms/extensions/gallery/db.py +298 -0
  41. llms/extensions/gallery/ui/index.mjs +481 -0
  42. llms/extensions/katex/__init__.py +6 -0
  43. llms/extensions/katex/__pycache__/__init__.cpython-314.pyc +0 -0
  44. llms/extensions/katex/ui/README.md +125 -0
  45. llms/extensions/katex/ui/contrib/auto-render.js +338 -0
  46. llms/extensions/katex/ui/contrib/auto-render.min.js +1 -0
  47. llms/extensions/katex/ui/contrib/auto-render.mjs +244 -0
  48. llms/extensions/katex/ui/contrib/copy-tex.js +127 -0
  49. llms/extensions/katex/ui/contrib/copy-tex.min.js +1 -0
  50. llms/extensions/katex/ui/contrib/copy-tex.mjs +105 -0
  51. llms/extensions/katex/ui/contrib/mathtex-script-type.js +109 -0
  52. llms/extensions/katex/ui/contrib/mathtex-script-type.min.js +1 -0
  53. llms/extensions/katex/ui/contrib/mathtex-script-type.mjs +24 -0
  54. llms/extensions/katex/ui/contrib/mhchem.js +3213 -0
  55. llms/extensions/katex/ui/contrib/mhchem.min.js +1 -0
  56. llms/extensions/katex/ui/contrib/mhchem.mjs +3109 -0
  57. llms/extensions/katex/ui/contrib/render-a11y-string.js +887 -0
  58. llms/extensions/katex/ui/contrib/render-a11y-string.min.js +1 -0
  59. llms/extensions/katex/ui/contrib/render-a11y-string.mjs +800 -0
  60. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.ttf +0 -0
  61. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.woff +0 -0
  62. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  63. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  64. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  65. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  66. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  67. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  68. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  69. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  70. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  71. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  72. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  73. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  74. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  75. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.ttf +0 -0
  76. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.woff +0 -0
  77. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.woff2 +0 -0
  78. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  79. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  80. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  81. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.ttf +0 -0
  82. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.woff +0 -0
  83. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.woff2 +0 -0
  84. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.ttf +0 -0
  85. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.woff +0 -0
  86. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.woff2 +0 -0
  87. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  88. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  89. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  90. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.ttf +0 -0
  91. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.woff +0 -0
  92. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.woff2 +0 -0
  93. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  94. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  95. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  96. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  97. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  98. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  99. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  100. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  101. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  102. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.ttf +0 -0
  103. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.woff +0 -0
  104. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.woff2 +0 -0
  105. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.ttf +0 -0
  106. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.woff +0 -0
  107. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  108. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.ttf +0 -0
  109. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.woff +0 -0
  110. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  111. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.ttf +0 -0
  112. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.woff +0 -0
  113. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  114. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.ttf +0 -0
  115. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.woff +0 -0
  116. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  117. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  118. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  119. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  120. llms/extensions/katex/ui/index.mjs +92 -0
  121. llms/extensions/katex/ui/katex-swap.css +1230 -0
  122. llms/extensions/katex/ui/katex-swap.min.css +1 -0
  123. llms/extensions/katex/ui/katex.css +1230 -0
  124. llms/extensions/katex/ui/katex.js +19080 -0
  125. llms/extensions/katex/ui/katex.min.css +1 -0
  126. llms/extensions/katex/ui/katex.min.js +1 -0
  127. llms/extensions/katex/ui/katex.min.mjs +1 -0
  128. llms/extensions/katex/ui/katex.mjs +18547 -0
  129. llms/extensions/providers/__init__.py +18 -0
  130. llms/extensions/providers/__pycache__/__init__.cpython-314.pyc +0 -0
  131. llms/extensions/providers/__pycache__/anthropic.cpython-314.pyc +0 -0
  132. llms/extensions/providers/__pycache__/chutes.cpython-314.pyc +0 -0
  133. llms/extensions/providers/__pycache__/google.cpython-314.pyc +0 -0
  134. llms/{providers → extensions/providers}/__pycache__/nvidia.cpython-314.pyc +0 -0
  135. llms/{providers → extensions/providers}/__pycache__/openai.cpython-314.pyc +0 -0
  136. llms/extensions/providers/__pycache__/openrouter.cpython-314.pyc +0 -0
  137. llms/{providers → extensions/providers}/anthropic.py +45 -5
  138. llms/{providers → extensions/providers}/chutes.py +21 -18
  139. llms/{providers → extensions/providers}/google.py +99 -27
  140. llms/{providers → extensions/providers}/nvidia.py +6 -8
  141. llms/{providers → extensions/providers}/openai.py +3 -6
  142. llms/{providers → extensions/providers}/openrouter.py +12 -10
  143. llms/extensions/system_prompts/__init__.py +45 -0
  144. llms/extensions/system_prompts/__pycache__/__init__.cpython-314.pyc +0 -0
  145. llms/extensions/system_prompts/ui/index.mjs +285 -0
  146. llms/extensions/system_prompts/ui/prompts.json +1067 -0
  147. llms/extensions/tools/__init__.py +5 -0
  148. llms/extensions/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  149. llms/{ui/modules/tools.mjs → extensions/tools/ui/index.mjs} +12 -10
  150. llms/index.html +26 -38
  151. llms/llms.json +20 -1
  152. llms/main.py +845 -245
  153. llms/providers-extra.json +0 -32
  154. llms/ui/App.mjs +18 -20
  155. llms/ui/ai.mjs +38 -15
  156. llms/ui/app.css +1440 -59
  157. llms/ui/ctx.mjs +154 -18
  158. llms/ui/index.mjs +17 -14
  159. llms/ui/lib/vue.min.mjs +10 -9
  160. llms/ui/lib/vue.mjs +1796 -1635
  161. llms/ui/markdown.mjs +4 -2
  162. llms/ui/modules/chat/ChatBody.mjs +101 -334
  163. llms/ui/modules/chat/HomeTools.mjs +12 -0
  164. llms/ui/modules/chat/SettingsDialog.mjs +1 -1
  165. llms/ui/modules/chat/index.mjs +351 -314
  166. llms/ui/modules/layout.mjs +2 -26
  167. llms/ui/modules/model-selector.mjs +3 -3
  168. llms/ui/tailwind.input.css +35 -1
  169. llms/ui/utils.mjs +33 -3
  170. {llms_py-3.0.0b6.dist-info → llms_py-3.0.0b8.dist-info}/METADATA +1 -1
  171. llms_py-3.0.0b8.dist-info/RECORD +198 -0
  172. llms/providers/__pycache__/anthropic.cpython-314.pyc +0 -0
  173. llms/providers/__pycache__/chutes.cpython-314.pyc +0 -0
  174. llms/providers/__pycache__/google.cpython-314.pyc +0 -0
  175. llms/providers/__pycache__/openrouter.cpython-314.pyc +0 -0
  176. llms/ui/modules/threads/threadStore.mjs +0 -586
  177. llms_py-3.0.0b6.dist-info/RECORD +0 -66
  178. {llms_py-3.0.0b6.dist-info → llms_py-3.0.0b8.dist-info}/WHEEL +0 -0
  179. {llms_py-3.0.0b6.dist-info → llms_py-3.0.0b8.dist-info}/entry_points.txt +0 -0
  180. {llms_py-3.0.0b6.dist-info → llms_py-3.0.0b8.dist-info}/licenses/LICENSE +0 -0
  181. {llms_py-3.0.0b6.dist-info → llms_py-3.0.0b8.dist-info}/top_level.txt +0 -0
llms/main.py CHANGED
@@ -9,6 +9,7 @@
9
9
  import argparse
10
10
  import asyncio
11
11
  import base64
12
+ import contextlib
12
13
  import hashlib
13
14
  import importlib.util
14
15
  import inspect
@@ -40,12 +41,12 @@ try:
40
41
  except ImportError:
41
42
  HAS_PIL = False
42
43
 
43
- VERSION = "3.0.0b6"
44
+ VERSION = "3.0.0b8"
44
45
  _ROOT = None
45
- DEBUG = True # os.getenv("PYPI_SERVICESTACK") is not None
46
- MOCK = False
47
- MOCK_DIR = os.getenv("MOCK_DIR")
46
+ DEBUG = os.getenv("DEBUG") == "1"
48
47
  MOCK = os.getenv("MOCK") == "1"
48
+ MOCK_DIR = os.getenv("MOCK_DIR")
49
+ DISABLE_EXTENSIONS = (os.getenv("LLMS_DISABLE") or "").split(",")
49
50
  g_config_path = None
50
51
  g_config = None
51
52
  g_providers = None
@@ -324,6 +325,15 @@ def convert_image_if_needed(image_bytes, mimetype="image/png"):
324
325
  return image_bytes, mimetype
325
326
 
326
327
 
328
+ def to_content(result):
329
+ if isinstance(result, (str, int, float, bool)):
330
+ return str(result)
331
+ elif isinstance(result, (list, set, tuple, dict)):
332
+ return json.dumps(result)
333
+ else:
334
+ return str(result)
335
+
336
+
327
337
  def function_to_tool_definition(func):
328
338
  type_hints = get_type_hints(func)
329
339
  signature = inspect.signature(func)
@@ -332,11 +342,11 @@ def function_to_tool_definition(func):
332
342
  for name, param in signature.parameters.items():
333
343
  param_type = type_hints.get(name, str)
334
344
  param_type_name = "string"
335
- if param_type == int:
345
+ if param_type is int:
336
346
  param_type_name = "integer"
337
- elif param_type == float:
347
+ elif param_type is float:
338
348
  param_type_name = "number"
339
- elif param_type == bool:
349
+ elif param_type is bool:
340
350
  param_type_name = "boolean"
341
351
 
342
352
  parameters["properties"][name] = {"type": param_type_name}
@@ -484,6 +494,92 @@ async def process_chat(chat, provider_id=None):
484
494
  return chat
485
495
 
486
496
 
497
+ def image_ext_from_mimetype(mimetype, default="png"):
498
+ if "/" in mimetype:
499
+ _ext = mimetypes.guess_extension(mimetype)
500
+ if _ext:
501
+ return _ext.lstrip(".")
502
+ return default
503
+
504
+
505
+ def audio_ext_from_format(format, default="mp3"):
506
+ if format == "mpeg":
507
+ return "mp3"
508
+ return format or default
509
+
510
+
511
+ def file_ext_from_mimetype(mimetype, default="pdf"):
512
+ if "/" in mimetype:
513
+ _ext = mimetypes.guess_extension(mimetype)
514
+ if _ext:
515
+ return _ext.lstrip(".")
516
+ return default
517
+
518
+
519
+ def cache_message_inline_data(m):
520
+ """
521
+ Replaces and caches any inline data URIs in the message content.
522
+ """
523
+ if "content" not in m:
524
+ return
525
+
526
+ content = m["content"]
527
+ if isinstance(content, list):
528
+ for item in content:
529
+ if item.get("type") == "image_url":
530
+ image_url = item.get("image_url", {})
531
+ url = image_url.get("url")
532
+ if url and url.startswith("data:"):
533
+ # Extract base64 and mimetype
534
+ try:
535
+ header, base64_data = url.split(";base64,")
536
+ # header is like "data:image/png"
537
+ ext = image_ext_from_mimetype(header.split(":")[1])
538
+ filename = f"image.{ext}" # Hash will handle uniqueness
539
+
540
+ cache_url, _ = save_image_to_cache(base64_data, filename, {}, ignore_info=True)
541
+ image_url["url"] = cache_url
542
+ except Exception as e:
543
+ _log(f"Error caching inline image: {e}")
544
+
545
+ elif item.get("type") == "input_audio":
546
+ input_audio = item.get("input_audio", {})
547
+ data = input_audio.get("data")
548
+ if data:
549
+ # Handle data URI or raw base64
550
+ base64_data = data
551
+ if data.startswith("data:"):
552
+ with contextlib.suppress(ValueError):
553
+ header, base64_data = data.split(";base64,")
554
+
555
+ fmt = audio_ext_from_format(input_audio.get("format"))
556
+ filename = f"audio.{fmt}"
557
+
558
+ try:
559
+ cache_url, _ = save_bytes_to_cache(base64_data, filename, {}, ignore_info=True)
560
+ input_audio["data"] = cache_url
561
+ except Exception as e:
562
+ _log(f"Error caching inline audio: {e}")
563
+
564
+ elif item.get("type") == "file":
565
+ file_info = item.get("file", {})
566
+ file_data = file_info.get("file_data")
567
+ if file_data and file_data.startswith("data:"):
568
+ try:
569
+ header, base64_data = file_data.split(";base64,")
570
+ mimetype = header.split(":")[1]
571
+ # Try to get extension from filename if available, else mimetype
572
+ filename = file_info.get("filename", "file")
573
+ if "." not in filename:
574
+ ext = file_ext_from_mimetype(mimetype)
575
+ filename = f"{filename}.{ext}"
576
+
577
+ cache_url, _ = save_bytes_to_cache(base64_data, filename, {}, ignore_info=True)
578
+ file_info["file_data"] = cache_url
579
+ except Exception as e:
580
+ _log(f"Error caching inline file: {e}")
581
+
582
+
487
583
  class HTTPError(Exception):
488
584
  def __init__(self, status, reason, body, headers=None):
489
585
  self.status = status
@@ -493,7 +589,7 @@ class HTTPError(Exception):
493
589
  super().__init__(f"HTTP {status} {reason}")
494
590
 
495
591
 
496
- def save_image_to_cache(base64_data, filename, image_info):
592
+ def save_bytes_to_cache(base64_data, filename, file_info, ignore_info=False):
497
593
  ext = filename.split(".")[-1]
498
594
  mimetype = get_file_mime_type(filename)
499
595
  content = base64.b64decode(base64_data) if isinstance(base64_data, str) else base64_data
@@ -505,12 +601,61 @@ def save_image_to_cache(base64_data, filename, image_info):
505
601
  subdir = sha256_hash[:2]
506
602
  relative_path = f"{subdir}/{save_filename}"
507
603
  full_path = get_cache_path(relative_path)
604
+ url = f"/~cache/{relative_path}"
605
+
606
+ # if file and its .info.json already exists, return it
607
+ info_path = os.path.splitext(full_path)[0] + ".info.json"
608
+ if os.path.exists(full_path) and os.path.exists(info_path):
609
+ _dbg(f"Cached bytes exists: {relative_path}")
610
+ if ignore_info:
611
+ return url, None
612
+ return url, json.load(open(info_path))
508
613
 
509
- url = f"~cache/{relative_path}"
614
+ os.makedirs(os.path.dirname(full_path), exist_ok=True)
615
+
616
+ with open(full_path, "wb") as f:
617
+ f.write(content)
618
+ info = {
619
+ "date": int(time.time()),
620
+ "url": url,
621
+ "size": len(content),
622
+ "type": mimetype,
623
+ "name": filename,
624
+ }
625
+ info.update(file_info)
626
+
627
+ # Save metadata
628
+ info_path = os.path.splitext(full_path)[0] + ".info.json"
629
+ with open(info_path, "w") as f:
630
+ json.dump(info, f)
631
+
632
+ _dbg(f"Saved cached bytes and info: {relative_path}")
633
+
634
+ g_app.on_cache_saved_filters({"url": url, "info": info})
635
+
636
+ return url, info
637
+
638
+
639
+ def save_image_to_cache(base64_data, filename, image_info, ignore_info=False):
640
+ ext = filename.split(".")[-1]
641
+ mimetype = get_file_mime_type(filename)
642
+ content = base64.b64decode(base64_data) if isinstance(base64_data, str) else base64_data
643
+ sha256_hash = hashlib.sha256(content).hexdigest()
644
+
645
+ save_filename = f"{sha256_hash}.{ext}" if ext else sha256_hash
646
+
647
+ # Use first 2 chars for subdir to avoid too many files in one dir
648
+ subdir = sha256_hash[:2]
649
+ relative_path = f"{subdir}/{save_filename}"
650
+ full_path = get_cache_path(relative_path)
651
+ url = f"/~cache/{relative_path}"
510
652
 
511
653
  # if file and its .info.json already exists, return it
512
654
  info_path = os.path.splitext(full_path)[0] + ".info.json"
513
655
  if os.path.exists(full_path) and os.path.exists(info_path):
656
+ _dbg(f"Saved image exists: {relative_path}")
657
+ if ignore_info:
658
+ return url, None
514
659
  return url, json.load(open(info_path))
515
660
 
516
661
  os.makedirs(os.path.dirname(full_path), exist_ok=True)
@@ -545,6 +690,10 @@ def save_image_to_cache(base64_data, filename, image_info):
545
690
  with open(info_path, "w") as f:
546
691
  json.dump(info, f)
547
692
 
693
+ _dbg(f"Saved image and info: {relative_path}")
694
+
695
+ g_app.on_cache_saved_filters({"url": url, "info": info})
696
+
548
697
  return url, info
549
698
 
550
699
 
@@ -577,6 +726,27 @@ def chat_to_prompt(chat):
577
726
  return prompt
578
727
 
579
728
 
729
+ def chat_to_system_prompt(chat):
730
+ if "messages" in chat:
731
+ for message in chat["messages"]:
732
+ if message["role"] == "system":
733
+ # if content is string
734
+ if isinstance(message["content"], str):
735
+ return message["content"]
736
+ elif isinstance(message["content"], list):
737
+ # if content is array of objects
738
+ for part in message["content"]:
739
+ if part["type"] == "text":
740
+ return part["text"]
741
+ return None
742
+
743
+
744
+ def chat_to_username(chat):
745
+ if "metadata" in chat and "user" in chat["metadata"]:
746
+ return chat["metadata"]["user"]
747
+ return None
748
+
749
+
580
750
  def last_user_prompt(chat):
581
751
  prompt = ""
582
752
  if "messages" in chat:
@@ -593,6 +763,49 @@ def last_user_prompt(chat):
593
763
  return prompt
594
764
 
595
765
 
766
+ def chat_response_to_message(openai_response):
767
+ """
768
+ Returns an assistant message from the OpenAI Response.
769
+ Handles normalizing text, image, and audio responses into the message content.
770
+ """
771
+ timestamp = int(time.time() * 1000) # openai_response.get("created")
772
+ choices = openai_response
773
+ if isinstance(openai_response, dict) and "choices" in openai_response:
774
+ choices = openai_response["choices"]
775
+
776
+ choice = choices[0] if isinstance(choices, list) and choices else choices
777
+
778
+ if isinstance(choice, str):
779
+ return {"role": "assistant", "content": choice, "timestamp": timestamp}
780
+
781
+ if isinstance(choice, dict):
782
+ message = choice.get("message", choice)
783
+ else:
784
+ return {"role": "assistant", "content": str(choice), "timestamp": timestamp}
785
+
786
+ # Ensure message is a dict
787
+ if not isinstance(message, dict):
788
+ return {"role": "assistant", "content": message, "timestamp": timestamp}
789
+
790
+ message.update({"timestamp": timestamp})
791
+ return message
792
+
793
+
794
+ def to_file_info(chat, info=None, response=None):
795
+ prompt = last_user_prompt(chat)
796
+ ret = info or {}
797
+ if chat["model"] and "model" not in ret:
798
+ ret["model"] = chat["model"]
799
+ if prompt and "prompt" not in ret:
800
+ ret["prompt"] = prompt
801
+ if "image_config" in chat:
802
+ ret.update(chat["image_config"])
803
+ user = chat_to_username(chat)
804
+ if user:
805
+ ret["user"] = user
806
+ return ret
807
+
808
+
596
809
  # Image Generator Providers
597
810
  class GeneratorBase:
598
811
  def __init__(self, **kwargs):
@@ -755,13 +968,17 @@ class OpenAiCompatible:
755
968
  if not self.models:
756
969
  await self.load_models()
757
970
 
758
- def model_cost(self, model):
971
+ def model_info(self, model):
759
972
  provider_model = self.provider_model(model) or model
760
973
  for model_id, model_info in self.models.items():
761
974
  if model_id.lower() == provider_model.lower():
762
- return model_info.get("cost")
975
+ return model_info
763
976
  return None
764
977
 
978
+ def model_cost(self, model):
979
+ model_info = self.model_info(model)
980
+ return model_info.get("cost") if model_info else None
981
+
765
982
  def provider_model(self, model):
766
983
  # convert model to lowercase for case-insensitive comparison
767
984
  model_lower = model.lower()
@@ -823,7 +1040,7 @@ class OpenAiCompatible:
823
1040
  chat["model"] = self.provider_model(chat["model"]) or chat["model"]
824
1041
 
825
1042
  if "modalities" in chat:
826
- for modality in chat["modalities"]:
1043
+ for modality in chat.get("modalities", []):
827
1044
  # use default implementation for text modalities
828
1045
  if modality == "text":
829
1046
  continue
@@ -875,13 +1092,14 @@ class OpenAiCompatible:
875
1092
  _log(f"POST {self.chat_url}")
876
1093
  _log(chat_summary(chat))
877
1094
  # remove metadata if any (conflicts with some providers, e.g. Z.ai)
878
- chat.pop("metadata", None)
1095
+ metadata = chat.pop("metadata", None)
879
1096
 
880
1097
  async with aiohttp.ClientSession() as session:
881
1098
  started_at = time.time()
882
1099
  async with session.post(
883
1100
  self.chat_url, headers=self.headers, data=json.dumps(chat), timeout=aiohttp.ClientTimeout(total=120)
884
1101
  ) as response:
1102
+ chat["metadata"] = metadata
885
1103
  return self.to_response(await response_json(response), chat, started_at)
886
1104
 
887
1105
 
@@ -1053,29 +1271,105 @@ def api_providers():
1053
1271
  return ret
1054
1272
 
1055
1273
 
1056
- async def chat_completion(chat):
1057
- model = chat["model"]
1058
- # get first provider that has the model
1059
- candidate_providers = [name for name, provider in g_handlers.items() if provider.provider_model(model)]
1060
- if len(candidate_providers) == 0:
1061
- raise (Exception(f"Model {model} not found"))
1274
+ def to_error_message(e):
1275
+ return str(e)
1276
+
1277
+
1278
+ def to_error_response(e, stacktrace=False):
1279
+ status = {"errorCode": "Error", "message": to_error_message(e)}
1280
+ if stacktrace:
1281
+ status["stackTrace"] = traceback.format_exc()
1282
+ return {"responseStatus": status}
1283
+
1284
+
1285
+ def create_error_response(message, error_code="Error", stack_trace=None):
1286
+ ret = {"responseStatus": {"errorCode": error_code, "message": message}}
1287
+ if stack_trace:
1288
+ ret["responseStatus"]["stackTrace"] = stack_trace
1289
+ return ret
1290
+
1291
+
1292
+ def should_cancel_thread(context):
1293
+ ret = context.get("cancelled", False)
1294
+ if ret:
1295
+ thread_id = context.get("threadId")
1296
+ _dbg(f"Thread cancelled {thread_id}")
1297
+ return ret
1298
+
1299
+
1300
+ def g_chat_request(template=None, text=None, model=None, system_prompt=None):
1301
+ chat_template = g_config["defaults"].get(template or "text")
1302
+ if not chat_template:
1303
+ raise Exception(f"Chat template '{template}' not found")
1304
+
1305
+ chat = chat_template.copy()
1306
+ if model:
1307
+ chat["model"] = model
1308
+ if system_prompt is not None:
1309
+ chat["messages"].insert(0, {"role": "system", "content": system_prompt})
1310
+ if text is not None:
1311
+ if not chat["messages"] or len(chat["messages"]) == 0:
1312
+ chat["messages"] = [{"role": "user", "content": [{"type": "text", "text": ""}]}]
1313
+
1314
+ # replace content of last message if exists, else add
1315
+ last_msg = chat["messages"][-1] if "messages" in chat else None
1316
+ if last_msg and last_msg["role"] == "user":
1317
+ if isinstance(last_msg["content"], list):
1318
+ last_msg["content"][-1]["text"] = text
1319
+ else:
1320
+ last_msg["content"] = text
1321
+ else:
1322
+ chat["messages"].append({"role": "user", "content": text})
1323
+
1324
+ return chat
1325
+
1326
+
1327
+ async def g_chat_completion(chat, context=None):
1328
+ try:
1329
+ model = chat.get("model")
1330
+ if not model:
1331
+ raise Exception("Model not specified")
1332
+
1333
+ if context is None:
1334
+ context = {"chat": chat, "tools": "all"}
1062
1335
 
1336
+ # get first provider that has the model
1337
+ candidate_providers = [name for name, provider in g_handlers.items() if provider.provider_model(model)]
1338
+ if len(candidate_providers) == 0:
1339
+ raise (Exception(f"Model {model} not found"))
1340
+ except Exception as e:
1341
+ await g_app.on_chat_error(e, context or {"chat": chat})
1342
+ raise e
1343
+
1344
+ started_at = time.time()
1063
1345
  first_exception = None
1346
+ provider_name = "Unknown"
1064
1347
  for name in candidate_providers:
1065
- provider = g_handlers[name]
1066
- _log(f"provider: {name} {type(provider).__name__}")
1067
1348
  try:
1068
- # Inject global tools if present
1069
- current_chat = chat.copy()
1349
+ provider_name = name
1350
+ provider = g_handlers[name]
1351
+ _log(f"provider: {name} {type(provider).__name__}")
1352
+ started_at = time.time()
1353
+ context["startedAt"] = datetime.now()
1354
+ context["provider"] = name
1355
+ model_info = provider.model_info(model)
1356
+ context["modelCost"] = model_info.get("cost", provider.model_cost(model)) or {"input": 0, "output": 0}
1357
+ context["modelInfo"] = model_info
1358
+
1359
+ # Accumulate usage across tool calls
1360
+ total_usage = {
1361
+ "prompt_tokens": 0,
1362
+ "completion_tokens": 0,
1363
+ "total_tokens": 0,
1364
+ }
1365
+ accumulated_cost = 0.0
1366
+
1070
1367
  # Inject global tools if present
1071
1368
  current_chat = chat.copy()
1072
1369
  if g_app.tool_definitions:
1073
- include_all_tools = False
1074
- only_tools = []
1075
- if "metadata" in chat:
1076
- only_tools_str = chat["metadata"].get("only_tools", "")
1077
- include_all_tools = only_tools_str == "all"
1078
- only_tools = only_tools_str.split(",")
1370
+ only_tools_str = context.get("tools", "all")
1371
+ include_all_tools = only_tools_str == "all"
1372
+ only_tools = only_tools_str.split(",")
1079
1373
 
1080
1374
  if include_all_tools or len(only_tools) > 0:
1081
1375
  if "tools" not in current_chat:
@@ -1087,12 +1381,38 @@ async def chat_completion(chat):
1087
1381
  if name not in existing_tools and (include_all_tools or name in only_tools):
1088
1382
  current_chat["tools"].append(tool_def)
1089
1383
 
1384
+ # Apply pre-chat filters ONCE
1385
+ context["chat"] = current_chat
1386
+ for filter_func in g_app.chat_request_filters:
1387
+ await filter_func(current_chat, context)
1388
+
1090
1389
  # Tool execution loop
1091
- max_iterations = 5
1390
+ max_iterations = 10
1092
1391
  tool_history = []
1392
+ final_response = None
1393
+
1093
1394
  for _ in range(max_iterations):
1395
+ if should_cancel_thread(context):
1396
+ return
1397
+
1094
1398
  response = await provider.chat(current_chat)
1095
1399
 
1400
+ if should_cancel_thread(context):
1401
+ return
1402
+
1403
+ # Aggregate usage
1404
+ if "usage" in response:
1405
+ usage = response["usage"]
1406
+ total_usage["prompt_tokens"] += usage.get("prompt_tokens", 0)
1407
+ total_usage["completion_tokens"] += usage.get("completion_tokens", 0)
1408
+ total_usage["total_tokens"] += usage.get("total_tokens", 0)
1409
+
1410
+ # Calculate cost for this step if available
1411
+ if "cost" in response and isinstance(response["cost"], (int, float)):
1412
+ accumulated_cost += response["cost"]
1413
+ elif "cost" in usage and isinstance(usage["cost"], (int, float)):
1414
+ accumulated_cost += usage["cost"]
1415
+
1096
1416
  # Check for tool_calls in the response
1097
1417
  choice = response.get("choices", [])[0] if response.get("choices") else {}
1098
1418
  message = choice.get("message", {})
@@ -1102,48 +1422,85 @@ async def chat_completion(chat):
1102
1422
  # Append the assistant's message with tool calls to history
1103
1423
  if "messages" not in current_chat:
1104
1424
  current_chat["messages"] = []
1425
+ if "timestamp" not in message:
1426
+ message["timestamp"] = int(time.time() * 1000)
1105
1427
  current_chat["messages"].append(message)
1106
1428
  tool_history.append(message)
1107
1429
 
1108
1430
  for tool_call in tool_calls:
1109
1431
  function_name = tool_call["function"]["name"]
1110
- function_args = json.loads(tool_call["function"]["arguments"])
1111
-
1112
- tool_result = f"Error: Tool {function_name} not found"
1113
- if function_name in g_app.tools:
1114
- try:
1115
- func = g_app.tools[function_name]
1116
- if inspect.iscoroutinefunction(func):
1117
- tool_result = await func(**function_args)
1118
- else:
1119
- tool_result = func(**function_args)
1120
- except Exception as e:
1121
- tool_result = f"Error executing tool {function_name}: {e}"
1432
+ try:
1433
+ function_args = json.loads(tool_call["function"]["arguments"])
1434
+ except Exception as e:
1435
+ tool_result = f"Error parsing JSON arguments for tool {function_name}: {e}"
1436
+ else:
1437
+ tool_result = f"Error: Tool {function_name} not found"
1438
+ if function_name in g_app.tools:
1439
+ try:
1440
+ func = g_app.tools[function_name]
1441
+ if inspect.iscoroutinefunction(func):
1442
+ tool_result = await func(**function_args)
1443
+ else:
1444
+ tool_result = func(**function_args)
1445
+ except Exception as e:
1446
+ tool_result = f"Error executing tool {function_name}: {e}"
1122
1447
 
1123
1448
  # Append tool result to history
1124
- tool_msg = {"role": "tool", "tool_call_id": tool_call["id"], "content": str(tool_result)}
1449
+ tool_msg = {"role": "tool", "tool_call_id": tool_call["id"], "content": to_content(tool_result)}
1125
1450
  current_chat["messages"].append(tool_msg)
1126
1451
  tool_history.append(tool_msg)
1127
1452
 
1453
+ for filter_func in g_app.chat_tool_filters:
1454
+ await filter_func(current_chat, context)
1455
+
1456
+ if should_cancel_thread(context):
1457
+ return
1458
+
1128
1459
  # Continue loop to send tool results back to LLM
1129
1460
  continue
1130
1461
 
1131
- # If no tool calls, return the response
1462
+ # If no tool calls, this is the final response
1132
1463
  if tool_history:
1133
1464
  response["tool_history"] = tool_history
1134
- return response
1465
+
1466
+ # Update final response with aggregated usage
1467
+ if "usage" not in response:
1468
+ response["usage"] = {}
1469
+ # convert to int seconds
1470
+ context["duration"] = duration = int(time.time() - started_at)
1471
+ total_usage.update({"duration": duration})
1472
+ response["usage"].update(total_usage)
1473
+ # If we accumulated cost, set it on the response
1474
+ if accumulated_cost > 0:
1475
+ response["cost"] = accumulated_cost
1476
+
1477
+ final_response = response
1478
+ break # Exit tool loop
1479
+
1480
+ if final_response:
1481
+ # Apply post-chat filters ONCE on final response
1482
+ for filter_func in g_app.chat_response_filters:
1483
+ await filter_func(final_response, context)
1484
+
1485
+ if DEBUG:
1486
+ _dbg(json.dumps(final_response, indent=2))
1487
+
1488
+ return final_response
1135
1489
 
1136
1490
  except Exception as e:
1137
1491
  if first_exception is None:
1138
1492
  first_exception = e
1139
- _log(f"Provider {name} failed: {e}")
1493
+ context["stackTrace"] = traceback.format_exc()
1494
+ _err(f"Provider {provider_name} failed", first_exception)
1495
+ await g_app.on_chat_error(e, context)
1496
+
1140
1497
  continue
1141
1498
 
1142
1499
  # If we get here, all providers failed
1143
1500
  raise first_exception
1144
1501
 
1145
1502
 
1146
- async def cli_chat(chat, image=None, audio=None, file=None, args=None, raw=False):
1503
+ async def cli_chat(chat, tools=None, image=None, audio=None, file=None, args=None, raw=False):
1147
1504
  if g_default_model:
1148
1505
  chat["model"] = g_default_model
1149
1506
 
@@ -1218,16 +1575,11 @@ async def cli_chat(chat, image=None, audio=None, file=None, args=None, raw=False
1218
1575
  printdump(chat)
1219
1576
 
1220
1577
  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)
1225
-
1226
- response = await chat_completion(chat)
1578
+ context = {
1579
+ "tools": tools or "all",
1580
+ }
1581
+ response = await g_app.chat_completion(chat, context=context)
1227
1582
 
1228
- # Apply post-chat filters
1229
- for filter_func in g_app.chat_response_filters:
1230
- response = await filter_func(response, context)
1231
1583
  if raw:
1232
1584
  print(json.dumps(response, indent=2))
1233
1585
  exit(0)
@@ -1244,28 +1596,32 @@ async def cli_chat(chat, image=None, audio=None, file=None, args=None, raw=False
1244
1596
  for image in msg["images"]:
1245
1597
  image_url = image["image_url"]["url"]
1246
1598
  generated_files.append(image_url)
1599
+ if "audios" in msg:
1600
+ for audio in msg["audios"]:
1601
+ audio_url = audio["audio_url"]["url"]
1602
+ generated_files.append(audio_url)
1247
1603
 
1248
1604
  if len(generated_files) > 0:
1249
1605
  print("\nSaved files:")
1250
1606
  for file in generated_files:
1251
- if file.startswith("~cache"):
1252
- print(get_cache_path(file[7:]))
1253
- _log(f"http://localhost:8000/{file}")
1607
+ if file.startswith("/~cache"):
1608
+ print(get_cache_path(file[8:]))
1609
+ print(f"http://localhost:8000/{file}")
1254
1610
  else:
1255
1611
  print(file)
1256
1612
 
1257
1613
  except HTTPError as e:
1258
1614
  # HTTP error (4xx, 5xx)
1259
1615
  print(f"{e}:\n{e.body}")
1260
- exit(1)
1616
+ g_app.exit(1)
1261
1617
  except aiohttp.ClientConnectionError as e:
1262
1618
  # Connection issues
1263
1619
  print(f"Connection error: {e}")
1264
- exit(1)
1620
+ g_app.exit(1)
1265
1621
  except asyncio.TimeoutError as e:
1266
1622
  # Timeout
1267
1623
  print(f"Timeout error: {e}")
1268
- exit(1)
1624
+ g_app.exit(1)
1269
1625
 
1270
1626
 
1271
1627
  def config_str(key):
@@ -1288,7 +1644,7 @@ def init_llms(config, providers):
1288
1644
  # iterate over config and replace $ENV with env value
1289
1645
  for key, value in g_config.items():
1290
1646
  if isinstance(value, str) and value.startswith("$"):
1291
- g_config[key] = os.environ.get(value[1:], "")
1647
+ g_config[key] = os.getenv(value[1:], "")
1292
1648
 
1293
1649
  # if g_verbose:
1294
1650
  # printdump(g_config)
@@ -1326,11 +1682,11 @@ def create_provider_kwargs(definition, provider=None):
1326
1682
  if "api_key" in provider:
1327
1683
  value = provider["api_key"]
1328
1684
  if isinstance(value, str) and value.startswith("$"):
1329
- provider["api_key"] = os.environ.get(value[1:], "")
1685
+ provider["api_key"] = os.getenv(value[1:], "")
1330
1686
 
1331
1687
  if "api_key" not in provider and "env" in provider:
1332
1688
  for env_var in provider["env"]:
1333
- val = os.environ.get(env_var)
1689
+ val = os.getenv(env_var)
1334
1690
  if val:
1335
1691
  provider["api_key"] = val
1336
1692
  break
@@ -1467,11 +1823,11 @@ def print_status():
1467
1823
 
1468
1824
 
1469
1825
  def home_llms_path(filename):
1470
- return f"{os.environ.get('HOME')}/.llms/{filename}"
1826
+ return f"{os.getenv('HOME')}/.llms/{filename}"
1471
1827
 
1472
1828
 
1473
- def get_cache_path(filename):
1474
- return home_llms_path(f"cache/{filename}")
1829
+ def get_cache_path(path=""):
1830
+ return home_llms_path(f"cache/{path}") if path else home_llms_path("cache")
1475
1831
 
1476
1832
 
1477
1833
  def get_config_path():
@@ -1480,8 +1836,8 @@ def get_config_path():
1480
1836
  "./llms.json",
1481
1837
  home_config_path,
1482
1838
  ]
1483
- if os.environ.get("LLMS_CONFIG_PATH"):
1484
- check_paths.insert(0, os.environ.get("LLMS_CONFIG_PATH"))
1839
+ if os.getenv("LLMS_CONFIG_PATH"):
1840
+ check_paths.insert(0, os.getenv("LLMS_CONFIG_PATH"))
1485
1841
 
1486
1842
  for check_path in check_paths:
1487
1843
  g_config_path = os.path.normpath(os.path.join(os.path.dirname(__file__), check_path))
@@ -1951,14 +2307,41 @@ class AppExtensions:
1951
2307
  def __init__(self, cli_args, extra_args):
1952
2308
  self.cli_args = cli_args
1953
2309
  self.extra_args = extra_args
2310
+ self.config = None
2311
+ self.error_auth_required = create_error_response("Authentication required", "Unauthorized")
1954
2312
  self.ui_extensions = []
1955
2313
  self.chat_request_filters = []
2314
+ self.chat_tool_filters = []
1956
2315
  self.chat_response_filters = []
2316
+ self.chat_error_filters = []
1957
2317
  self.server_add_get = []
1958
2318
  self.server_add_post = []
1959
- self.server_add_post = []
2319
+ self.server_add_put = []
2320
+ self.server_add_delete = []
2321
+ self.server_add_patch = []
2322
+ self.cache_saved_filters = []
2323
+ self.shutdown_handlers = []
1960
2324
  self.tools = {}
1961
2325
  self.tool_definitions = []
2326
+ self.index_headers = []
2327
+ self.index_footers = []
2328
+ self.request_args = {
2329
+ "image_config": dict, # e.g. { "aspect_ratio": "1:1" }
2330
+ "temperature": float, # e.g: 0.7
2331
+ "max_completion_tokens": int, # e.g: 2048
2332
+ "seed": int, # e.g: 42
2333
+ "top_p": float, # e.g: 0.9
2334
+ "frequency_penalty": float, # e.g: 0.5
2335
+ "presence_penalty": float, # e.g: 0.5
2336
+ "stop": list, # e.g: ["Stop"]
2337
+ "reasoning_effort": str, # e.g: minimal, low, medium, high
2338
+ "verbosity": str, # e.g: low, medium, high
2339
+ "service_tier": str, # e.g: auto, default
2340
+ "top_logprobs": int,
2341
+ "safety_identifier": str,
2342
+ "store": bool,
2343
+ "enable_thinking": bool,
2344
+ }
1962
2345
  self.all_providers = [
1963
2346
  OpenAiCompatible,
1964
2347
  MistralProvider,
@@ -1980,11 +2363,108 @@ class AppExtensions:
1980
2363
  "16:9": "1344×768",
1981
2364
  "21:9": "1536×672",
1982
2365
  }
2366
+ self.import_maps = {
2367
+ "vue-prod": "/ui/lib/vue.min.mjs",
2368
+ "vue": "/ui/lib/vue.mjs",
2369
+ "vue-router": "/ui/lib/vue-router.min.mjs",
2370
+ "@servicestack/client": "/ui/lib/servicestack-client.mjs",
2371
+ "@servicestack/vue": "/ui/lib/servicestack-vue.mjs",
2372
+ "idb": "/ui/lib/idb.min.mjs",
2373
+ "marked": "/ui/lib/marked.min.mjs",
2374
+ "highlight.js": "/ui/lib/highlight.min.mjs",
2375
+ "chart.js": "/ui/lib/chart.js",
2376
+ "color.js": "/ui/lib/color.js",
2377
+ "ctx.mjs": "/ui/ctx.mjs",
2378
+ }
2379
+
2380
+ def set_config(self, config):
2381
+ self.config = config
2382
+ self.auth_enabled = self.config.get("auth", {}).get("enabled", False)
2383
+
2384
+ # Authentication middleware helper
2385
+ def check_auth(self, request):
2386
+ """Check if request is authenticated. Returns (is_authenticated, user_data)"""
2387
+ if not self.auth_enabled:
2388
+ return True, None
2389
+
2390
+ # Check for OAuth session token
2391
+ session_token = get_session_token(request)
2392
+ if session_token and session_token in g_sessions:
2393
+ return True, g_sessions[session_token]
2394
+
2395
+ # Check for API key
2396
+ auth_header = request.headers.get("Authorization", "")
2397
+ if auth_header.startswith("Bearer "):
2398
+ api_key = auth_header[7:]
2399
+ if api_key:
2400
+ return True, {"authProvider": "apikey"}
2401
+
2402
+ return False, None
2403
+
2404
+ def get_session(self, request):
2405
+ session_token = get_session_token(request)
2406
+
2407
+ if not session_token or session_token not in g_sessions:
2408
+ return None
2409
+
2410
+ session_data = g_sessions[session_token]
2411
+ return session_data
2412
+
2413
+ def get_username(self, request):
2414
+ session = self.get_session(request)
2415
+ if session:
2416
+ return session.get("userName")
2417
+ return None
2418
+
2419
+ def get_user_path(self, username=None):
2420
+ if username:
2421
+ return home_llms_path(os.path.join("user", username))
2422
+ return home_llms_path(os.path.join("user", "default"))
2423
+
2424
+ def chat_request(self, template=None, text=None, model=None, system_prompt=None):
2425
+ return g_chat_request(template=template, text=text, model=model, system_prompt=system_prompt)
2426
+
2427
+ async def chat_completion(self, chat, context=None):
2428
+ response = await g_chat_completion(chat, context)
2429
+ return response
2430
+
2431
+ def on_cache_saved_filters(self, context):
2432
+ # _log(f"on_cache_saved_filters {len(self.cache_saved_filters)}: {context['url']}")
2433
+ for filter_func in self.cache_saved_filters:
2434
+ filter_func(context)
2435
+
2436
+ async def on_chat_error(self, e, context):
2437
+ # Apply chat error filters
2438
+ if "stackTrace" not in context:
2439
+ context["stackTrace"] = traceback.format_exc()
2440
+ for filter_func in self.chat_error_filters:
2441
+ try:
2442
+ await filter_func(e, context)
2443
+ except Exception as e:
2444
+ _err("chat error filter failed", e)
2445
+
2446
+ def exit(self, exit_code=0):
2447
+ if len(self.shutdown_handlers) > 0:
2448
+ _dbg(f"running {len(self.shutdown_handlers)} shutdown handlers...")
2449
+ for handler in self.shutdown_handlers:
2450
+ handler()
2451
+
2452
+ _dbg(f"exit({exit_code})")
2453
+ sys.exit(exit_code)
2454
+
2455
+
2456
+ def handler_name(handler):
2457
+ if hasattr(handler, "__name__"):
2458
+ return handler.__name__
2459
+ return "unknown"
1983
2460
 
1984
2461
 
1985
2462
  class ExtensionContext:
1986
2463
  def __init__(self, app, path):
1987
2464
  self.app = app
2465
+ self.cli_args = app.cli_args
2466
+ self.extra_args = app.extra_args
2467
+ self.error_auth_required = app.error_auth_required
1988
2468
  self.path = path
1989
2469
  self.name = os.path.basename(path)
1990
2470
  if self.name.endswith(".py"):
@@ -1994,16 +2474,30 @@ class ExtensionContext:
1994
2474
  self.MOCK_DIR = MOCK_DIR
1995
2475
  self.debug = DEBUG
1996
2476
  self.verbose = g_verbose
2477
+ self.aspect_ratios = app.aspect_ratios
2478
+ self.request_args = app.request_args
1997
2479
 
1998
2480
  def chat_to_prompt(self, chat):
1999
2481
  return chat_to_prompt(chat)
2000
2482
 
2483
+ def chat_to_system_prompt(self, chat):
2484
+ return chat_to_system_prompt(chat)
2485
+
2486
+ def chat_response_to_message(self, response):
2487
+ return chat_response_to_message(response)
2488
+
2001
2489
  def last_user_prompt(self, chat):
2002
2490
  return last_user_prompt(chat)
2003
2491
 
2492
+ def to_file_info(self, chat, info=None, response=None):
2493
+ return to_file_info(chat, info=info, response=response)
2494
+
2004
2495
  def save_image_to_cache(self, base64_data, filename, image_info):
2005
2496
  return save_image_to_cache(base64_data, filename, image_info)
2006
2497
 
2498
+ def save_bytes_to_cache(self, bytes_data, filename, file_info):
2499
+ return save_bytes_to_cache(bytes_data, filename, file_info)
2500
+
2007
2501
  def text_from_file(self, path):
2008
2502
  return text_from_file(path)
2009
2503
 
@@ -2026,8 +2520,14 @@ class ExtensionContext:
2026
2520
  if self.verbose:
2027
2521
  print(traceback.format_exc(), flush=True)
2028
2522
 
2523
+ def error_message(self, e):
2524
+ return to_error_message(e)
2525
+
2526
+ def error_response(self, e, stacktrace=False):
2527
+ return to_error_response(e, stacktrace=stacktrace)
2528
+
2029
2529
  def add_provider(self, provider):
2030
- self.log(f"Registered provider: {provider}")
2530
+ self.log(f"Registered provider: {provider.__name__}")
2031
2531
  self.app.all_providers.append(provider)
2032
2532
 
2033
2533
  def register_ui_extension(self, index):
@@ -2036,13 +2536,29 @@ class ExtensionContext:
2036
2536
  self.app.ui_extensions.append({"id": self.name, "path": path})
2037
2537
 
2038
2538
  def register_chat_request_filter(self, handler):
2039
- self.log(f"Registered chat request filter: {handler}")
2539
+ self.log(f"Registered chat request filter: {handler_name(handler)}")
2040
2540
  self.app.chat_request_filters.append(handler)
2041
2541
 
2542
+ def register_chat_tool_filter(self, handler):
2543
+ self.log(f"Registered chat tool filter: {handler_name(handler)}")
2544
+ self.app.chat_tool_filters.append(handler)
2545
+
2042
2546
  def register_chat_response_filter(self, handler):
2043
- self.log(f"Registered chat response filter: {handler}")
2547
+ self.log(f"Registered chat response filter: {handler_name(handler)}")
2044
2548
  self.app.chat_response_filters.append(handler)
2045
2549
 
2550
+ def register_chat_error_filter(self, handler):
2551
+ self.log(f"Registered chat error filter: {handler_name(handler)}")
2552
+ self.app.chat_error_filters.append(handler)
2553
+
2554
+ def register_cache_saved_filter(self, handler):
2555
+ self.log(f"Registered cache saved filter: {handler_name(handler)}")
2556
+ self.app.cache_saved_filters.append(handler)
2557
+
2558
+ def register_shutdown_handler(self, handler):
2559
+ self.log(f"Registered shutdown handler: {handler_name(handler)}")
2560
+ self.app.shutdown_handlers.append(handler)
2561
+
2046
2562
  def add_static_files(self, ext_dir):
2047
2563
  self.log(f"Registered static files: {ext_dir}")
2048
2564
 
@@ -2063,11 +2579,38 @@ class ExtensionContext:
2063
2579
  self.dbg(f"Registered POST: {os.path.join(self.ext_prefix, path)}")
2064
2580
  self.app.server_add_post.append((os.path.join(self.ext_prefix, path), handler, kwargs))
2065
2581
 
2582
+ def add_put(self, path, handler, **kwargs):
2583
+ self.dbg(f"Registered PUT: {os.path.join(self.ext_prefix, path)}")
2584
+ self.app.server_add_put.append((os.path.join(self.ext_prefix, path), handler, kwargs))
2585
+
2586
+ def add_delete(self, path, handler, **kwargs):
2587
+ self.dbg(f"Registered DELETE: {os.path.join(self.ext_prefix, path)}")
2588
+ self.app.server_add_delete.append((os.path.join(self.ext_prefix, path), handler, kwargs))
2589
+
2590
+ def add_patch(self, path, handler, **kwargs):
2591
+ self.dbg(f"Registered PATCH: {os.path.join(self.ext_prefix, path)}")
2592
+ self.app.server_add_patch.append((os.path.join(self.ext_prefix, path), handler, kwargs))
2593
+
2594
+ def add_importmaps(self, dict):
2595
+ self.app.import_maps.update(dict)
2596
+
2597
+ def add_index_header(self, html):
2598
+ self.app.index_headers.append(html)
2599
+
2600
+ def add_index_footer(self, html):
2601
+ self.app.index_footers.append(html)
2602
+
2066
2603
  def get_config(self):
2067
2604
  return g_config
2068
2605
 
2069
- def chat_completion(self, chat):
2070
- return chat_completion(chat)
2606
+ def get_cache_path(self, path=""):
2607
+ return get_cache_path(path)
2608
+
2609
+ def chat_request(self, template=None, text=None, model=None, system_prompt=None):
2610
+ return self.app.chat_request(template=template, text=text, model=model, system_prompt=system_prompt)
2611
+
2612
+ def chat_completion(self, chat, context=None):
2613
+ return self.app.chat_completion(chat, context=context)
2071
2614
 
2072
2615
  def get_providers(self):
2073
2616
  return g_handlers
@@ -2075,21 +2618,6 @@ class ExtensionContext:
2075
2618
  def get_provider(self, name):
2076
2619
  return g_handlers.get(name)
2077
2620
 
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
2621
  def register_tool(self, func, tool_def=None):
2094
2622
  if tool_def is None:
2095
2623
  tool_def = function_to_tool_definition(func)
@@ -2099,44 +2627,83 @@ class ExtensionContext:
2099
2627
  self.app.tools[name] = func
2100
2628
  self.app.tool_definitions.append(tool_def)
2101
2629
 
2630
+ def check_auth(self, request):
2631
+ return self.app.check_auth(request)
2102
2632
 
2103
- def load_builtin_extensions():
2104
- providers_path = _ROOT / "providers"
2105
- if not providers_path.exists():
2106
- return
2633
+ def get_session(self, request):
2634
+ return self.app.get_session(request)
2107
2635
 
2108
- for item in os.listdir(providers_path):
2109
- if not item.endswith(".py") or item == "__init__.py":
2110
- continue
2636
+ def get_username(self, request):
2637
+ return self.app.get_username(request)
2111
2638
 
2112
- item_path = providers_path / item
2113
- module_name = item[:-3]
2639
+ def get_user_path(self, username=None):
2640
+ return self.app.get_user_path(username)
2114
2641
 
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)
2642
+ def should_cancel_thread(self, context):
2643
+ return should_cancel_thread(context)
2644
+
2645
+ def cache_message_inline_data(self, message):
2646
+ return cache_message_inline_data(message)
2647
+
2648
+ def to_content(self, result):
2649
+ return to_content(result)
2128
2650
 
2129
2651
 
2130
2652
  def get_extensions_path():
2131
- return os.environ.get("LLMS_EXTENSIONS_DIR", os.path.join(Path.home(), ".llms", "extensions"))
2653
+ return os.getenv("LLMS_EXTENSIONS_DIR", os.path.join(Path.home(), ".llms", "extensions"))
2132
2654
 
2133
2655
 
2134
- def init_extensions(parser):
2656
+ def get_disabled_extensions():
2657
+ ret = DISABLE_EXTENSIONS.copy()
2658
+ if g_config:
2659
+ for ext in g_config.get("disable_extensions", []):
2660
+ if ext not in ret:
2661
+ ret.append(ext)
2662
+ return ret
2663
+
2664
+
2665
+ def get_extensions_dirs():
2666
+ """
2667
+ Returns a list of extension directories.
2668
+ """
2135
2669
  extensions_path = get_extensions_path()
2136
2670
  os.makedirs(extensions_path, exist_ok=True)
2137
2671
 
2138
- for item in os.listdir(extensions_path):
2139
- item_path = os.path.join(extensions_path, item)
2672
+ # allow overriding builtin extensions
2673
+ override_extensions = []
2674
+ if os.path.exists(extensions_path):
2675
+ override_extensions = os.listdir(extensions_path)
2676
+
2677
+ ret = []
2678
+ disabled_extensions = get_disabled_extensions()
2679
+
2680
+ builtin_extensions_dir = _ROOT / "extensions"
2681
+ if os.path.exists(builtin_extensions_dir):
2682
+ for item in os.listdir(builtin_extensions_dir):
2683
+ if os.path.isdir(os.path.join(builtin_extensions_dir, item)):
2684
+ if item in override_extensions:
2685
+ continue
2686
+ if item in disabled_extensions:
2687
+ continue
2688
+ ret.append(os.path.join(builtin_extensions_dir, item))
2689
+
2690
+ if os.path.exists(extensions_path):
2691
+ for item in os.listdir(extensions_path):
2692
+ if os.path.isdir(os.path.join(extensions_path, item)):
2693
+ if item in disabled_extensions:
2694
+ continue
2695
+ ret.append(os.path.join(extensions_path, item))
2696
+
2697
+ return ret
2698
+
2699
+
2700
+ def init_extensions(parser):
2701
+ """
2702
+ Initializes extensions by loading their __init__.py files and calling the __parser__ function if it exists.
2703
+ """
2704
+ for item_path in get_extensions_dirs():
2705
+ item = os.path.basename(item_path)
2706
+
2140
2707
  if os.path.isdir(item_path):
2141
2708
  try:
2142
2709
  # check for __parser__ function if exists in __init.__.py and call it with parser
@@ -2161,25 +2728,28 @@ def install_extensions():
2161
2728
  Scans ensure ~/.llms/extensions/ for directories with __init__.py and loads them as extensions.
2162
2729
  Calls the `__install__(ctx)` function in the extension module.
2163
2730
  """
2164
- extensions_path = get_extensions_path()
2165
- os.makedirs(extensions_path, exist_ok=True)
2166
2731
 
2167
- ext_count = len(os.listdir(extensions_path))
2732
+ extension_dirs = get_extensions_dirs()
2733
+ ext_count = len(list(extension_dirs))
2168
2734
  if ext_count == 0:
2169
2735
  _log("No extensions found")
2170
2736
  return
2171
2737
 
2738
+ disabled_extensions = get_disabled_extensions()
2739
+ if len(disabled_extensions) > 0:
2740
+ _log(f"Disabled extensions: {', '.join(disabled_extensions)}")
2741
+
2172
2742
  _log(f"Installing {ext_count} extension{'' if ext_count == 1 else 's'}...")
2173
2743
 
2174
- sys.path.append(extensions_path)
2744
+ for item_path in extension_dirs:
2745
+ item = os.path.basename(item_path)
2175
2746
 
2176
- for item in os.listdir(extensions_path):
2177
- item_path = os.path.join(extensions_path, item)
2178
2747
  if os.path.isdir(item_path):
2179
- init_file = os.path.join(item_path, "__init__.py")
2180
- if os.path.exists(init_file):
2748
+ sys.path.append(item_path)
2749
+ try:
2181
2750
  ctx = ExtensionContext(g_app, item_path)
2182
- try:
2751
+ init_file = os.path.join(item_path, "__init__.py")
2752
+ if os.path.exists(init_file):
2183
2753
  spec = importlib.util.spec_from_file_location(item, init_file)
2184
2754
  if spec and spec.loader:
2185
2755
  module = importlib.util.module_from_spec(spec)
@@ -2194,20 +2764,20 @@ def install_extensions():
2194
2764
  _dbg(f"Extension {item} has no __install__ function")
2195
2765
  else:
2196
2766
  _dbg(f"Extension {item} has no __init__.py")
2767
+ else:
2768
+ _dbg(f"Extension {init_file} not found")
2197
2769
 
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)
2770
+ # if ui folder exists, serve as static files at /ext/{item}/
2771
+ ui_path = os.path.join(item_path, "ui")
2772
+ if os.path.exists(ui_path):
2773
+ ctx.add_static_files(ui_path)
2202
2774
 
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")
2775
+ # Register UI extension if index.mjs exists (/ext/{item}/index.mjs)
2776
+ if os.path.exists(os.path.join(ui_path, "index.mjs")):
2777
+ ctx.register_ui_extension("index.mjs")
2206
2778
 
2207
- except Exception as e:
2208
- _err(f"Failed to install extension {item}", e)
2209
- else:
2210
- _dbg(f"Extension {init_file} not found")
2779
+ except Exception as e:
2780
+ _err(f"Failed to install extension {item}", e)
2211
2781
  else:
2212
2782
  _dbg(f"Extension {item} not found: {item_path} is not a directory {os.path.exists(item_path)}")
2213
2783
 
@@ -2216,11 +2786,9 @@ def run_extension_cli():
2216
2786
  """
2217
2787
  Run the CLI for an extension.
2218
2788
  """
2219
- extensions_path = get_extensions_path()
2220
- os.makedirs(extensions_path, exist_ok=True)
2789
+ for item_path in get_extensions_dirs():
2790
+ item = os.path.basename(item_path)
2221
2791
 
2222
- for item in os.listdir(extensions_path):
2223
- item_path = os.path.join(extensions_path, item)
2224
2792
  if os.path.isdir(item_path):
2225
2793
  init_file = os.path.join(item_path, "__init__.py")
2226
2794
  if os.path.exists(init_file):
@@ -2235,8 +2803,8 @@ def run_extension_cli():
2235
2803
  # Check for __run__ function if exists in __init__.py and call it with ctx
2236
2804
  run_func = getattr(module, "__run__", None)
2237
2805
  if callable(run_func):
2806
+ _log(f"Running extension {item}...")
2238
2807
  handled = run_func(ctx)
2239
- _log(f"Extension {item} was run")
2240
2808
  return handled
2241
2809
 
2242
2810
  except Exception as e:
@@ -2247,6 +2815,11 @@ def run_extension_cli():
2247
2815
  def main():
2248
2816
  global _ROOT, g_verbose, g_default_model, g_logprefix, g_providers, g_config, g_config_path, g_app
2249
2817
 
2818
+ _ROOT = os.getenv("LLMS_ROOT", resolve_root())
2819
+ if not _ROOT:
2820
+ print("Resource root not found")
2821
+ exit(1)
2822
+
2250
2823
  parser = argparse.ArgumentParser(description=f"llms v{VERSION}")
2251
2824
  parser.add_argument("--config", default=None, help="Path to config file", metavar="FILE")
2252
2825
  parser.add_argument("--providers", default=None, help="Path to models.dev providers file", metavar="FILE")
@@ -2256,6 +2829,9 @@ def main():
2256
2829
  parser.add_argument(
2257
2830
  "-s", "--system", default=None, help="System prompt to use for chat completion", metavar="PROMPT"
2258
2831
  )
2832
+ parser.add_argument(
2833
+ "--tools", default=None, help="Tools to use for chat completion (all|none|<tool>,<tool>...)", metavar="TOOLS"
2834
+ )
2259
2835
  parser.add_argument("--image", default=None, help="Image input to use in chat completion")
2260
2836
  parser.add_argument("--audio", default=None, help="Audio input to use in chat completion")
2261
2837
  parser.add_argument("--file", default=None, help="File input to use in chat completion")
@@ -2283,9 +2859,7 @@ def main():
2283
2859
 
2284
2860
  parser.add_argument("--init", action="store_true", help="Create a default llms.json")
2285
2861
  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
2862
 
2288
- parser.add_argument("--root", default=None, help="Change root directory for UI files", metavar="PATH")
2289
2863
  parser.add_argument("--logprefix", default="", help="Prefix used in log messages", metavar="PREFIX")
2290
2864
  parser.add_argument("--verbose", action="store_true", help="Verbose output")
2291
2865
 
@@ -2323,7 +2897,7 @@ def main():
2323
2897
  g_app = AppExtensions(cli_args, extra_args)
2324
2898
 
2325
2899
  # Check for verbose mode from CLI argument or environment variables
2326
- verbose_env = os.environ.get("VERBOSE", "").lower()
2900
+ verbose_env = os.getenv("VERBOSE", "").lower()
2327
2901
  if cli_args.verbose or verbose_env in ("1", "true"):
2328
2902
  g_verbose = True
2329
2903
  # printdump(cli_args)
@@ -2332,11 +2906,6 @@ def main():
2332
2906
  if cli_args.logprefix:
2333
2907
  g_logprefix = cli_args.logprefix
2334
2908
 
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
2909
  home_config_path = home_llms_path("llms.json")
2341
2910
  home_providers_path = home_llms_path("providers.json")
2342
2911
  home_providers_extra_path = home_llms_path("providers-extra.json")
@@ -2385,6 +2954,8 @@ def main():
2385
2954
  g_config_path = home_config_path
2386
2955
  g_config = load_config_json(text_from_file(g_config_path))
2387
2956
 
2957
+ g_app.set_config(g_config)
2958
+
2388
2959
  if not g_providers:
2389
2960
  g_providers = json.loads(text_from_file(home_providers_path))
2390
2961
 
@@ -2397,7 +2968,7 @@ def main():
2397
2968
  if (
2398
2969
  os.path.exists(home_providers_path)
2399
2970
  and (time.time() - os.path.getmtime(home_providers_path)) > 86400
2400
- and os.environ.get("LLMS_DISABLE_UPDATE", "") != "1"
2971
+ and os.getenv("LLMS_DISABLE_UPDATE", "") != "1"
2401
2972
  ):
2402
2973
  try:
2403
2974
  asyncio.run(update_providers(home_providers_path))
@@ -2554,12 +3125,10 @@ def main():
2554
3125
  asyncio.run(update_extensions(cli_args.update))
2555
3126
  exit(0)
2556
3127
 
2557
- load_builtin_extensions()
3128
+ install_extensions()
2558
3129
 
2559
3130
  asyncio.run(reload_providers())
2560
3131
 
2561
- install_extensions()
2562
-
2563
3132
  # print names
2564
3133
  _log(f"enabled providers: {', '.join(g_handlers.keys())}")
2565
3134
 
@@ -2605,14 +3174,14 @@ def main():
2605
3174
  print(f"\n{model_count} models available from {provider_count} providers")
2606
3175
 
2607
3176
  print_status()
2608
- exit(0)
3177
+ g_app.exit(0)
2609
3178
 
2610
3179
  if cli_args.check is not None:
2611
3180
  # Check validity of models for a provider
2612
3181
  provider_name = cli_args.check
2613
3182
  model_names = extra_args if len(extra_args) > 0 else None
2614
3183
  asyncio.run(check_models(provider_name, model_names))
2615
- exit(0)
3184
+ g_app.exit(0)
2616
3185
 
2617
3186
  if cli_args.serve is not None:
2618
3187
  # Disable inactive providers and save to config before starting server
@@ -2645,8 +3214,8 @@ def main():
2645
3214
  if client_secret.startswith("$"):
2646
3215
  client_secret = client_secret[1:]
2647
3216
 
2648
- client_id = os.environ.get(client_id, client_id)
2649
- client_secret = os.environ.get(client_secret, client_secret)
3217
+ client_id = os.getenv(client_id, client_id)
3218
+ client_secret = os.getenv(client_secret, client_secret)
2650
3219
 
2651
3220
  if (
2652
3221
  not client_id
@@ -2667,59 +3236,22 @@ def main():
2667
3236
  _log(f"client_max_size set to {client_max_size} bytes ({client_max_size / 1024 / 1024:.1f}MB)")
2668
3237
  app = web.Application(client_max_size=client_max_size)
2669
3238
 
2670
- # Authentication middleware helper
2671
- def check_auth(request):
2672
- """Check if request is authenticated. Returns (is_authenticated, user_data)"""
2673
- if not auth_enabled:
2674
- return True, None
2675
-
2676
- # Check for OAuth session token
2677
- session_token = get_session_token(request)
2678
- if session_token and session_token in g_sessions:
2679
- return True, g_sessions[session_token]
2680
-
2681
- # Check for API key
2682
- auth_header = request.headers.get("Authorization", "")
2683
- if auth_header.startswith("Bearer "):
2684
- api_key = auth_header[7:]
2685
- if api_key:
2686
- return True, {"authProvider": "apikey"}
2687
-
2688
- return False, None
2689
-
2690
3239
  async def chat_handler(request):
2691
3240
  # Check authentication if enabled
2692
- is_authenticated, user_data = check_auth(request)
3241
+ is_authenticated, user_data = g_app.check_auth(request)
2693
3242
  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
- )
3243
+ return web.json_response(g_app.error_auth_required, status=401)
2704
3244
 
2705
3245
  try:
2706
3246
  chat = await request.json()
2707
-
2708
- # Apply pre-chat filters
2709
- 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
-
3247
+ context = {"chat": chat, "request": request, "user": g_app.get_username(request)}
3248
+ metadata = chat.get("metadata", {})
3249
+ context["threadId"] = metadata.get("threadId", None)
3250
+ context["tools"] = metadata.get("tools", "all")
3251
+ response = await g_app.chat_completion(chat, context)
2720
3252
  return web.json_response(response)
2721
3253
  except Exception as e:
2722
- return web.json_response({"error": str(e)}, status=500)
3254
+ return web.json_response(to_error_response(e), status=500)
2723
3255
 
2724
3256
  app.router.add_post("/v1/chat/completions", chat_handler)
2725
3257
 
@@ -2771,18 +3303,9 @@ def main():
2771
3303
 
2772
3304
  async def upload_handler(request):
2773
3305
  # Check authentication if enabled
2774
- is_authenticated, user_data = check_auth(request)
3306
+ is_authenticated, user_data = g_app.check_auth(request)
2775
3307
  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
- )
3308
+ return web.json_response(g_app.error_auth_required, status=401)
2786
3309
 
2787
3310
  reader = await request.multipart()
2788
3311
 
@@ -2792,7 +3315,7 @@ def main():
2792
3315
  field = await reader.next()
2793
3316
 
2794
3317
  if not field:
2795
- return web.json_response({"error": "No file provided"}, status=400)
3318
+ return web.json_response(create_error_response("No file provided"), status=400)
2796
3319
 
2797
3320
  filename = field.filename or "file"
2798
3321
  content = await field.read()
@@ -2830,9 +3353,10 @@ def main():
2830
3353
  with open(full_path, "wb") as f:
2831
3354
  f.write(content)
2832
3355
 
3356
+ url = f"/~cache/{relative_path}"
2833
3357
  response_data = {
2834
3358
  "date": int(time.time()),
2835
- "url": f"/~cache/{relative_path}",
3359
+ "url": url,
2836
3360
  "size": len(content),
2837
3361
  "type": mimetype,
2838
3362
  "name": filename,
@@ -2852,6 +3376,8 @@ def main():
2852
3376
  with open(info_path, "w") as f:
2853
3377
  json.dump(response_data, f)
2854
3378
 
3379
+ g_app.on_cache_saved_filters({"url": url, "info": response_data})
3380
+
2855
3381
  return web.json_response(response_data)
2856
3382
 
2857
3383
  app.router.add_post("/upload", upload_handler)
@@ -2877,7 +3403,7 @@ def main():
2877
3403
 
2878
3404
  # Check for directory traversal for info path
2879
3405
  try:
2880
- cache_root = Path(get_cache_path(""))
3406
+ cache_root = Path(get_cache_path())
2881
3407
  requested_path = Path(info_path).resolve()
2882
3408
  if not str(requested_path).startswith(str(cache_root)):
2883
3409
  return web.Response(text="403: Forbidden", status=403)
@@ -2893,7 +3419,7 @@ def main():
2893
3419
 
2894
3420
  # Check for directory traversal
2895
3421
  try:
2896
- cache_root = Path(get_cache_path(""))
3422
+ cache_root = Path(get_cache_path())
2897
3423
  requested_path = Path(full_path).resolve()
2898
3424
  if not str(requested_path).startswith(str(cache_root)):
2899
3425
  return web.Response(text="403: Forbidden", status=403)
@@ -2912,7 +3438,7 @@ def main():
2912
3438
  async def github_auth_handler(request):
2913
3439
  """Initiate GitHub OAuth flow"""
2914
3440
  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)
3441
+ return web.json_response(create_error_response("GitHub OAuth not configured"), status=500)
2916
3442
 
2917
3443
  auth_config = g_config["auth"]["github"]
2918
3444
  client_id = auth_config.get("client_id", "")
@@ -2924,11 +3450,11 @@ def main():
2924
3450
  if redirect_uri.startswith("$"):
2925
3451
  redirect_uri = redirect_uri[1:]
2926
3452
 
2927
- client_id = os.environ.get(client_id, client_id)
2928
- redirect_uri = os.environ.get(redirect_uri, redirect_uri)
3453
+ client_id = os.getenv(client_id, client_id)
3454
+ redirect_uri = os.getenv(redirect_uri, redirect_uri)
2929
3455
 
2930
3456
  if not client_id:
2931
- return web.json_response({"error": "GitHub client_id not configured"}, status=500)
3457
+ return web.json_response(create_error_response("GitHub client_id not configured"), status=500)
2932
3458
 
2933
3459
  # Generate CSRF state token
2934
3460
  state = secrets.token_urlsafe(32)
@@ -2960,7 +3486,7 @@ def main():
2960
3486
  if restrict_to.startswith("$"):
2961
3487
  restrict_to = restrict_to[1:]
2962
3488
 
2963
- restrict_to = os.environ.get(restrict_to, None if restrict_to == "GITHUB_USERS" else restrict_to)
3489
+ restrict_to = os.getenv(restrict_to, None if restrict_to == "GITHUB_USERS" else restrict_to)
2964
3490
 
2965
3491
  # If restrict_to is configured, validate the user
2966
3492
  if restrict_to:
@@ -2999,7 +3525,7 @@ def main():
2999
3525
  g_oauth_states.pop(state)
3000
3526
 
3001
3527
  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)
3528
+ return web.json_response(create_error_response("GitHub OAuth not configured"), status=500)
3003
3529
 
3004
3530
  auth_config = g_config["auth"]["github"]
3005
3531
  client_id = auth_config.get("client_id", "")
@@ -3014,12 +3540,12 @@ def main():
3014
3540
  if redirect_uri.startswith("$"):
3015
3541
  redirect_uri = redirect_uri[1:]
3016
3542
 
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)
3543
+ client_id = os.getenv(client_id, client_id)
3544
+ client_secret = os.getenv(client_secret, client_secret)
3545
+ redirect_uri = os.getenv(redirect_uri, redirect_uri)
3020
3546
 
3021
3547
  if not client_id or not client_secret:
3022
- return web.json_response({"error": "GitHub OAuth credentials not configured"}, status=500)
3548
+ return web.json_response(create_error_response("GitHub OAuth credentials not configured"), status=500)
3023
3549
 
3024
3550
  # Exchange code for access token
3025
3551
  async with aiohttp.ClientSession() as session:
@@ -3038,7 +3564,7 @@ def main():
3038
3564
 
3039
3565
  if not access_token:
3040
3566
  error = token_response.get("error_description", "Failed to get access token")
3041
- return web.Response(text=f"OAuth error: {error}", status=400)
3567
+ return web.json_response(create_error_response(f"OAuth error: {error}"), status=400)
3042
3568
 
3043
3569
  # Fetch user info
3044
3570
  user_url = "https://api.github.com/user"
@@ -3073,7 +3599,7 @@ def main():
3073
3599
  session_token = get_session_token(request)
3074
3600
 
3075
3601
  if not session_token or session_token not in g_sessions:
3076
- return web.json_response({"error": "Invalid or expired session"}, status=401)
3602
+ return web.json_response(create_error_response("Invalid or expired session"), status=401)
3077
3603
 
3078
3604
  session_data = g_sessions[session_token]
3079
3605
 
@@ -3129,9 +3655,7 @@ def main():
3129
3655
  # })
3130
3656
 
3131
3657
  # Not authenticated - return error in expected format
3132
- return web.json_response(
3133
- {"responseStatus": {"errorCode": "Unauthorized", "message": "Not authenticated"}}, status=401
3134
- )
3658
+ return web.json_response(g_app.error_auth_required, status=401)
3135
3659
 
3136
3660
  app.router.add_get("/auth", auth_handler)
3137
3661
  app.router.add_get("/auth/github", github_auth_handler)
@@ -3191,15 +3715,81 @@ def main():
3191
3715
 
3192
3716
  # go through and register all g_app extensions
3193
3717
  for handler in g_app.server_add_get:
3194
- app.router.add_get(handler[0], handler[1], **handler[2])
3718
+ handler_fn = handler[1]
3719
+
3720
+ async def managed_handler(request, handler_fn=handler_fn):
3721
+ try:
3722
+ return await handler_fn(request)
3723
+ except Exception as e:
3724
+ return web.json_response(to_error_response(e, stacktrace=g_verbose), status=500)
3725
+
3726
+ app.router.add_get(handler[0], managed_handler, **handler[2])
3195
3727
  for handler in g_app.server_add_post:
3196
- app.router.add_post(handler[0], handler[1], **handler[2])
3728
+ handler_fn = handler[1]
3729
+
3730
+ async def managed_handler(request, handler_fn=handler_fn):
3731
+ try:
3732
+ return await handler_fn(request)
3733
+ except Exception as e:
3734
+ return web.json_response(to_error_response(e, stacktrace=g_verbose), status=500)
3735
+
3736
+ app.router.add_post(handler[0], managed_handler, **handler[2])
3737
+ for handler in g_app.server_add_put:
3738
+ handler_fn = handler[1]
3739
+
3740
+ async def managed_handler(request, handler_fn=handler_fn):
3741
+ try:
3742
+ return await handler_fn(request)
3743
+ except Exception as e:
3744
+ return web.json_response(to_error_response(e, stacktrace=g_verbose), status=500)
3745
+
3746
+ app.router.add_put(handler[0], managed_handler, **handler[2])
3747
+ for handler in g_app.server_add_delete:
3748
+ handler_fn = handler[1]
3749
+
3750
+ async def managed_handler(request, handler_fn=handler_fn):
3751
+ try:
3752
+ return await handler_fn(request)
3753
+ except Exception as e:
3754
+ return web.json_response(to_error_response(e, stacktrace=g_verbose), status=500)
3755
+
3756
+ app.router.add_delete(handler[0], managed_handler, **handler[2])
3757
+ for handler in g_app.server_add_patch:
3758
+ handler_fn = handler[1]
3759
+
3760
+ async def managed_handler(request, handler_fn=handler_fn):
3761
+ try:
3762
+ return await handler_fn(request)
3763
+ except Exception as e:
3764
+ return web.json_response(to_error_response(e, stacktrace=g_verbose), status=500)
3765
+
3766
+ app.router.add_patch(handler[0], managed_handler, **handler[2])
3197
3767
 
3198
3768
  # Serve index.html from root
3199
3769
  async def index_handler(request):
3200
3770
  index_content = read_resource_file_bytes("index.html")
3201
- if index_content is None:
3202
- raise web.HTTPNotFound
3771
+
3772
+ importmaps = {"imports": g_app.import_maps}
3773
+ importmaps_script = '<script type="importmap">\n' + json.dumps(importmaps, indent=4) + "\n</script>"
3774
+ index_content = index_content.replace(
3775
+ b'<script type="importmap"></script>',
3776
+ importmaps_script.encode("utf-8"),
3777
+ )
3778
+
3779
+ if len(g_app.index_headers) > 0:
3780
+ html_header = ""
3781
+ for header in g_app.index_headers:
3782
+ html_header += header
3783
+ # replace </head> with html_header
3784
+ index_content = index_content.replace(b"</head>", html_header.encode("utf-8") + b"\n</head>")
3785
+
3786
+ if len(g_app.index_footers) > 0:
3787
+ html_footer = ""
3788
+ for footer in g_app.index_footers:
3789
+ html_footer += footer
3790
+ # replace </body> with html_footer
3791
+ index_content = index_content.replace(b"</body>", html_footer.encode("utf-8") + b"\n</body>")
3792
+
3203
3793
  return web.Response(body=index_content, content_type="text/html")
3204
3794
 
3205
3795
  app.router.add_get("/", index_handler)
@@ -3219,7 +3809,7 @@ def main():
3219
3809
 
3220
3810
  print(f"Starting server on port {port}...")
3221
3811
  web.run_app(app, host="0.0.0.0", port=port, print=_log)
3222
- exit(0)
3812
+ g_app.exit(0)
3223
3813
 
3224
3814
  if cli_args.enable is not None:
3225
3815
  if cli_args.enable.endswith(","):
@@ -3236,7 +3826,7 @@ def main():
3236
3826
 
3237
3827
  for provider in enable_providers:
3238
3828
  if provider not in g_config["providers"]:
3239
- print(f"Provider {provider} not found")
3829
+ print(f"Provider '{provider}' not found")
3240
3830
  print(f"Available providers: {', '.join(g_config['providers'].keys())}")
3241
3831
  exit(1)
3242
3832
  if provider in g_config["providers"]:
@@ -3249,7 +3839,7 @@ def main():
3249
3839
  print_status()
3250
3840
  if len(msgs) > 0:
3251
3841
  print("\n" + "\n".join(msgs))
3252
- exit(0)
3842
+ g_app.exit(0)
3253
3843
 
3254
3844
  if cli_args.disable is not None:
3255
3845
  if cli_args.disable.endswith(","):
@@ -3272,7 +3862,7 @@ def main():
3272
3862
  print(f"\nDisabled provider {provider}")
3273
3863
 
3274
3864
  print_status()
3275
- exit(0)
3865
+ g_app.exit(0)
3276
3866
 
3277
3867
  if cli_args.default is not None:
3278
3868
  default_model = cli_args.default
@@ -3284,7 +3874,7 @@ def main():
3284
3874
  default_text["model"] = default_model
3285
3875
  save_config(g_config)
3286
3876
  print(f"\nDefault model set to: {default_model}")
3287
- exit(0)
3877
+ g_app.exit(0)
3288
3878
 
3289
3879
  if (
3290
3880
  cli_args.chat is not None
@@ -3324,6 +3914,9 @@ def main():
3324
3914
 
3325
3915
  if len(extra_args) > 0:
3326
3916
  prompt = " ".join(extra_args)
3917
+ if not chat["messages"] or len(chat["messages"]) == 0:
3918
+ chat["messages"] = [{"role": "user", "content": [{"type": "text", "text": ""}]}]
3919
+
3327
3920
  # replace content of last message if exists, else add
3328
3921
  last_msg = chat["messages"][-1] if "messages" in chat else None
3329
3922
  if last_msg and last_msg["role"] == "user":
@@ -3341,21 +3934,28 @@ def main():
3341
3934
 
3342
3935
  asyncio.run(
3343
3936
  cli_chat(
3344
- chat, image=cli_args.image, audio=cli_args.audio, file=cli_args.file, args=args, raw=cli_args.raw
3937
+ chat,
3938
+ tools=cli_args.tools,
3939
+ image=cli_args.image,
3940
+ audio=cli_args.audio,
3941
+ file=cli_args.file,
3942
+ args=args,
3943
+ raw=cli_args.raw,
3345
3944
  )
3346
3945
  )
3347
- exit(0)
3946
+ g_app.exit(0)
3348
3947
  except Exception as e:
3349
3948
  print(f"{cli_args.logprefix}Error: {e}")
3350
3949
  if cli_args.verbose:
3351
3950
  traceback.print_exc()
3352
- exit(1)
3951
+ g_app.exit(1)
3353
3952
 
3354
3953
  handled = run_extension_cli()
3355
3954
 
3356
3955
  if not handled:
3357
3956
  # show usage from ArgumentParser
3358
3957
  parser.print_help()
3958
+ g_app.exit(0)
3359
3959
 
3360
3960
 
3361
3961
  if __name__ == "__main__":