llms-py 3.0.0__py3-none-any.whl → 3.0.0b1__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 (206) hide show
  1. llms/index.html +77 -35
  2. llms/llms.json +23 -72
  3. llms/main.py +732 -1786
  4. llms/providers.json +1 -1
  5. llms/{extensions/analytics/ui/index.mjs → ui/Analytics.mjs} +238 -154
  6. llms/ui/App.mjs +60 -151
  7. llms/ui/Avatar.mjs +85 -0
  8. llms/ui/Brand.mjs +52 -0
  9. llms/ui/ChatPrompt.mjs +606 -0
  10. llms/ui/Main.mjs +873 -0
  11. llms/ui/ModelSelector.mjs +693 -0
  12. llms/ui/OAuthSignIn.mjs +92 -0
  13. llms/ui/ProviderIcon.mjs +36 -0
  14. llms/ui/ProviderStatus.mjs +105 -0
  15. llms/{extensions/app/ui → ui}/Recents.mjs +65 -91
  16. llms/ui/{modules/chat/SettingsDialog.mjs → SettingsDialog.mjs} +9 -9
  17. llms/{extensions/app/ui/index.mjs → ui/Sidebar.mjs} +58 -124
  18. llms/ui/SignIn.mjs +64 -0
  19. llms/ui/SystemPromptEditor.mjs +31 -0
  20. llms/ui/SystemPromptSelector.mjs +56 -0
  21. llms/ui/Welcome.mjs +8 -0
  22. llms/ui/ai.mjs +53 -125
  23. llms/ui/app.css +111 -1837
  24. llms/ui/lib/charts.mjs +13 -9
  25. llms/ui/lib/servicestack-vue.mjs +3 -3
  26. llms/ui/lib/vue.min.mjs +9 -10
  27. llms/ui/lib/vue.mjs +1602 -1763
  28. llms/ui/markdown.mjs +2 -10
  29. llms/ui/tailwind.input.css +80 -496
  30. llms/ui/threadStore.mjs +572 -0
  31. llms/ui/utils.mjs +117 -113
  32. llms/ui.json +1069 -0
  33. {llms_py-3.0.0.dist-info → llms_py-3.0.0b1.dist-info}/METADATA +1 -1
  34. llms_py-3.0.0b1.dist-info/RECORD +49 -0
  35. llms/__pycache__/__init__.cpython-312.pyc +0 -0
  36. llms/__pycache__/__init__.cpython-313.pyc +0 -0
  37. llms/__pycache__/__init__.cpython-314.pyc +0 -0
  38. llms/__pycache__/__main__.cpython-312.pyc +0 -0
  39. llms/__pycache__/__main__.cpython-314.pyc +0 -0
  40. llms/__pycache__/llms.cpython-312.pyc +0 -0
  41. llms/__pycache__/main.cpython-312.pyc +0 -0
  42. llms/__pycache__/main.cpython-313.pyc +0 -0
  43. llms/__pycache__/main.cpython-314.pyc +0 -0
  44. llms/__pycache__/plugins.cpython-314.pyc +0 -0
  45. llms/extensions/app/README.md +0 -20
  46. llms/extensions/app/__init__.py +0 -530
  47. llms/extensions/app/__pycache__/__init__.cpython-314.pyc +0 -0
  48. llms/extensions/app/__pycache__/db.cpython-314.pyc +0 -0
  49. llms/extensions/app/__pycache__/db_manager.cpython-314.pyc +0 -0
  50. llms/extensions/app/db.py +0 -644
  51. llms/extensions/app/db_manager.py +0 -195
  52. llms/extensions/app/requests.json +0 -9073
  53. llms/extensions/app/threads.json +0 -15290
  54. llms/extensions/app/ui/threadStore.mjs +0 -411
  55. llms/extensions/core_tools/CALCULATOR.md +0 -32
  56. llms/extensions/core_tools/__init__.py +0 -598
  57. llms/extensions/core_tools/__pycache__/__init__.cpython-314.pyc +0 -0
  58. llms/extensions/core_tools/ui/codemirror/addon/edit/closebrackets.js +0 -201
  59. llms/extensions/core_tools/ui/codemirror/addon/edit/closetag.js +0 -185
  60. llms/extensions/core_tools/ui/codemirror/addon/edit/continuelist.js +0 -101
  61. llms/extensions/core_tools/ui/codemirror/addon/edit/matchbrackets.js +0 -160
  62. llms/extensions/core_tools/ui/codemirror/addon/edit/matchtags.js +0 -66
  63. llms/extensions/core_tools/ui/codemirror/addon/edit/trailingspace.js +0 -27
  64. llms/extensions/core_tools/ui/codemirror/addon/selection/active-line.js +0 -72
  65. llms/extensions/core_tools/ui/codemirror/addon/selection/mark-selection.js +0 -119
  66. llms/extensions/core_tools/ui/codemirror/addon/selection/selection-pointer.js +0 -98
  67. llms/extensions/core_tools/ui/codemirror/doc/docs.css +0 -225
  68. llms/extensions/core_tools/ui/codemirror/doc/source_sans.woff +0 -0
  69. llms/extensions/core_tools/ui/codemirror/lib/codemirror.css +0 -344
  70. llms/extensions/core_tools/ui/codemirror/lib/codemirror.js +0 -9884
  71. llms/extensions/core_tools/ui/codemirror/mode/clike/clike.js +0 -942
  72. llms/extensions/core_tools/ui/codemirror/mode/javascript/index.html +0 -118
  73. llms/extensions/core_tools/ui/codemirror/mode/javascript/javascript.js +0 -962
  74. llms/extensions/core_tools/ui/codemirror/mode/javascript/typescript.html +0 -62
  75. llms/extensions/core_tools/ui/codemirror/mode/python/python.js +0 -402
  76. llms/extensions/core_tools/ui/codemirror/theme/dracula.css +0 -40
  77. llms/extensions/core_tools/ui/codemirror/theme/mocha.css +0 -135
  78. llms/extensions/core_tools/ui/index.mjs +0 -650
  79. llms/extensions/gallery/README.md +0 -61
  80. llms/extensions/gallery/__init__.py +0 -61
  81. llms/extensions/gallery/__pycache__/__init__.cpython-314.pyc +0 -0
  82. llms/extensions/gallery/__pycache__/db.cpython-314.pyc +0 -0
  83. llms/extensions/gallery/db.py +0 -298
  84. llms/extensions/gallery/ui/index.mjs +0 -482
  85. llms/extensions/katex/README.md +0 -39
  86. llms/extensions/katex/__init__.py +0 -6
  87. llms/extensions/katex/__pycache__/__init__.cpython-314.pyc +0 -0
  88. llms/extensions/katex/ui/README.md +0 -125
  89. llms/extensions/katex/ui/contrib/auto-render.js +0 -338
  90. llms/extensions/katex/ui/contrib/auto-render.min.js +0 -1
  91. llms/extensions/katex/ui/contrib/auto-render.mjs +0 -244
  92. llms/extensions/katex/ui/contrib/copy-tex.js +0 -127
  93. llms/extensions/katex/ui/contrib/copy-tex.min.js +0 -1
  94. llms/extensions/katex/ui/contrib/copy-tex.mjs +0 -105
  95. llms/extensions/katex/ui/contrib/mathtex-script-type.js +0 -109
  96. llms/extensions/katex/ui/contrib/mathtex-script-type.min.js +0 -1
  97. llms/extensions/katex/ui/contrib/mathtex-script-type.mjs +0 -24
  98. llms/extensions/katex/ui/contrib/mhchem.js +0 -3213
  99. llms/extensions/katex/ui/contrib/mhchem.min.js +0 -1
  100. llms/extensions/katex/ui/contrib/mhchem.mjs +0 -3109
  101. llms/extensions/katex/ui/contrib/render-a11y-string.js +0 -887
  102. llms/extensions/katex/ui/contrib/render-a11y-string.min.js +0 -1
  103. llms/extensions/katex/ui/contrib/render-a11y-string.mjs +0 -800
  104. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.ttf +0 -0
  105. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.woff +0 -0
  106. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  107. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  108. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  109. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  110. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  111. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  112. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  113. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  114. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  115. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  116. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  117. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  118. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  119. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.ttf +0 -0
  120. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.woff +0 -0
  121. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.woff2 +0 -0
  122. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  123. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  124. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  125. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.ttf +0 -0
  126. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.woff +0 -0
  127. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.woff2 +0 -0
  128. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.ttf +0 -0
  129. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.woff +0 -0
  130. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.woff2 +0 -0
  131. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  132. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  133. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  134. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.ttf +0 -0
  135. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.woff +0 -0
  136. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.woff2 +0 -0
  137. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  138. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  139. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  140. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  141. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  142. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  143. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  144. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  145. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  146. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.ttf +0 -0
  147. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.woff +0 -0
  148. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.woff2 +0 -0
  149. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.ttf +0 -0
  150. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.woff +0 -0
  151. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  152. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.ttf +0 -0
  153. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.woff +0 -0
  154. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  155. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.ttf +0 -0
  156. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.woff +0 -0
  157. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  158. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.ttf +0 -0
  159. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.woff +0 -0
  160. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  161. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  162. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  163. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  164. llms/extensions/katex/ui/index.mjs +0 -92
  165. llms/extensions/katex/ui/katex-swap.css +0 -1230
  166. llms/extensions/katex/ui/katex-swap.min.css +0 -1
  167. llms/extensions/katex/ui/katex.css +0 -1230
  168. llms/extensions/katex/ui/katex.js +0 -19080
  169. llms/extensions/katex/ui/katex.min.css +0 -1
  170. llms/extensions/katex/ui/katex.min.js +0 -1
  171. llms/extensions/katex/ui/katex.min.mjs +0 -1
  172. llms/extensions/katex/ui/katex.mjs +0 -18547
  173. llms/extensions/providers/__init__.py +0 -18
  174. llms/extensions/providers/__pycache__/__init__.cpython-314.pyc +0 -0
  175. llms/extensions/providers/__pycache__/anthropic.cpython-314.pyc +0 -0
  176. llms/extensions/providers/__pycache__/chutes.cpython-314.pyc +0 -0
  177. llms/extensions/providers/__pycache__/google.cpython-314.pyc +0 -0
  178. llms/extensions/providers/__pycache__/nvidia.cpython-314.pyc +0 -0
  179. llms/extensions/providers/__pycache__/openai.cpython-314.pyc +0 -0
  180. llms/extensions/providers/__pycache__/openrouter.cpython-314.pyc +0 -0
  181. llms/extensions/providers/anthropic.py +0 -229
  182. llms/extensions/providers/chutes.py +0 -155
  183. llms/extensions/providers/google.py +0 -378
  184. llms/extensions/providers/nvidia.py +0 -105
  185. llms/extensions/providers/openai.py +0 -156
  186. llms/extensions/providers/openrouter.py +0 -72
  187. llms/extensions/system_prompts/README.md +0 -22
  188. llms/extensions/system_prompts/__init__.py +0 -45
  189. llms/extensions/system_prompts/__pycache__/__init__.cpython-314.pyc +0 -0
  190. llms/extensions/system_prompts/ui/index.mjs +0 -280
  191. llms/extensions/system_prompts/ui/prompts.json +0 -1067
  192. llms/extensions/tools/__init__.py +0 -5
  193. llms/extensions/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  194. llms/extensions/tools/ui/index.mjs +0 -204
  195. llms/providers-extra.json +0 -356
  196. llms/ui/ctx.mjs +0 -365
  197. llms/ui/index.mjs +0 -129
  198. llms/ui/modules/chat/ChatBody.mjs +0 -691
  199. llms/ui/modules/chat/index.mjs +0 -828
  200. llms/ui/modules/layout.mjs +0 -243
  201. llms/ui/modules/model-selector.mjs +0 -851
  202. llms_py-3.0.0.dist-info/RECORD +0 -202
  203. {llms_py-3.0.0.dist-info → llms_py-3.0.0b1.dist-info}/WHEEL +0 -0
  204. {llms_py-3.0.0.dist-info → llms_py-3.0.0b1.dist-info}/entry_points.txt +0 -0
  205. {llms_py-3.0.0.dist-info → llms_py-3.0.0b1.dist-info}/licenses/LICENSE +0 -0
  206. {llms_py-3.0.0.dist-info → llms_py-3.0.0b1.dist-info}/top_level.txt +0 -0
llms/main.py CHANGED
@@ -9,27 +9,22 @@
9
9
  import argparse
10
10
  import asyncio
11
11
  import base64
12
- import contextlib
12
+ from datetime import datetime
13
13
  import hashlib
14
- import importlib.util
15
- import inspect
16
14
  import json
17
15
  import mimetypes
18
16
  import os
19
17
  import re
20
18
  import secrets
21
- import shutil
22
19
  import site
23
20
  import subprocess
24
21
  import sys
25
22
  import time
26
23
  import traceback
27
- from datetime import datetime
28
24
  from importlib import resources # Py≥3.9 (pip install importlib_resources for 3.7/3.8)
29
25
  from io import BytesIO
30
26
  from pathlib import Path
31
- from typing import get_type_hints
32
- from urllib.parse import parse_qs, urlencode, urljoin
27
+ from urllib.parse import parse_qs, urlencode
33
28
 
34
29
  import aiohttp
35
30
  from aiohttp import web
@@ -41,13 +36,10 @@ try:
41
36
  except ImportError:
42
37
  HAS_PIL = False
43
38
 
44
- VERSION = "3.0.0"
39
+ VERSION = "3.0.0b1"
45
40
  _ROOT = None
46
- DEBUG = os.getenv("DEBUG") == "1"
47
- MOCK = os.getenv("MOCK") == "1"
48
- MOCK_DIR = os.getenv("MOCK_DIR")
49
- DISABLE_EXTENSIONS = (os.getenv("LLMS_DISABLE") or "").split(",")
50
41
  g_config_path = None
42
+ g_ui_path = None
51
43
  g_config = None
52
44
  g_providers = None
53
45
  g_handlers = {}
@@ -56,25 +48,14 @@ g_logprefix = ""
56
48
  g_default_model = ""
57
49
  g_sessions = {} # OAuth session storage: {session_token: {userId, userName, displayName, profileUrl, email, created}}
58
50
  g_oauth_states = {} # CSRF protection: {state: {created, redirect_uri}}
59
- g_app = None # ExtensionsContext Singleton
60
51
 
61
52
 
62
53
  def _log(message):
54
+ """Helper method for logging from the global polling task."""
63
55
  if g_verbose:
64
56
  print(f"{g_logprefix}{message}", flush=True)
65
57
 
66
58
 
67
- def _dbg(message):
68
- if DEBUG:
69
- print(f"DEBUG: {message}", flush=True)
70
-
71
-
72
- def _err(message, e):
73
- print(f"ERROR: {message}: {e}", flush=True)
74
- if g_verbose:
75
- print(traceback.format_exc(), flush=True)
76
-
77
-
78
59
  def printdump(obj):
79
60
  args = obj.__dict__ if hasattr(obj, "__dict__") else obj
80
61
  print(json.dumps(args, indent=2))
@@ -107,6 +88,17 @@ def chat_summary(chat):
107
88
  return json.dumps(clone, indent=2)
108
89
 
109
90
 
91
+ def gemini_chat_summary(gemini_chat):
92
+ """Summarize Gemini chat completion request for logging. Replace inline_data with size of content only"""
93
+ clone = json.loads(json.dumps(gemini_chat))
94
+ for content in clone["contents"]:
95
+ for part in content["parts"]:
96
+ if "inline_data" in part:
97
+ data = part["inline_data"]["data"]
98
+ part["inline_data"]["data"] = f"({len(data)})"
99
+ return json.dumps(clone, indent=2)
100
+
101
+
110
102
  image_exts = ["png", "webp", "jpg", "jpeg", "gif", "bmp", "svg", "tiff", "ico"]
111
103
  audio_exts = ["mp3", "wav", "ogg", "flac", "m4a", "opus", "webm"]
112
104
 
@@ -200,16 +192,6 @@ def is_base_64(data):
200
192
  return False
201
193
 
202
194
 
203
- def id_to_name(id):
204
- return id.replace("-", " ").title()
205
-
206
-
207
- def pluralize(word, count):
208
- if count == 1:
209
- return word
210
- return word + "s"
211
-
212
-
213
195
  def get_file_mime_type(filename):
214
196
  mime_type, _ = mimetypes.guess_type(filename)
215
197
  return mime_type or "application/octet-stream"
@@ -331,52 +313,11 @@ def convert_image_if_needed(image_bytes, mimetype="image/png"):
331
313
  return image_bytes, mimetype
332
314
 
333
315
 
334
- def to_content(result):
335
- if isinstance(result, (str, int, float, bool)):
336
- return str(result)
337
- elif isinstance(result, (list, set, tuple, dict)):
338
- return json.dumps(result)
339
- else:
340
- return str(result)
341
-
342
-
343
- def function_to_tool_definition(func):
344
- type_hints = get_type_hints(func)
345
- signature = inspect.signature(func)
346
- parameters = {"type": "object", "properties": {}, "required": []}
347
-
348
- for name, param in signature.parameters.items():
349
- param_type = type_hints.get(name, str)
350
- param_type_name = "string"
351
- if param_type is int:
352
- param_type_name = "integer"
353
- elif param_type is float:
354
- param_type_name = "number"
355
- elif param_type is bool:
356
- param_type_name = "boolean"
357
-
358
- parameters["properties"][name] = {"type": param_type_name}
359
- if param.default == inspect.Parameter.empty:
360
- parameters["required"].append(name)
361
-
362
- return {
363
- "type": "function",
364
- "function": {
365
- "name": func.__name__,
366
- "description": func.__doc__ or "",
367
- "parameters": parameters,
368
- },
369
- }
370
-
371
-
372
316
  async def process_chat(chat, provider_id=None):
373
317
  if not chat:
374
318
  raise Exception("No chat provided")
375
319
  if "stream" not in chat:
376
320
  chat["stream"] = False
377
- # Some providers don't support empty tools
378
- if "tools" in chat and len(chat["tools"]) == 0:
379
- del chat["tools"]
380
321
  if "messages" not in chat:
381
322
  return chat
382
323
 
@@ -503,92 +444,6 @@ async def process_chat(chat, provider_id=None):
503
444
  return chat
504
445
 
505
446
 
506
- def image_ext_from_mimetype(mimetype, default="png"):
507
- if "/" in mimetype:
508
- _ext = mimetypes.guess_extension(mimetype)
509
- if _ext:
510
- return _ext.lstrip(".")
511
- return default
512
-
513
-
514
- def audio_ext_from_format(format, default="mp3"):
515
- if format == "mpeg":
516
- return "mp3"
517
- return format or default
518
-
519
-
520
- def file_ext_from_mimetype(mimetype, default="pdf"):
521
- if "/" in mimetype:
522
- _ext = mimetypes.guess_extension(mimetype)
523
- if _ext:
524
- return _ext.lstrip(".")
525
- return default
526
-
527
-
528
- def cache_message_inline_data(m):
529
- """
530
- Replaces and caches any inline data URIs in the message content.
531
- """
532
- if "content" not in m:
533
- return
534
-
535
- content = m["content"]
536
- if isinstance(content, list):
537
- for item in content:
538
- if item.get("type") == "image_url":
539
- image_url = item.get("image_url", {})
540
- url = image_url.get("url")
541
- if url and url.startswith("data:"):
542
- # Extract base64 and mimetype
543
- try:
544
- header, base64_data = url.split(";base64,")
545
- # header is like "data:image/png"
546
- ext = image_ext_from_mimetype(header.split(":")[1])
547
- filename = f"image.{ext}" # Hash will handle uniqueness
548
-
549
- cache_url, _ = save_image_to_cache(base64_data, filename, {}, ignore_info=True)
550
- image_url["url"] = cache_url
551
- except Exception as e:
552
- _log(f"Error caching inline image: {e}")
553
-
554
- elif item.get("type") == "input_audio":
555
- input_audio = item.get("input_audio", {})
556
- data = input_audio.get("data")
557
- if data:
558
- # Handle data URI or raw base64
559
- base64_data = data
560
- if data.startswith("data:"):
561
- with contextlib.suppress(ValueError):
562
- header, base64_data = data.split(";base64,")
563
-
564
- fmt = audio_ext_from_format(input_audio.get("format"))
565
- filename = f"audio.{fmt}"
566
-
567
- try:
568
- cache_url, _ = save_bytes_to_cache(base64_data, filename, {}, ignore_info=True)
569
- input_audio["data"] = cache_url
570
- except Exception as e:
571
- _log(f"Error caching inline audio: {e}")
572
-
573
- elif item.get("type") == "file":
574
- file_info = item.get("file", {})
575
- file_data = file_info.get("file_data")
576
- if file_data and file_data.startswith("data:"):
577
- try:
578
- header, base64_data = file_data.split(";base64,")
579
- mimetype = header.split(":")[1]
580
- # Try to get extension from filename if available, else mimetype
581
- filename = file_info.get("filename", "file")
582
- if "." not in filename:
583
- ext = file_ext_from_mimetype(mimetype)
584
- filename = f"{filename}.{ext}"
585
-
586
- cache_url, _ = save_bytes_to_cache(base64_data, filename, {}, ignore_info=True)
587
- file_info["file_data"] = cache_url
588
- except Exception as e:
589
- _log(f"Error caching inline file: {e}")
590
-
591
-
592
447
  class HTTPError(Exception):
593
448
  def __init__(self, status, reason, body, headers=None):
594
449
  self.status = status
@@ -598,302 +453,15 @@ class HTTPError(Exception):
598
453
  super().__init__(f"HTTP {status} {reason}")
599
454
 
600
455
 
601
- def save_bytes_to_cache(base64_data, filename, file_info, ignore_info=False):
602
- ext = filename.split(".")[-1]
603
- mimetype = get_file_mime_type(filename)
604
- content = base64.b64decode(base64_data) if isinstance(base64_data, str) else base64_data
605
- sha256_hash = hashlib.sha256(content).hexdigest()
606
-
607
- save_filename = f"{sha256_hash}.{ext}" if ext else sha256_hash
608
-
609
- # Use first 2 chars for subdir to avoid too many files in one dir
610
- subdir = sha256_hash[:2]
611
- relative_path = f"{subdir}/{save_filename}"
612
- full_path = get_cache_path(relative_path)
613
- url = f"/~cache/{relative_path}"
614
-
615
- # if file and its .info.json already exists, return it
616
- info_path = os.path.splitext(full_path)[0] + ".info.json"
617
- if os.path.exists(full_path) and os.path.exists(info_path):
618
- _dbg(f"Cached bytes exists: {relative_path}")
619
- if ignore_info:
620
- return url, None
621
- return url, json.load(open(info_path))
622
-
623
- os.makedirs(os.path.dirname(full_path), exist_ok=True)
624
-
625
- with open(full_path, "wb") as f:
626
- f.write(content)
627
- info = {
628
- "date": int(time.time()),
629
- "url": url,
630
- "size": len(content),
631
- "type": mimetype,
632
- "name": filename,
633
- }
634
- info.update(file_info)
635
-
636
- # Save metadata
637
- info_path = os.path.splitext(full_path)[0] + ".info.json"
638
- with open(info_path, "w") as f:
639
- json.dump(info, f)
640
-
641
- _dbg(f"Saved cached bytes and info: {relative_path}")
642
-
643
- g_app.on_cache_saved_filters({"url": url, "info": info})
644
-
645
- return url, info
646
-
647
-
648
- def save_image_to_cache(base64_data, filename, image_info, ignore_info=False):
649
- ext = filename.split(".")[-1]
650
- mimetype = get_file_mime_type(filename)
651
- content = base64.b64decode(base64_data) if isinstance(base64_data, str) else base64_data
652
- sha256_hash = hashlib.sha256(content).hexdigest()
653
-
654
- save_filename = f"{sha256_hash}.{ext}" if ext else sha256_hash
655
-
656
- # Use first 2 chars for subdir to avoid too many files in one dir
657
- subdir = sha256_hash[:2]
658
- relative_path = f"{subdir}/{save_filename}"
659
- full_path = get_cache_path(relative_path)
660
- url = f"/~cache/{relative_path}"
661
-
662
- # if file and its .info.json already exists, return it
663
- info_path = os.path.splitext(full_path)[0] + ".info.json"
664
- if os.path.exists(full_path) and os.path.exists(info_path):
665
- _dbg(f"Saved image exists: {relative_path}")
666
- if ignore_info:
667
- return url, None
668
- return url, json.load(open(info_path))
669
-
670
- os.makedirs(os.path.dirname(full_path), exist_ok=True)
671
-
672
- with open(full_path, "wb") as f:
673
- f.write(content)
674
- info = {
675
- "date": int(time.time()),
676
- "url": url,
677
- "size": len(content),
678
- "type": mimetype,
679
- "name": filename,
680
- }
681
- info.update(image_info)
682
-
683
- # If image, get dimensions
684
- if HAS_PIL and mimetype.startswith("image/"):
685
- try:
686
- with Image.open(BytesIO(content)) as img:
687
- info["width"] = img.width
688
- info["height"] = img.height
689
- except Exception:
690
- pass
691
-
692
- if "width" in info and "height" in info:
693
- _log(f"Saved image to cache: {full_path} ({len(content)} bytes) {info['width']}x{info['height']}")
694
- else:
695
- _log(f"Saved image to cache: {full_path} ({len(content)} bytes)")
696
-
697
- # Save metadata
698
- info_path = os.path.splitext(full_path)[0] + ".info.json"
699
- with open(info_path, "w") as f:
700
- json.dump(info, f)
701
-
702
- _dbg(f"Saved image and info: {relative_path}")
703
-
704
- g_app.on_cache_saved_filters({"url": url, "info": info})
705
-
706
- return url, info
707
-
708
-
709
456
  async def response_json(response):
710
457
  text = await response.text()
711
458
  if response.status >= 400:
712
- _dbg(f"HTTP {response.status} {response.reason}: {text}")
713
459
  raise HTTPError(response.status, reason=response.reason, body=text, headers=dict(response.headers))
714
460
  response.raise_for_status()
715
461
  body = json.loads(text)
716
462
  return body
717
463
 
718
464
 
719
- def chat_to_prompt(chat):
720
- prompt = ""
721
- if "messages" in chat:
722
- for message in chat["messages"]:
723
- if message["role"] == "user":
724
- # if content is string
725
- if isinstance(message["content"], str):
726
- if prompt:
727
- prompt += "\n"
728
- prompt += message["content"]
729
- elif isinstance(message["content"], list):
730
- # if content is array of objects
731
- for part in message["content"]:
732
- if part["type"] == "text":
733
- if prompt:
734
- prompt += "\n"
735
- prompt += part["text"]
736
- return prompt
737
-
738
-
739
- def chat_to_system_prompt(chat):
740
- if "messages" in chat:
741
- for message in chat["messages"]:
742
- if message["role"] == "system":
743
- # if content is string
744
- if isinstance(message["content"], str):
745
- return message["content"]
746
- elif isinstance(message["content"], list):
747
- # if content is array of objects
748
- for part in message["content"]:
749
- if part["type"] == "text":
750
- return part["text"]
751
- return None
752
-
753
-
754
- def chat_to_username(chat):
755
- if "metadata" in chat and "user" in chat["metadata"]:
756
- return chat["metadata"]["user"]
757
- return None
758
-
759
-
760
- def last_user_prompt(chat):
761
- prompt = ""
762
- if "messages" in chat:
763
- for message in chat["messages"]:
764
- if message["role"] == "user":
765
- # if content is string
766
- if isinstance(message["content"], str):
767
- prompt = message["content"]
768
- elif isinstance(message["content"], list):
769
- # if content is array of objects
770
- for part in message["content"]:
771
- if part["type"] == "text":
772
- prompt = part["text"]
773
- return prompt
774
-
775
-
776
- def chat_response_to_message(openai_response):
777
- """
778
- Returns an assistant message from the OpenAI Response.
779
- Handles normalizing text, image, and audio responses into the message content.
780
- """
781
- timestamp = int(time.time() * 1000) # openai_response.get("created")
782
- choices = openai_response
783
- if isinstance(openai_response, dict) and "choices" in openai_response:
784
- choices = openai_response["choices"]
785
-
786
- choice = choices[0] if isinstance(choices, list) and choices else choices
787
-
788
- if isinstance(choice, str):
789
- return {"role": "assistant", "content": choice, "timestamp": timestamp}
790
-
791
- if isinstance(choice, dict):
792
- message = choice.get("message", choice)
793
- else:
794
- return {"role": "assistant", "content": str(choice), "timestamp": timestamp}
795
-
796
- # Ensure message is a dict
797
- if not isinstance(message, dict):
798
- return {"role": "assistant", "content": message, "timestamp": timestamp}
799
-
800
- message.update({"timestamp": timestamp})
801
- return message
802
-
803
-
804
- def to_file_info(chat, info=None, response=None):
805
- prompt = last_user_prompt(chat)
806
- ret = info or {}
807
- if chat["model"] and "model" not in ret:
808
- ret["model"] = chat["model"]
809
- if prompt and "prompt" not in ret:
810
- ret["prompt"] = prompt
811
- if "image_config" in chat:
812
- ret.update(chat["image_config"])
813
- user = chat_to_username(chat)
814
- if user:
815
- ret["user"] = user
816
- return ret
817
-
818
-
819
- # Image Generator Providers
820
- class GeneratorBase:
821
- def __init__(self, **kwargs):
822
- self.id = kwargs.get("id")
823
- self.api = kwargs.get("api")
824
- self.api_key = kwargs.get("api_key")
825
- self.headers = {
826
- "Accept": "application/json",
827
- "Content-Type": "application/json",
828
- }
829
- self.chat_url = f"{self.api}/chat/completions"
830
- self.default_content = "I've generated the image for you."
831
-
832
- def validate(self, **kwargs):
833
- if not self.api_key:
834
- api_keys = ", ".join(self.env)
835
- return f"Provider '{self.name}' requires API Key {api_keys}"
836
- return None
837
-
838
- def test(self, **kwargs):
839
- error_msg = self.validate(**kwargs)
840
- if error_msg:
841
- _log(error_msg)
842
- return False
843
- return True
844
-
845
- async def load(self):
846
- pass
847
-
848
- def gen_summary(self, gen):
849
- """Summarize gen response for logging."""
850
- clone = json.loads(json.dumps(gen))
851
- return json.dumps(clone, indent=2)
852
-
853
- def chat_summary(self, chat):
854
- return chat_summary(chat)
855
-
856
- def process_chat(self, chat, provider_id=None):
857
- return process_chat(chat, provider_id)
858
-
859
- async def response_json(self, response):
860
- return await response_json(response)
861
-
862
- def get_headers(self, provider, chat):
863
- headers = self.headers.copy()
864
- if provider is not None:
865
- headers["Authorization"] = f"Bearer {provider.api_key}"
866
- elif self.api_key:
867
- headers["Authorization"] = f"Bearer {self.api_key}"
868
- return headers
869
-
870
- def to_response(self, response, chat, started_at):
871
- raise NotImplementedError
872
-
873
- async def chat(self, chat, provider=None):
874
- return {
875
- "choices": [
876
- {
877
- "message": {
878
- "role": "assistant",
879
- "content": "Not Implemented",
880
- "images": [
881
- {
882
- "type": "image_url",
883
- "image_url": {
884
- "url": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSJjdXJyZW50Q29sb3IiIGQ9Ik0xMiAyMGE4IDggMCAxIDAgMC0xNmE4IDggMCAwIDAgMCAxNm0wIDJDNi40NzcgMjIgMiAxNy41MjMgMiAxMlM2LjQ3NyAyIDEyIDJzMTAgNC40NzcgMTAgMTBzLTQuNDc3IDEwLTEwIDEwbS0xLTZoMnYyaC0yem0wLTEwaDJ2OGgtMnoiLz48L3N2Zz4=",
885
- },
886
- }
887
- ],
888
- }
889
- }
890
- ]
891
- }
892
-
893
-
894
- # OpenAI Providers
895
-
896
-
897
465
  class OpenAiCompatible:
898
466
  sdk = "@ai-sdk/openai-compatible"
899
467
 
@@ -905,9 +473,8 @@ class OpenAiCompatible:
905
473
 
906
474
  self.id = kwargs.get("id")
907
475
  self.api = kwargs.get("api").strip("/")
908
- self.env = kwargs.get("env", [])
909
476
  self.api_key = kwargs.get("api_key")
910
- self.name = kwargs.get("name", id_to_name(self.id))
477
+ self.name = kwargs.get("name", self.id.replace("-", " ").title().replace(" ", ""))
911
478
  self.set_models(**kwargs)
912
479
 
913
480
  self.chat_url = f"{self.api}/chat/completions"
@@ -935,7 +502,6 @@ class OpenAiCompatible:
935
502
  self.stream = bool(kwargs["stream"]) if "stream" in kwargs else None
936
503
  self.enable_thinking = bool(kwargs["enable_thinking"]) if "enable_thinking" in kwargs else None
937
504
  self.check = kwargs.get("check")
938
- self.modalities = kwargs.get("modalities", {})
939
505
 
940
506
  def set_models(self, **kwargs):
941
507
  models = kwargs.get("models", {})
@@ -961,34 +527,23 @@ class OpenAiCompatible:
961
527
  _log(f"Filtering {len(self.models)} models, excluding models that match regex: {exclude_models}")
962
528
  self.models = {k: v for k, v in self.models.items() if not re.search(exclude_models, k)}
963
529
 
964
- def validate(self, **kwargs):
965
- if not self.api_key:
966
- api_keys = ", ".join(self.env)
967
- return f"Provider '{self.name}' requires API Key {api_keys}"
968
- return None
969
-
970
530
  def test(self, **kwargs):
971
- error_msg = self.validate(**kwargs)
972
- if error_msg:
973
- _log(error_msg)
974
- return False
975
- return True
531
+ ret = self.api and self.api_key and (len(self.models) > 0)
532
+ if not ret:
533
+ _log(f"Provider {self.name} Missing: {self.api}, {self.api_key}, {len(self.models)}")
534
+ return ret
976
535
 
977
536
  async def load(self):
978
537
  if not self.models:
979
538
  await self.load_models()
980
539
 
981
- def model_info(self, model):
540
+ def model_cost(self, model):
982
541
  provider_model = self.provider_model(model) or model
983
542
  for model_id, model_info in self.models.items():
984
543
  if model_id.lower() == provider_model.lower():
985
- return model_info
544
+ return model_info.get("cost")
986
545
  return None
987
546
 
988
- def model_cost(self, model):
989
- model_info = self.model_info(model)
990
- return model_info.get("cost") if model_info else None
991
-
992
547
  def provider_model(self, model):
993
548
  # convert model to lowercase for case-insensitive comparison
994
549
  model_lower = model.lower()
@@ -1024,11 +579,56 @@ class OpenAiCompatible:
1024
579
  if "/" in model:
1025
580
  last_part = model.split("/")[-1]
1026
581
  return self.provider_model(last_part)
1027
-
1028
582
  return None
1029
583
 
1030
- def response_json(self, response):
1031
- return response_json(response)
584
+ def validate_modalities(self, chat):
585
+ model_id = chat.get("model")
586
+ if not model_id or not self.models:
587
+ return
588
+
589
+ model_info = None
590
+ # Try to find model info using provider_model logic (already resolved to ID)
591
+ if model_id in self.models:
592
+ model_info = self.models[model_id]
593
+ else:
594
+ # Fallback scan
595
+ for m_id, m_info in self.models.items():
596
+ if m_id == model_id or m_info.get("id") == model_id:
597
+ model_info = m_info
598
+ break
599
+
600
+ print(f"DEBUG: Validate modalities: model={model_id}, found_info={model_info is not None}")
601
+ if model_info:
602
+ print(f"DEBUG: Modalities: {model_info.get('modalities')}")
603
+
604
+ if not model_info:
605
+ return
606
+
607
+ modalities = model_info.get("modalities", {})
608
+ input_modalities = modalities.get("input", [])
609
+
610
+ # Check for unsupported modalities
611
+ has_audio = False
612
+ has_image = False
613
+ for message in chat.get("messages", []):
614
+ content = message.get("content")
615
+ if isinstance(content, list):
616
+ for item in content:
617
+ type_ = item.get("type")
618
+ if type_ == "input_audio" or "input_audio" in item:
619
+ has_audio = True
620
+ elif type_ == "image_url" or "image_url" in item:
621
+ has_image = True
622
+
623
+ if has_audio and "audio" not in input_modalities:
624
+ raise Exception(
625
+ f"Model '{model_id}' does not support audio input. Supported modalities: {', '.join(input_modalities)}"
626
+ )
627
+
628
+ if has_image and "image" not in input_modalities:
629
+ raise Exception(
630
+ f"Model '{model_id}' does not support image input. Supported modalities: {', '.join(input_modalities)}"
631
+ )
1032
632
 
1033
633
  def to_response(self, response, chat, started_at):
1034
634
  if "metadata" not in response:
@@ -1038,27 +638,13 @@ class OpenAiCompatible:
1038
638
  pricing = self.model_cost(chat["model"])
1039
639
  if pricing and "input" in pricing and "output" in pricing:
1040
640
  response["metadata"]["pricing"] = f"{pricing['input']}/{pricing['output']}"
641
+ _log(json.dumps(response, indent=2))
1041
642
  return response
1042
643
 
1043
- def chat_summary(self, chat):
1044
- return chat_summary(chat)
1045
-
1046
- def process_chat(self, chat, provider_id=None):
1047
- return process_chat(chat, provider_id)
1048
-
1049
644
  async def chat(self, chat):
1050
645
  chat["model"] = self.provider_model(chat["model"]) or chat["model"]
1051
646
 
1052
- if "modalities" in chat:
1053
- for modality in chat.get("modalities", []):
1054
- # use default implementation for text modalities
1055
- if modality == "text":
1056
- continue
1057
- modality_provider = self.modalities.get(modality)
1058
- if modality_provider:
1059
- return await modality_provider.chat(chat, self)
1060
- else:
1061
- raise Exception(f"Provider {self.name} does not support '{modality}' modality")
647
+ self.validate_modalities(chat)
1062
648
 
1063
649
  # with open(os.path.join(os.path.dirname(__file__), 'chat.wip.json'), "w") as f:
1064
650
  # f.write(json.dumps(chat, indent=2))
@@ -1102,17 +688,203 @@ class OpenAiCompatible:
1102
688
  _log(f"POST {self.chat_url}")
1103
689
  _log(chat_summary(chat))
1104
690
  # remove metadata if any (conflicts with some providers, e.g. Z.ai)
1105
- metadata = chat.pop("metadata", None)
691
+ chat.pop("metadata", None)
1106
692
 
1107
693
  async with aiohttp.ClientSession() as session:
1108
694
  started_at = time.time()
1109
695
  async with session.post(
1110
696
  self.chat_url, headers=self.headers, data=json.dumps(chat), timeout=aiohttp.ClientTimeout(total=120)
1111
697
  ) as response:
1112
- chat["metadata"] = metadata
1113
698
  return self.to_response(await response_json(response), chat, started_at)
1114
699
 
1115
700
 
701
+ class OpenAiProvider(OpenAiCompatible):
702
+ sdk = "@ai-sdk/openai"
703
+
704
+ def __init__(self, **kwargs):
705
+ if "api" not in kwargs:
706
+ kwargs["api"] = "https://api.openai.com/v1"
707
+ super().__init__(**kwargs)
708
+
709
+
710
+ class AnthropicProvider(OpenAiCompatible):
711
+ sdk = "@ai-sdk/anthropic"
712
+
713
+ def __init__(self, **kwargs):
714
+ if "api" not in kwargs:
715
+ kwargs["api"] = "https://api.anthropic.com/v1"
716
+ super().__init__(**kwargs)
717
+
718
+ # Anthropic uses x-api-key header instead of Authorization
719
+ if self.api_key:
720
+ self.headers = self.headers.copy()
721
+ if "Authorization" in self.headers:
722
+ del self.headers["Authorization"]
723
+ self.headers["x-api-key"] = self.api_key
724
+
725
+ if "anthropic-version" not in self.headers:
726
+ self.headers = self.headers.copy()
727
+ self.headers["anthropic-version"] = "2023-06-01"
728
+ self.chat_url = f"{self.api}/messages"
729
+
730
+ async def chat(self, chat):
731
+ chat["model"] = self.provider_model(chat["model"]) or chat["model"]
732
+
733
+ chat = await process_chat(chat, provider_id=self.id)
734
+
735
+ # Transform OpenAI format to Anthropic format
736
+ anthropic_request = {
737
+ "model": chat["model"],
738
+ "messages": [],
739
+ }
740
+
741
+ # Extract system message (Anthropic uses top-level 'system' parameter)
742
+ system_messages = []
743
+ for message in chat.get("messages", []):
744
+ if message.get("role") == "system":
745
+ content = message.get("content", "")
746
+ if isinstance(content, str):
747
+ system_messages.append(content)
748
+ elif isinstance(content, list):
749
+ for item in content:
750
+ if item.get("type") == "text":
751
+ system_messages.append(item.get("text", ""))
752
+
753
+ if system_messages:
754
+ anthropic_request["system"] = "\n".join(system_messages)
755
+
756
+ # Transform messages (exclude system messages)
757
+ for message in chat.get("messages", []):
758
+ if message.get("role") == "system":
759
+ continue
760
+
761
+ anthropic_message = {"role": message.get("role"), "content": []}
762
+
763
+ content = message.get("content", "")
764
+ if isinstance(content, str):
765
+ anthropic_message["content"] = content
766
+ elif isinstance(content, list):
767
+ for item in content:
768
+ if item.get("type") == "text":
769
+ anthropic_message["content"].append({"type": "text", "text": item.get("text", "")})
770
+ elif item.get("type") == "image_url" and "image_url" in item:
771
+ # Transform OpenAI image_url format to Anthropic format
772
+ image_url = item["image_url"].get("url", "")
773
+ if image_url.startswith("data:"):
774
+ # Extract media type and base64 data
775
+ parts = image_url.split(";base64,", 1)
776
+ if len(parts) == 2:
777
+ media_type = parts[0].replace("data:", "")
778
+ base64_data = parts[1]
779
+ anthropic_message["content"].append(
780
+ {
781
+ "type": "image",
782
+ "source": {"type": "base64", "media_type": media_type, "data": base64_data},
783
+ }
784
+ )
785
+
786
+ anthropic_request["messages"].append(anthropic_message)
787
+
788
+ # Handle max_tokens (required by Anthropic, uses max_tokens not max_completion_tokens)
789
+ if "max_completion_tokens" in chat:
790
+ anthropic_request["max_tokens"] = chat["max_completion_tokens"]
791
+ elif "max_tokens" in chat:
792
+ anthropic_request["max_tokens"] = chat["max_tokens"]
793
+ else:
794
+ # Anthropic requires max_tokens, set a default
795
+ anthropic_request["max_tokens"] = 4096
796
+
797
+ # Copy other supported parameters
798
+ if "temperature" in chat:
799
+ anthropic_request["temperature"] = chat["temperature"]
800
+ if "top_p" in chat:
801
+ anthropic_request["top_p"] = chat["top_p"]
802
+ if "top_k" in chat:
803
+ anthropic_request["top_k"] = chat["top_k"]
804
+ if "stop" in chat:
805
+ anthropic_request["stop_sequences"] = chat["stop"] if isinstance(chat["stop"], list) else [chat["stop"]]
806
+ if "stream" in chat:
807
+ anthropic_request["stream"] = chat["stream"]
808
+ if "tools" in chat:
809
+ anthropic_request["tools"] = chat["tools"]
810
+ if "tool_choice" in chat:
811
+ anthropic_request["tool_choice"] = chat["tool_choice"]
812
+
813
+ _log(f"POST {self.chat_url}")
814
+ _log(f"Anthropic Request: {json.dumps(anthropic_request, indent=2)}")
815
+
816
+ async with aiohttp.ClientSession() as session:
817
+ started_at = time.time()
818
+ async with session.post(
819
+ self.chat_url,
820
+ headers=self.headers,
821
+ data=json.dumps(anthropic_request),
822
+ timeout=aiohttp.ClientTimeout(total=120),
823
+ ) as response:
824
+ return self.to_response(await response_json(response), chat, started_at)
825
+
826
+ def to_response(self, response, chat, started_at):
827
+ """Convert Anthropic response format to OpenAI-compatible format."""
828
+ # Transform Anthropic response to OpenAI format
829
+ openai_response = {
830
+ "id": response.get("id", ""),
831
+ "object": "chat.completion",
832
+ "created": int(started_at),
833
+ "model": response.get("model", ""),
834
+ "choices": [],
835
+ "usage": {},
836
+ }
837
+
838
+ # Transform content blocks to message content
839
+ content_parts = []
840
+ thinking_parts = []
841
+
842
+ for block in response.get("content", []):
843
+ if block.get("type") == "text":
844
+ content_parts.append(block.get("text", ""))
845
+ elif block.get("type") == "thinking":
846
+ # Store thinking blocks separately (some models include reasoning)
847
+ thinking_parts.append(block.get("thinking", ""))
848
+
849
+ # Combine all text content
850
+ message_content = "\n".join(content_parts) if content_parts else ""
851
+
852
+ # Create the choice object
853
+ choice = {
854
+ "index": 0,
855
+ "message": {"role": "assistant", "content": message_content},
856
+ "finish_reason": response.get("stop_reason", "stop"),
857
+ }
858
+
859
+ # Add thinking as metadata if present
860
+ if thinking_parts:
861
+ choice["message"]["thinking"] = "\n".join(thinking_parts)
862
+
863
+ openai_response["choices"].append(choice)
864
+
865
+ # Transform usage
866
+ if "usage" in response:
867
+ usage = response["usage"]
868
+ openai_response["usage"] = {
869
+ "prompt_tokens": usage.get("input_tokens", 0),
870
+ "completion_tokens": usage.get("output_tokens", 0),
871
+ "total_tokens": usage.get("input_tokens", 0) + usage.get("output_tokens", 0),
872
+ }
873
+
874
+ # Add metadata
875
+ if "metadata" not in openai_response:
876
+ openai_response["metadata"] = {}
877
+ openai_response["metadata"]["duration"] = int((time.time() - started_at) * 1000)
878
+
879
+ if chat is not None and "model" in chat:
880
+ cost = self.model_cost(chat["model"])
881
+ if cost and "input" in cost and "output" in cost:
882
+ openai_response["metadata"]["pricing"] = f"{cost['input']}/{cost['output']}"
883
+
884
+ _log(json.dumps(openai_response, indent=2))
885
+ return openai_response
886
+
887
+
1116
888
  class MistralProvider(OpenAiCompatible):
1117
889
  sdk = "@ai-sdk/mistral"
1118
890
 
@@ -1169,10 +941,11 @@ class OllamaProvider(OpenAiCompatible):
1169
941
  ) as response:
1170
942
  data = await response_json(response)
1171
943
  for model in data.get("models", []):
1172
- model_id = model["model"]
1173
- if model_id.endswith(":latest"):
1174
- model_id = model_id[:-7]
1175
- ret[model_id] = model_id
944
+ name = model["model"]
945
+ if name.endswith(":latest"):
946
+ name = name[:-7]
947
+ model_id = name.replace(":", "-")
948
+ ret[model_id] = name
1176
949
  _log(f"Loaded Ollama models: {ret}")
1177
950
  except Exception as e:
1178
951
  _log(f"Error getting Ollama models: {e}")
@@ -1208,8 +981,8 @@ class OllamaProvider(OpenAiCompatible):
1208
981
  }
1209
982
  self.models = models
1210
983
 
1211
- def validate(self, **kwargs):
1212
- return None
984
+ def test(self, **kwargs):
985
+ return True
1213
986
 
1214
987
 
1215
988
  class LMStudioProvider(OllamaProvider):
@@ -1238,6 +1011,237 @@ class LMStudioProvider(OllamaProvider):
1238
1011
  return ret
1239
1012
 
1240
1013
 
1014
+ # class GoogleOpenAiProvider(OpenAiCompatible):
1015
+ # sdk = "google-openai-compatible"
1016
+
1017
+ # def __init__(self, api_key, **kwargs):
1018
+ # super().__init__(api="https://generativelanguage.googleapis.com", api_key=api_key, **kwargs)
1019
+ # self.chat_url = "https://generativelanguage.googleapis.com/v1beta/chat/completions"
1020
+
1021
+
1022
+ class GoogleProvider(OpenAiCompatible):
1023
+ sdk = "@ai-sdk/google"
1024
+
1025
+ def __init__(self, **kwargs):
1026
+ new_kwargs = {"api": "https://generativelanguage.googleapis.com", **kwargs}
1027
+ super().__init__(**new_kwargs)
1028
+ self.safety_settings = kwargs.get("safety_settings")
1029
+ self.thinking_config = kwargs.get("thinking_config")
1030
+ self.curl = kwargs.get("curl")
1031
+ self.headers = kwargs.get("headers", {"Content-Type": "application/json"})
1032
+ # Google fails when using Authorization header, use query string param instead
1033
+ if "Authorization" in self.headers:
1034
+ del self.headers["Authorization"]
1035
+
1036
+ async def chat(self, chat):
1037
+ chat["model"] = self.provider_model(chat["model"]) or chat["model"]
1038
+
1039
+ chat = await process_chat(chat)
1040
+ generation_config = {}
1041
+
1042
+ # Filter out system messages and convert to proper Gemini format
1043
+ contents = []
1044
+ system_prompt = None
1045
+
1046
+ async with aiohttp.ClientSession() as session:
1047
+ for message in chat["messages"]:
1048
+ if message["role"] == "system":
1049
+ content = message["content"]
1050
+ if isinstance(content, list):
1051
+ for item in content:
1052
+ if "text" in item:
1053
+ system_prompt = item["text"]
1054
+ break
1055
+ elif isinstance(content, str):
1056
+ system_prompt = content
1057
+ elif "content" in message:
1058
+ if isinstance(message["content"], list):
1059
+ parts = []
1060
+ for item in message["content"]:
1061
+ if "type" in item:
1062
+ if item["type"] == "image_url" and "image_url" in item:
1063
+ image_url = item["image_url"]
1064
+ if "url" not in image_url:
1065
+ continue
1066
+ url = image_url["url"]
1067
+ if not url.startswith("data:"):
1068
+ raise (Exception("Image was not downloaded: " + url))
1069
+ # Extract mime type from data uri
1070
+ mimetype = url.split(";", 1)[0].split(":", 1)[1] if ";" in url else "image/png"
1071
+ base64_data = url.split(",", 1)[1]
1072
+ parts.append({"inline_data": {"mime_type": mimetype, "data": base64_data}})
1073
+ elif item["type"] == "input_audio" and "input_audio" in item:
1074
+ input_audio = item["input_audio"]
1075
+ if "data" not in input_audio:
1076
+ continue
1077
+ data = input_audio["data"]
1078
+ format = input_audio["format"]
1079
+ mimetype = f"audio/{format}"
1080
+ parts.append({"inline_data": {"mime_type": mimetype, "data": data}})
1081
+ elif item["type"] == "file" and "file" in item:
1082
+ file = item["file"]
1083
+ if "file_data" not in file:
1084
+ continue
1085
+ data = file["file_data"]
1086
+ if not data.startswith("data:"):
1087
+ raise (Exception("File was not downloaded: " + data))
1088
+ # Extract mime type from data uri
1089
+ mimetype = (
1090
+ data.split(";", 1)[0].split(":", 1)[1]
1091
+ if ";" in data
1092
+ else "application/octet-stream"
1093
+ )
1094
+ base64_data = data.split(",", 1)[1]
1095
+ parts.append({"inline_data": {"mime_type": mimetype, "data": base64_data}})
1096
+ if "text" in item:
1097
+ text = item["text"]
1098
+ parts.append({"text": text})
1099
+ if len(parts) > 0:
1100
+ contents.append(
1101
+ {
1102
+ "role": message["role"]
1103
+ if "role" in message and message["role"] == "user"
1104
+ else "model",
1105
+ "parts": parts,
1106
+ }
1107
+ )
1108
+ else:
1109
+ content = message["content"]
1110
+ contents.append(
1111
+ {
1112
+ "role": message["role"] if "role" in message and message["role"] == "user" else "model",
1113
+ "parts": [{"text": content}],
1114
+ }
1115
+ )
1116
+
1117
+ gemini_chat = {
1118
+ "contents": contents,
1119
+ }
1120
+
1121
+ if self.safety_settings:
1122
+ gemini_chat["safetySettings"] = self.safety_settings
1123
+
1124
+ # Add system instruction if present
1125
+ if system_prompt is not None:
1126
+ gemini_chat["systemInstruction"] = {"parts": [{"text": system_prompt}]}
1127
+
1128
+ if "max_completion_tokens" in chat:
1129
+ generation_config["maxOutputTokens"] = chat["max_completion_tokens"]
1130
+ if "stop" in chat:
1131
+ generation_config["stopSequences"] = [chat["stop"]]
1132
+ if "temperature" in chat:
1133
+ generation_config["temperature"] = chat["temperature"]
1134
+ if "top_p" in chat:
1135
+ generation_config["topP"] = chat["top_p"]
1136
+ if "top_logprobs" in chat:
1137
+ generation_config["topK"] = chat["top_logprobs"]
1138
+
1139
+ if "thinkingConfig" in chat:
1140
+ generation_config["thinkingConfig"] = chat["thinkingConfig"]
1141
+ elif self.thinking_config:
1142
+ generation_config["thinkingConfig"] = self.thinking_config
1143
+
1144
+ if len(generation_config) > 0:
1145
+ gemini_chat["generationConfig"] = generation_config
1146
+
1147
+ started_at = int(time.time() * 1000)
1148
+ gemini_chat_url = f"https://generativelanguage.googleapis.com/v1beta/models/{chat['model']}:generateContent?key={self.api_key}"
1149
+
1150
+ _log(f"POST {gemini_chat_url}")
1151
+ _log(gemini_chat_summary(gemini_chat))
1152
+ started_at = time.time()
1153
+
1154
+ if self.curl:
1155
+ curl_args = [
1156
+ "curl",
1157
+ "-X",
1158
+ "POST",
1159
+ "-H",
1160
+ "Content-Type: application/json",
1161
+ "-d",
1162
+ json.dumps(gemini_chat),
1163
+ gemini_chat_url,
1164
+ ]
1165
+ try:
1166
+ o = subprocess.run(curl_args, check=True, capture_output=True, text=True, timeout=120)
1167
+ obj = json.loads(o.stdout)
1168
+ except Exception as e:
1169
+ raise Exception(f"Error executing curl: {e}") from e
1170
+ else:
1171
+ async with session.post(
1172
+ gemini_chat_url,
1173
+ headers=self.headers,
1174
+ data=json.dumps(gemini_chat),
1175
+ timeout=aiohttp.ClientTimeout(total=120),
1176
+ ) as res:
1177
+ obj = await response_json(res)
1178
+ _log(f"google response:\n{json.dumps(obj, indent=2)}")
1179
+
1180
+ response = {
1181
+ "id": f"chatcmpl-{started_at}",
1182
+ "created": started_at,
1183
+ "model": obj.get("modelVersion", chat["model"]),
1184
+ }
1185
+ choices = []
1186
+ if "error" in obj:
1187
+ _log(f"Error: {obj['error']}")
1188
+ raise Exception(obj["error"]["message"])
1189
+ for i, candidate in enumerate(obj["candidates"]):
1190
+ role = "assistant"
1191
+ if "content" in candidate and "role" in candidate["content"]:
1192
+ role = "assistant" if candidate["content"]["role"] == "model" else candidate["content"]["role"]
1193
+
1194
+ # Safely extract content from all text parts
1195
+ content = ""
1196
+ reasoning = ""
1197
+ if "content" in candidate and "parts" in candidate["content"]:
1198
+ text_parts = []
1199
+ reasoning_parts = []
1200
+ for part in candidate["content"]["parts"]:
1201
+ if "text" in part:
1202
+ if "thought" in part and part["thought"]:
1203
+ reasoning_parts.append(part["text"])
1204
+ else:
1205
+ text_parts.append(part["text"])
1206
+ content = " ".join(text_parts)
1207
+ reasoning = " ".join(reasoning_parts)
1208
+
1209
+ choice = {
1210
+ "index": i,
1211
+ "finish_reason": candidate.get("finishReason", "stop"),
1212
+ "message": {
1213
+ "role": role,
1214
+ "content": content,
1215
+ },
1216
+ }
1217
+ if reasoning:
1218
+ choice["message"]["reasoning"] = reasoning
1219
+ choices.append(choice)
1220
+ response["choices"] = choices
1221
+ if "usageMetadata" in obj:
1222
+ usage = obj["usageMetadata"]
1223
+ response["usage"] = {
1224
+ "completion_tokens": usage["candidatesTokenCount"],
1225
+ "total_tokens": usage["totalTokenCount"],
1226
+ "prompt_tokens": usage["promptTokenCount"],
1227
+ }
1228
+ return self.to_response(response, chat, started_at)
1229
+
1230
+
1231
+ ALL_PROVIDERS = [
1232
+ OpenAiCompatible,
1233
+ OpenAiProvider,
1234
+ AnthropicProvider,
1235
+ MistralProvider,
1236
+ GroqProvider,
1237
+ XaiProvider,
1238
+ CodestralProvider,
1239
+ GoogleProvider,
1240
+ OllamaProvider,
1241
+ LMStudioProvider,
1242
+ ]
1243
+
1244
+
1241
1245
  def get_provider_model(model_name):
1242
1246
  for provider in g_handlers.values():
1243
1247
  provider_model = provider.provider_model(model_name)
@@ -1281,237 +1285,31 @@ def api_providers():
1281
1285
  return ret
1282
1286
 
1283
1287
 
1284
- def to_error_message(e):
1285
- return str(e)
1286
-
1287
-
1288
- def to_error_response(e, stacktrace=False):
1289
- status = {"errorCode": "Error", "message": to_error_message(e)}
1290
- if stacktrace:
1291
- status["stackTrace"] = traceback.format_exc()
1292
- return {"responseStatus": status}
1293
-
1294
-
1295
- def create_error_response(message, error_code="Error", stack_trace=None):
1296
- ret = {"responseStatus": {"errorCode": error_code, "message": message}}
1297
- if stack_trace:
1298
- ret["responseStatus"]["stackTrace"] = stack_trace
1299
- return ret
1300
-
1301
-
1302
- def should_cancel_thread(context):
1303
- ret = context.get("cancelled", False)
1304
- if ret:
1305
- thread_id = context.get("threadId")
1306
- _dbg(f"Thread cancelled {thread_id}")
1307
- return ret
1308
-
1309
-
1310
- def g_chat_request(template=None, text=None, model=None, system_prompt=None):
1311
- chat_template = g_config["defaults"].get(template or "text")
1312
- if not chat_template:
1313
- raise Exception(f"Chat template '{template}' not found")
1314
-
1315
- chat = chat_template.copy()
1316
- if model:
1317
- chat["model"] = model
1318
- if system_prompt is not None:
1319
- chat["messages"].insert(0, {"role": "system", "content": system_prompt})
1320
- if text is not None:
1321
- if not chat["messages"] or len(chat["messages"]) == 0:
1322
- chat["messages"] = [{"role": "user", "content": [{"type": "text", "text": ""}]}]
1323
-
1324
- # replace content of last message if exists, else add
1325
- last_msg = chat["messages"][-1] if "messages" in chat else None
1326
- if last_msg and last_msg["role"] == "user":
1327
- if isinstance(last_msg["content"], list):
1328
- last_msg["content"][-1]["text"] = text
1329
- else:
1330
- last_msg["content"] = text
1331
- else:
1332
- chat["messages"].append({"role": "user", "content": text})
1333
-
1334
- return chat
1335
-
1336
-
1337
- async def g_chat_completion(chat, context=None):
1338
- try:
1339
- model = chat.get("model")
1340
- if not model:
1341
- raise Exception("Model not specified")
1342
-
1343
- if context is None:
1344
- context = {"chat": chat, "tools": "all"}
1345
-
1346
- # get first provider that has the model
1347
- candidate_providers = [name for name, provider in g_handlers.items() if provider.provider_model(model)]
1348
- if len(candidate_providers) == 0:
1349
- raise (Exception(f"Model {model} not found"))
1350
- except Exception as e:
1351
- await g_app.on_chat_error(e, context or {"chat": chat})
1352
- raise e
1288
+ async def chat_completion(chat):
1289
+ model = chat["model"]
1290
+ # get first provider that has the model
1291
+ candidate_providers = [name for name, provider in g_handlers.items() if provider.provider_model(model)]
1292
+ if len(candidate_providers) == 0:
1293
+ raise (Exception(f"Model {model} not found"))
1353
1294
 
1354
- started_at = time.time()
1355
1295
  first_exception = None
1356
- provider_name = "Unknown"
1357
1296
  for name in candidate_providers:
1297
+ provider = g_handlers[name]
1298
+ _log(f"provider: {name} {type(provider).__name__}")
1358
1299
  try:
1359
- provider_name = name
1360
- provider = g_handlers[name]
1361
- _log(f"provider: {name} {type(provider).__name__}")
1362
- started_at = time.time()
1363
- context["startedAt"] = datetime.now()
1364
- context["provider"] = name
1365
- model_info = provider.model_info(model)
1366
- context["modelCost"] = model_info.get("cost", provider.model_cost(model)) or {"input": 0, "output": 0}
1367
- context["modelInfo"] = model_info
1368
-
1369
- # Accumulate usage across tool calls
1370
- total_usage = {
1371
- "prompt_tokens": 0,
1372
- "completion_tokens": 0,
1373
- "total_tokens": 0,
1374
- }
1375
- accumulated_cost = 0.0
1376
-
1377
- # Inject global tools if present
1378
- current_chat = chat.copy()
1379
- if g_app.tool_definitions:
1380
- only_tools_str = context.get("tools", "all")
1381
- include_all_tools = only_tools_str == "all"
1382
- only_tools = only_tools_str.split(",")
1383
-
1384
- if include_all_tools or len(only_tools) > 0:
1385
- if "tools" not in current_chat:
1386
- current_chat["tools"] = []
1387
-
1388
- existing_tools = {t["function"]["name"] for t in current_chat["tools"]}
1389
- for tool_def in g_app.tool_definitions:
1390
- name = tool_def["function"]["name"]
1391
- if name not in existing_tools and (include_all_tools or name in only_tools):
1392
- current_chat["tools"].append(tool_def)
1393
-
1394
- # Apply pre-chat filters ONCE
1395
- context["chat"] = current_chat
1396
- for filter_func in g_app.chat_request_filters:
1397
- await filter_func(current_chat, context)
1398
-
1399
- # Tool execution loop
1400
- max_iterations = 10
1401
- tool_history = []
1402
- final_response = None
1403
-
1404
- for _ in range(max_iterations):
1405
- if should_cancel_thread(context):
1406
- return
1407
-
1408
- response = await provider.chat(current_chat)
1409
-
1410
- if should_cancel_thread(context):
1411
- return
1412
-
1413
- # Aggregate usage
1414
- if "usage" in response:
1415
- usage = response["usage"]
1416
- total_usage["prompt_tokens"] += usage.get("prompt_tokens", 0)
1417
- total_usage["completion_tokens"] += usage.get("completion_tokens", 0)
1418
- total_usage["total_tokens"] += usage.get("total_tokens", 0)
1419
-
1420
- # Calculate cost for this step if available
1421
- if "cost" in response and isinstance(response["cost"], (int, float)):
1422
- accumulated_cost += response["cost"]
1423
- elif "cost" in usage and isinstance(usage["cost"], (int, float)):
1424
- accumulated_cost += usage["cost"]
1425
-
1426
- # Check for tool_calls in the response
1427
- choice = response.get("choices", [])[0] if response.get("choices") else {}
1428
- message = choice.get("message", {})
1429
- tool_calls = message.get("tool_calls")
1430
-
1431
- if tool_calls:
1432
- # Append the assistant's message with tool calls to history
1433
- if "messages" not in current_chat:
1434
- current_chat["messages"] = []
1435
- if "timestamp" not in message:
1436
- message["timestamp"] = int(time.time() * 1000)
1437
- current_chat["messages"].append(message)
1438
- tool_history.append(message)
1439
-
1440
- await g_app.on_chat_tool(current_chat, context)
1441
-
1442
- for tool_call in tool_calls:
1443
- function_name = tool_call["function"]["name"]
1444
- try:
1445
- function_args = json.loads(tool_call["function"]["arguments"])
1446
- except Exception as e:
1447
- tool_result = f"Error parsing JSON arguments for tool {function_name}: {e}"
1448
- else:
1449
- tool_result = f"Error: Tool {function_name} not found"
1450
- if function_name in g_app.tools:
1451
- try:
1452
- func = g_app.tools[function_name]
1453
- if inspect.iscoroutinefunction(func):
1454
- tool_result = await func(**function_args)
1455
- else:
1456
- tool_result = func(**function_args)
1457
- except Exception as e:
1458
- tool_result = f"Error executing tool {function_name}: {e}"
1459
-
1460
- # Append tool result to history
1461
- tool_msg = {"role": "tool", "tool_call_id": tool_call["id"], "content": to_content(tool_result)}
1462
- current_chat["messages"].append(tool_msg)
1463
- tool_history.append(tool_msg)
1464
-
1465
- await g_app.on_chat_tool(current_chat, context)
1466
-
1467
- if should_cancel_thread(context):
1468
- return
1469
-
1470
- # Continue loop to send tool results back to LLM
1471
- continue
1472
-
1473
- # If no tool calls, this is the final response
1474
- if tool_history:
1475
- response["tool_history"] = tool_history
1476
-
1477
- # Update final response with aggregated usage
1478
- if "usage" not in response:
1479
- response["usage"] = {}
1480
- # convert to int seconds
1481
- context["duration"] = duration = int(time.time() - started_at)
1482
- total_usage.update({"duration": duration})
1483
- response["usage"].update(total_usage)
1484
- # If we accumulated cost, set it on the response
1485
- if accumulated_cost > 0:
1486
- response["cost"] = accumulated_cost
1487
-
1488
- final_response = response
1489
- break # Exit tool loop
1490
-
1491
- if final_response:
1492
- # Apply post-chat filters ONCE on final response
1493
- for filter_func in g_app.chat_response_filters:
1494
- await filter_func(final_response, context)
1495
-
1496
- if DEBUG:
1497
- _dbg(json.dumps(final_response, indent=2))
1498
-
1499
- return final_response
1500
-
1300
+ response = await provider.chat(chat.copy())
1301
+ return response
1501
1302
  except Exception as e:
1502
1303
  if first_exception is None:
1503
1304
  first_exception = e
1504
- context["stackTrace"] = traceback.format_exc()
1505
- _err(f"Provider {provider_name} failed", first_exception)
1506
- await g_app.on_chat_error(e, context)
1507
-
1305
+ _log(f"Provider {name} failed: {e}")
1508
1306
  continue
1509
1307
 
1510
1308
  # If we get here, all providers failed
1511
1309
  raise first_exception
1512
1310
 
1513
1311
 
1514
- async def cli_chat(chat, tools=None, image=None, audio=None, file=None, args=None, raw=False):
1312
+ async def cli_chat(chat, image=None, audio=None, file=None, args=None, raw=False):
1515
1313
  if g_default_model:
1516
1314
  chat["model"] = g_default_model
1517
1315
 
@@ -1586,53 +1384,25 @@ async def cli_chat(chat, tools=None, image=None, audio=None, file=None, args=Non
1586
1384
  printdump(chat)
1587
1385
 
1588
1386
  try:
1589
- context = {
1590
- "tools": tools or "all",
1591
- }
1592
- response = await g_app.chat_completion(chat, context=context)
1593
-
1387
+ response = await chat_completion(chat)
1594
1388
  if raw:
1595
1389
  print(json.dumps(response, indent=2))
1596
1390
  exit(0)
1597
1391
  else:
1598
- msg = response["choices"][0]["message"]
1599
- if "content" in msg or "answer" in msg:
1600
- print(msg["content"])
1601
-
1602
- generated_files = []
1603
- for choice in response["choices"]:
1604
- if "message" in choice:
1605
- msg = choice["message"]
1606
- if "images" in msg:
1607
- for image in msg["images"]:
1608
- image_url = image["image_url"]["url"]
1609
- generated_files.append(image_url)
1610
- if "audios" in msg:
1611
- for audio in msg["audios"]:
1612
- audio_url = audio["audio_url"]["url"]
1613
- generated_files.append(audio_url)
1614
-
1615
- if len(generated_files) > 0:
1616
- print("\nSaved files:")
1617
- for file in generated_files:
1618
- if file.startswith("/~cache"):
1619
- print(get_cache_path(file[8:]))
1620
- print(urljoin("http://localhost:8000", file))
1621
- else:
1622
- print(file)
1623
-
1392
+ answer = response["choices"][0]["message"]["content"]
1393
+ print(answer)
1624
1394
  except HTTPError as e:
1625
1395
  # HTTP error (4xx, 5xx)
1626
1396
  print(f"{e}:\n{e.body}")
1627
- g_app.exit(1)
1397
+ exit(1)
1628
1398
  except aiohttp.ClientConnectionError as e:
1629
1399
  # Connection issues
1630
1400
  print(f"Connection error: {e}")
1631
- g_app.exit(1)
1401
+ exit(1)
1632
1402
  except asyncio.TimeoutError as e:
1633
1403
  # Timeout
1634
1404
  print(f"Timeout error: {e}")
1635
- g_app.exit(1)
1405
+ exit(1)
1636
1406
 
1637
1407
 
1638
1408
  def config_str(key):
@@ -1655,33 +1425,29 @@ def init_llms(config, providers):
1655
1425
  # iterate over config and replace $ENV with env value
1656
1426
  for key, value in g_config.items():
1657
1427
  if isinstance(value, str) and value.startswith("$"):
1658
- g_config[key] = os.getenv(value[1:], "")
1428
+ g_config[key] = os.environ.get(value[1:], "")
1659
1429
 
1660
1430
  # if g_verbose:
1661
1431
  # printdump(g_config)
1662
1432
  providers = g_config["providers"]
1663
1433
 
1664
1434
  for id, orig in providers.items():
1665
- if "enabled" in orig and not orig["enabled"]:
1435
+ definition = orig.copy()
1436
+ if "enabled" in definition and not definition["enabled"]:
1666
1437
  continue
1667
1438
 
1668
- provider, constructor_kwargs = create_provider_from_definition(id, orig)
1439
+ provider_id = definition.get("id", id)
1440
+ if "id" not in definition:
1441
+ definition["id"] = provider_id
1442
+ provider = g_providers.get(provider_id)
1443
+ constructor_kwargs = create_provider_kwargs(definition, provider)
1444
+ provider = create_provider(constructor_kwargs)
1445
+
1669
1446
  if provider and provider.test(**constructor_kwargs):
1670
1447
  g_handlers[id] = provider
1671
1448
  return g_handlers
1672
1449
 
1673
1450
 
1674
- def create_provider_from_definition(id, orig):
1675
- definition = orig.copy()
1676
- provider_id = definition.get("id", id)
1677
- if "id" not in definition:
1678
- definition["id"] = provider_id
1679
- provider = g_providers.get(provider_id)
1680
- constructor_kwargs = create_provider_kwargs(definition, provider)
1681
- provider = create_provider(constructor_kwargs)
1682
- return provider, constructor_kwargs
1683
-
1684
-
1685
1451
  def create_provider_kwargs(definition, provider=None):
1686
1452
  if provider:
1687
1453
  provider = provider.copy()
@@ -1693,11 +1459,11 @@ def create_provider_kwargs(definition, provider=None):
1693
1459
  if "api_key" in provider:
1694
1460
  value = provider["api_key"]
1695
1461
  if isinstance(value, str) and value.startswith("$"):
1696
- provider["api_key"] = os.getenv(value[1:], "")
1462
+ provider["api_key"] = os.environ.get(value[1:], "")
1697
1463
 
1698
1464
  if "api_key" not in provider and "env" in provider:
1699
1465
  for env_var in provider["env"]:
1700
- val = os.getenv(env_var)
1466
+ val = os.environ.get(env_var)
1701
1467
  if val:
1702
1468
  provider["api_key"] = val
1703
1469
  break
@@ -1709,15 +1475,6 @@ def create_provider_kwargs(definition, provider=None):
1709
1475
  if isinstance(value, (list, dict)):
1710
1476
  constructor_kwargs[key] = value.copy()
1711
1477
  constructor_kwargs["headers"] = g_config["defaults"]["headers"].copy()
1712
-
1713
- if "modalities" in definition:
1714
- constructor_kwargs["modalities"] = {}
1715
- for modality, modality_definition in definition["modalities"].items():
1716
- modality_provider = create_provider(modality_definition)
1717
- if not modality_provider:
1718
- return None
1719
- constructor_kwargs["modalities"][modality] = modality_provider
1720
-
1721
1478
  return constructor_kwargs
1722
1479
 
1723
1480
 
@@ -1730,11 +1487,9 @@ def create_provider(provider):
1730
1487
  _log(f"Provider {provider_label} is missing 'npm' sdk")
1731
1488
  return None
1732
1489
 
1733
- for provider_type in g_app.all_providers:
1490
+ for provider_type in ALL_PROVIDERS:
1734
1491
  if provider_type.sdk == npm_sdk:
1735
1492
  kwargs = create_provider_kwargs(provider)
1736
- if kwargs is None:
1737
- kwargs = provider
1738
1493
  return provider_type(**kwargs)
1739
1494
 
1740
1495
  _log(f"Could not find provider {provider_label} with npm sdk {npm_sdk}")
@@ -1788,23 +1543,11 @@ async def update_providers(home_providers_path):
1788
1543
  global g_providers
1789
1544
  text = await get_text("https://models.dev/api.json")
1790
1545
  all_providers = json.loads(text)
1791
- extra_providers = {}
1792
- extra_providers_path = home_providers_path.replace("providers.json", "providers-extra.json")
1793
- if os.path.exists(extra_providers_path):
1794
- with open(extra_providers_path) as f:
1795
- extra_providers = json.load(f)
1796
1546
 
1797
1547
  filtered_providers = {}
1798
1548
  for id, provider in all_providers.items():
1799
1549
  if id in g_config["providers"]:
1800
1550
  filtered_providers[id] = provider
1801
- if id in extra_providers and "models" in extra_providers[id]:
1802
- for model_id, model in extra_providers[id]["models"].items():
1803
- if "id" not in model:
1804
- model["id"] = model_id
1805
- if "name" not in model:
1806
- model["name"] = id_to_name(model["id"])
1807
- filtered_providers[id]["models"][model_id] = model
1808
1551
 
1809
1552
  os.makedirs(os.path.dirname(home_providers_path), exist_ok=True)
1810
1553
  with open(home_providers_path, "w", encoding="utf-8") as f:
@@ -1834,11 +1577,11 @@ def print_status():
1834
1577
 
1835
1578
 
1836
1579
  def home_llms_path(filename):
1837
- return f"{os.getenv('HOME')}/.llms/{filename}"
1580
+ return f"{os.environ.get('HOME')}/.llms/{filename}"
1838
1581
 
1839
1582
 
1840
- def get_cache_path(path=""):
1841
- return home_llms_path(f"cache/{path}") if path else home_llms_path("cache")
1583
+ def get_cache_path(filename):
1584
+ return home_llms_path(f"cache/{filename}")
1842
1585
 
1843
1586
 
1844
1587
  def get_config_path():
@@ -1847,8 +1590,8 @@ def get_config_path():
1847
1590
  "./llms.json",
1848
1591
  home_config_path,
1849
1592
  ]
1850
- if os.getenv("LLMS_CONFIG_PATH"):
1851
- check_paths.insert(0, os.getenv("LLMS_CONFIG_PATH"))
1593
+ if os.environ.get("LLMS_CONFIG_PATH"):
1594
+ check_paths.insert(0, os.environ.get("LLMS_CONFIG_PATH"))
1852
1595
 
1853
1596
  for check_path in check_paths:
1854
1597
  g_config_path = os.path.normpath(os.path.join(os.path.dirname(__file__), check_path))
@@ -1857,18 +1600,26 @@ def get_config_path():
1857
1600
  return None
1858
1601
 
1859
1602
 
1603
+ def get_ui_path():
1604
+ ui_paths = [home_llms_path("ui.json"), "ui.json"]
1605
+ for ui_path in ui_paths:
1606
+ if os.path.exists(ui_path):
1607
+ return ui_path
1608
+ return None
1609
+
1610
+
1860
1611
  def enable_provider(provider):
1861
1612
  msg = None
1862
1613
  provider_config = g_config["providers"][provider]
1863
- if not provider_config:
1864
- return None, f"Provider {provider} not found"
1865
-
1866
- provider, constructor_kwargs = create_provider_from_definition(provider, provider_config)
1867
- msg = provider.validate(**constructor_kwargs)
1868
- if msg:
1869
- return None, msg
1870
-
1871
1614
  provider_config["enabled"] = True
1615
+ if "api_key" in provider_config:
1616
+ api_key = provider_config["api_key"]
1617
+ if isinstance(api_key, str):
1618
+ if api_key.startswith("$"):
1619
+ if not os.environ.get(api_key[1:], ""):
1620
+ msg = f"WARNING: {provider} requires missing API Key in Environment Variable {api_key}"
1621
+ else:
1622
+ msg = f"WARNING: {provider} is not configured with an API Key"
1872
1623
  save_config(g_config)
1873
1624
  init_llms(g_config, g_providers)
1874
1625
  return provider_config, msg
@@ -2193,14 +1944,9 @@ async def text_from_resource_or_url(filename):
2193
1944
 
2194
1945
  async def save_home_configs():
2195
1946
  home_config_path = home_llms_path("llms.json")
1947
+ home_ui_path = home_llms_path("ui.json")
2196
1948
  home_providers_path = home_llms_path("providers.json")
2197
- home_providers_extra_path = home_llms_path("providers-extra.json")
2198
-
2199
- if (
2200
- os.path.exists(home_config_path)
2201
- and os.path.exists(home_providers_path)
2202
- and os.path.exists(home_providers_extra_path)
2203
- ):
1949
+ if os.path.exists(home_config_path) and os.path.exists(home_ui_path) and os.path.exists(home_providers_path):
2204
1950
  return
2205
1951
 
2206
1952
  llms_home = os.path.dirname(home_config_path)
@@ -2212,17 +1958,17 @@ async def save_home_configs():
2212
1958
  f.write(config_json)
2213
1959
  _log(f"Created default config at {home_config_path}")
2214
1960
 
1961
+ if not os.path.exists(home_ui_path):
1962
+ ui_json = await text_from_resource_or_url("ui.json")
1963
+ with open(home_ui_path, "w", encoding="utf-8") as f:
1964
+ f.write(ui_json)
1965
+ _log(f"Created default ui config at {home_ui_path}")
1966
+
2215
1967
  if not os.path.exists(home_providers_path):
2216
1968
  providers_json = await text_from_resource_or_url("providers.json")
2217
1969
  with open(home_providers_path, "w", encoding="utf-8") as f:
2218
1970
  f.write(providers_json)
2219
1971
  _log(f"Created default providers config at {home_providers_path}")
2220
-
2221
- if not os.path.exists(home_providers_extra_path):
2222
- extra_json = await text_from_resource_or_url("providers-extra.json")
2223
- with open(home_providers_extra_path, "w", encoding="utf-8") as f:
2224
- f.write(extra_json)
2225
- _log(f"Created default extra providers config at {home_providers_extra_path}")
2226
1972
  except Exception:
2227
1973
  print("Could not create llms.json. Create one with --init or use --config <path>")
2228
1974
  exit(1)
@@ -2259,586 +2005,62 @@ async def reload_providers():
2259
2005
  return g_handlers
2260
2006
 
2261
2007
 
2262
- async def watch_config_files(config_path, providers_path, interval=1):
2008
+ async def watch_config_files(config_path, ui_path, interval=1):
2263
2009
  """Watch config files and reload providers when they change"""
2264
2010
  global g_config
2265
2011
 
2266
2012
  config_path = Path(config_path)
2267
- providers_path = Path(providers_path)
2268
-
2269
- _log(f"Watching config file: {config_path}")
2270
- _log(f"Watching providers file: {providers_path}")
2013
+ ui_path = Path(ui_path) if ui_path else None
2271
2014
 
2272
- def get_latest_mtime():
2273
- ret = 0
2274
- name = "llms.json"
2275
- if config_path.is_file():
2276
- ret = config_path.stat().st_mtime
2277
- name = config_path.name
2278
- if providers_path.is_file() and providers_path.stat().st_mtime > ret:
2279
- ret = providers_path.stat().st_mtime
2280
- name = providers_path.name
2281
- return ret, name
2015
+ file_mtimes = {}
2282
2016
 
2283
- latest_mtime, name = get_latest_mtime()
2017
+ _log(f"Watching config files: {config_path}" + (f", {ui_path}" if ui_path else ""))
2284
2018
 
2285
2019
  while True:
2286
2020
  await asyncio.sleep(interval)
2287
2021
 
2288
2022
  # Check llms.json
2289
2023
  try:
2290
- new_mtime, name = get_latest_mtime()
2291
- if new_mtime > latest_mtime:
2292
- _log(f"Config file changed: {name}")
2293
- latest_mtime = new_mtime
2024
+ if config_path.is_file():
2025
+ mtime = config_path.stat().st_mtime
2294
2026
 
2295
- try:
2296
- # Reload llms.json
2297
- with open(config_path) as f:
2298
- g_config = json.load(f)
2027
+ if str(config_path) not in file_mtimes:
2028
+ file_mtimes[str(config_path)] = mtime
2029
+ elif file_mtimes[str(config_path)] != mtime:
2030
+ _log(f"Config file changed: {config_path.name}")
2031
+ file_mtimes[str(config_path)] = mtime
2299
2032
 
2300
- # Reload providers
2301
- await reload_providers()
2302
- _log("Providers reloaded successfully")
2303
- except Exception as e:
2304
- _log(f"Error reloading config: {e}")
2033
+ try:
2034
+ # Reload llms.json
2035
+ with open(config_path) as f:
2036
+ g_config = json.load(f)
2037
+
2038
+ # Reload providers
2039
+ await reload_providers()
2040
+ _log("Providers reloaded successfully")
2041
+ except Exception as e:
2042
+ _log(f"Error reloading config: {e}")
2305
2043
  except FileNotFoundError:
2306
2044
  pass
2307
2045
 
2308
-
2309
- def get_session_token(request):
2310
- return request.query.get("session") or request.headers.get("X-Session-Token") or request.cookies.get("llms-token")
2311
-
2312
-
2313
- class AppExtensions:
2314
- """
2315
- APIs extensions can use to extend the app
2316
- """
2317
-
2318
- def __init__(self, cli_args, extra_args):
2319
- self.cli_args = cli_args
2320
- self.extra_args = extra_args
2321
- self.config = None
2322
- self.error_auth_required = create_error_response("Authentication required", "Unauthorized")
2323
- self.ui_extensions = []
2324
- self.chat_request_filters = []
2325
- self.chat_tool_filters = []
2326
- self.chat_response_filters = []
2327
- self.chat_error_filters = []
2328
- self.server_add_get = []
2329
- self.server_add_post = []
2330
- self.server_add_put = []
2331
- self.server_add_delete = []
2332
- self.server_add_patch = []
2333
- self.cache_saved_filters = []
2334
- self.shutdown_handlers = []
2335
- self.tools = {}
2336
- self.tool_definitions = []
2337
- self.index_headers = []
2338
- self.index_footers = []
2339
- self.request_args = {
2340
- "image_config": dict, # e.g. { "aspect_ratio": "1:1" }
2341
- "temperature": float, # e.g: 0.7
2342
- "max_completion_tokens": int, # e.g: 2048
2343
- "seed": int, # e.g: 42
2344
- "top_p": float, # e.g: 0.9
2345
- "frequency_penalty": float, # e.g: 0.5
2346
- "presence_penalty": float, # e.g: 0.5
2347
- "stop": list, # e.g: ["Stop"]
2348
- "reasoning_effort": str, # e.g: minimal, low, medium, high
2349
- "verbosity": str, # e.g: low, medium, high
2350
- "service_tier": str, # e.g: auto, default
2351
- "top_logprobs": int,
2352
- "safety_identifier": str,
2353
- "store": bool,
2354
- "enable_thinking": bool,
2355
- }
2356
- self.all_providers = [
2357
- OpenAiCompatible,
2358
- MistralProvider,
2359
- GroqProvider,
2360
- XaiProvider,
2361
- CodestralProvider,
2362
- OllamaProvider,
2363
- LMStudioProvider,
2364
- ]
2365
- self.aspect_ratios = {
2366
- "1:1": "1024×1024",
2367
- "2:3": "832×1248",
2368
- "3:2": "1248×832",
2369
- "3:4": "864×1184",
2370
- "4:3": "1184×864",
2371
- "4:5": "896×1152",
2372
- "5:4": "1152×896",
2373
- "9:16": "768×1344",
2374
- "16:9": "1344×768",
2375
- "21:9": "1536×672",
2376
- }
2377
- self.import_maps = {
2378
- "vue-prod": "/ui/lib/vue.min.mjs",
2379
- "vue": "/ui/lib/vue.mjs",
2380
- "vue-router": "/ui/lib/vue-router.min.mjs",
2381
- "@servicestack/client": "/ui/lib/servicestack-client.mjs",
2382
- "@servicestack/vue": "/ui/lib/servicestack-vue.mjs",
2383
- "idb": "/ui/lib/idb.min.mjs",
2384
- "marked": "/ui/lib/marked.min.mjs",
2385
- "highlight.js": "/ui/lib/highlight.min.mjs",
2386
- "chart.js": "/ui/lib/chart.js",
2387
- "color.js": "/ui/lib/color.js",
2388
- "ctx.mjs": "/ui/ctx.mjs",
2389
- }
2390
-
2391
- def set_config(self, config):
2392
- self.config = config
2393
- self.auth_enabled = self.config.get("auth", {}).get("enabled", False)
2394
-
2395
- # Authentication middleware helper
2396
- def check_auth(self, request):
2397
- """Check if request is authenticated. Returns (is_authenticated, user_data)"""
2398
- if not self.auth_enabled:
2399
- return True, None
2400
-
2401
- # Check for OAuth session token
2402
- session_token = get_session_token(request)
2403
- if session_token and session_token in g_sessions:
2404
- return True, g_sessions[session_token]
2405
-
2406
- # Check for API key
2407
- auth_header = request.headers.get("Authorization", "")
2408
- if auth_header.startswith("Bearer "):
2409
- api_key = auth_header[7:]
2410
- if api_key:
2411
- return True, {"authProvider": "apikey"}
2412
-
2413
- return False, None
2414
-
2415
- def get_session(self, request):
2416
- session_token = get_session_token(request)
2417
-
2418
- if not session_token or session_token not in g_sessions:
2419
- return None
2420
-
2421
- session_data = g_sessions[session_token]
2422
- return session_data
2423
-
2424
- def get_username(self, request):
2425
- session = self.get_session(request)
2426
- if session:
2427
- return session.get("userName")
2428
- return None
2429
-
2430
- def get_user_path(self, username=None):
2431
- if username:
2432
- return home_llms_path(os.path.join("user", username))
2433
- return home_llms_path(os.path.join("user", "default"))
2434
-
2435
- def chat_request(self, template=None, text=None, model=None, system_prompt=None):
2436
- return g_chat_request(template=template, text=text, model=model, system_prompt=system_prompt)
2437
-
2438
- async def chat_completion(self, chat, context=None):
2439
- response = await g_chat_completion(chat, context)
2440
- return response
2441
-
2442
- def on_cache_saved_filters(self, context):
2443
- # _log(f"on_cache_saved_filters {len(self.cache_saved_filters)}: {context['url']}")
2444
- for filter_func in self.cache_saved_filters:
2445
- filter_func(context)
2446
-
2447
- async def on_chat_error(self, e, context):
2448
- # Apply chat error filters
2449
- if "stackTrace" not in context:
2450
- context["stackTrace"] = traceback.format_exc()
2451
- for filter_func in self.chat_error_filters:
2452
- try:
2453
- await filter_func(e, context)
2454
- except Exception as e:
2455
- _err("chat error filter failed", e)
2456
-
2457
- async def on_chat_tool(self, chat, context):
2458
- m_len = len(chat.get("messages", []))
2459
- t_len = len(self.chat_tool_filters)
2460
- _dbg(
2461
- f"on_tool_call for thread {context.get('threadId', None)} with {m_len} {pluralize('message', m_len)}, invoking {t_len} {pluralize('filter', t_len)}:"
2462
- )
2463
- for filter_func in self.chat_tool_filters:
2464
- await filter_func(chat, context)
2465
-
2466
- def exit(self, exit_code=0):
2467
- if len(self.shutdown_handlers) > 0:
2468
- _dbg(f"running {len(self.shutdown_handlers)} shutdown handlers...")
2469
- for handler in self.shutdown_handlers:
2470
- handler()
2471
-
2472
- _dbg(f"exit({exit_code})")
2473
- sys.exit(exit_code)
2474
-
2475
-
2476
- def handler_name(handler):
2477
- if hasattr(handler, "__name__"):
2478
- return handler.__name__
2479
- return "unknown"
2480
-
2481
-
2482
- class ExtensionContext:
2483
- def __init__(self, app, path):
2484
- self.app = app
2485
- self.cli_args = app.cli_args
2486
- self.extra_args = app.extra_args
2487
- self.error_auth_required = app.error_auth_required
2488
- self.path = path
2489
- self.name = os.path.basename(path)
2490
- if self.name.endswith(".py"):
2491
- self.name = self.name[:-3]
2492
- self.ext_prefix = f"/ext/{self.name}"
2493
- self.MOCK = MOCK
2494
- self.MOCK_DIR = MOCK_DIR
2495
- self.debug = DEBUG
2496
- self.verbose = g_verbose
2497
- self.aspect_ratios = app.aspect_ratios
2498
- self.request_args = app.request_args
2499
-
2500
- def chat_to_prompt(self, chat):
2501
- return chat_to_prompt(chat)
2502
-
2503
- def chat_to_system_prompt(self, chat):
2504
- return chat_to_system_prompt(chat)
2505
-
2506
- def chat_response_to_message(self, response):
2507
- return chat_response_to_message(response)
2508
-
2509
- def last_user_prompt(self, chat):
2510
- return last_user_prompt(chat)
2511
-
2512
- def to_file_info(self, chat, info=None, response=None):
2513
- return to_file_info(chat, info=info, response=response)
2514
-
2515
- def save_image_to_cache(self, base64_data, filename, image_info):
2516
- return save_image_to_cache(base64_data, filename, image_info)
2517
-
2518
- def save_bytes_to_cache(self, bytes_data, filename, file_info):
2519
- return save_bytes_to_cache(bytes_data, filename, file_info)
2520
-
2521
- def text_from_file(self, path):
2522
- return text_from_file(path)
2523
-
2524
- def log(self, message):
2525
- if self.verbose:
2526
- print(f"[{self.name}] {message}", flush=True)
2527
- return message
2528
-
2529
- def log_json(self, obj):
2530
- if self.verbose:
2531
- print(f"[{self.name}] {json.dumps(obj, indent=2)}", flush=True)
2532
- return obj
2533
-
2534
- def dbg(self, message):
2535
- if self.debug:
2536
- print(f"DEBUG [{self.name}]: {message}", flush=True)
2537
-
2538
- def err(self, message, e):
2539
- print(f"ERROR [{self.name}]: {message}", e)
2540
- if self.verbose:
2541
- print(traceback.format_exc(), flush=True)
2542
-
2543
- def error_message(self, e):
2544
- return to_error_message(e)
2545
-
2546
- def error_response(self, e, stacktrace=False):
2547
- return to_error_response(e, stacktrace=stacktrace)
2548
-
2549
- def add_provider(self, provider):
2550
- self.log(f"Registered provider: {provider.__name__}")
2551
- self.app.all_providers.append(provider)
2552
-
2553
- def register_ui_extension(self, index):
2554
- path = os.path.join(self.ext_prefix, index)
2555
- self.log(f"Registered UI extension: {path}")
2556
- self.app.ui_extensions.append({"id": self.name, "path": path})
2557
-
2558
- def register_chat_request_filter(self, handler):
2559
- self.log(f"Registered chat request filter: {handler_name(handler)}")
2560
- self.app.chat_request_filters.append(handler)
2561
-
2562
- def register_chat_tool_filter(self, handler):
2563
- self.log(f"Registered chat tool filter: {handler_name(handler)}")
2564
- self.app.chat_tool_filters.append(handler)
2565
-
2566
- def register_chat_response_filter(self, handler):
2567
- self.log(f"Registered chat response filter: {handler_name(handler)}")
2568
- self.app.chat_response_filters.append(handler)
2569
-
2570
- def register_chat_error_filter(self, handler):
2571
- self.log(f"Registered chat error filter: {handler_name(handler)}")
2572
- self.app.chat_error_filters.append(handler)
2573
-
2574
- def register_cache_saved_filter(self, handler):
2575
- self.log(f"Registered cache saved filter: {handler_name(handler)}")
2576
- self.app.cache_saved_filters.append(handler)
2577
-
2578
- def register_shutdown_handler(self, handler):
2579
- self.log(f"Registered shutdown handler: {handler_name(handler)}")
2580
- self.app.shutdown_handlers.append(handler)
2581
-
2582
- def add_static_files(self, ext_dir):
2583
- self.log(f"Registered static files: {ext_dir}")
2584
-
2585
- async def serve_static(request):
2586
- path = request.match_info["path"]
2587
- file_path = os.path.join(ext_dir, path)
2588
- if os.path.exists(file_path):
2589
- return web.FileResponse(file_path)
2590
- return web.Response(status=404)
2591
-
2592
- self.app.server_add_get.append((os.path.join(self.ext_prefix, "{path:.*}"), serve_static, {}))
2593
-
2594
- def add_get(self, path, handler, **kwargs):
2595
- self.dbg(f"Registered GET: {os.path.join(self.ext_prefix, path)}")
2596
- self.app.server_add_get.append((os.path.join(self.ext_prefix, path), handler, kwargs))
2597
-
2598
- def add_post(self, path, handler, **kwargs):
2599
- self.dbg(f"Registered POST: {os.path.join(self.ext_prefix, path)}")
2600
- self.app.server_add_post.append((os.path.join(self.ext_prefix, path), handler, kwargs))
2601
-
2602
- def add_put(self, path, handler, **kwargs):
2603
- self.dbg(f"Registered PUT: {os.path.join(self.ext_prefix, path)}")
2604
- self.app.server_add_put.append((os.path.join(self.ext_prefix, path), handler, kwargs))
2605
-
2606
- def add_delete(self, path, handler, **kwargs):
2607
- self.dbg(f"Registered DELETE: {os.path.join(self.ext_prefix, path)}")
2608
- self.app.server_add_delete.append((os.path.join(self.ext_prefix, path), handler, kwargs))
2609
-
2610
- def add_patch(self, path, handler, **kwargs):
2611
- self.dbg(f"Registered PATCH: {os.path.join(self.ext_prefix, path)}")
2612
- self.app.server_add_patch.append((os.path.join(self.ext_prefix, path), handler, kwargs))
2613
-
2614
- def add_importmaps(self, dict):
2615
- self.app.import_maps.update(dict)
2616
-
2617
- def add_index_header(self, html):
2618
- self.app.index_headers.append(html)
2619
-
2620
- def add_index_footer(self, html):
2621
- self.app.index_footers.append(html)
2622
-
2623
- def get_config(self):
2624
- return g_config
2625
-
2626
- def get_cache_path(self, path=""):
2627
- return get_cache_path(path)
2628
-
2629
- def chat_request(self, template=None, text=None, model=None, system_prompt=None):
2630
- return self.app.chat_request(template=template, text=text, model=model, system_prompt=system_prompt)
2631
-
2632
- def chat_completion(self, chat, context=None):
2633
- return self.app.chat_completion(chat, context=context)
2634
-
2635
- def get_providers(self):
2636
- return g_handlers
2637
-
2638
- def get_provider(self, name):
2639
- return g_handlers.get(name)
2640
-
2641
- def register_tool(self, func, tool_def=None):
2642
- if tool_def is None:
2643
- tool_def = function_to_tool_definition(func)
2644
-
2645
- name = tool_def["function"]["name"]
2646
- self.log(f"Registered tool: {name}")
2647
- self.app.tools[name] = func
2648
- self.app.tool_definitions.append(tool_def)
2649
-
2650
- def check_auth(self, request):
2651
- return self.app.check_auth(request)
2652
-
2653
- def get_session(self, request):
2654
- return self.app.get_session(request)
2655
-
2656
- def get_username(self, request):
2657
- return self.app.get_username(request)
2658
-
2659
- def get_user_path(self, username=None):
2660
- return self.app.get_user_path(username)
2661
-
2662
- def should_cancel_thread(self, context):
2663
- return should_cancel_thread(context)
2664
-
2665
- def cache_message_inline_data(self, message):
2666
- return cache_message_inline_data(message)
2667
-
2668
- def to_content(self, result):
2669
- return to_content(result)
2670
-
2671
-
2672
- def get_extensions_path():
2673
- return os.getenv("LLMS_EXTENSIONS_DIR", os.path.join(Path.home(), ".llms", "extensions"))
2674
-
2675
-
2676
- def get_disabled_extensions():
2677
- ret = DISABLE_EXTENSIONS.copy()
2678
- if g_config:
2679
- for ext in g_config.get("disable_extensions", []):
2680
- if ext not in ret:
2681
- ret.append(ext)
2682
- return ret
2683
-
2684
-
2685
- def get_extensions_dirs():
2686
- """
2687
- Returns a list of extension directories.
2688
- """
2689
- extensions_path = get_extensions_path()
2690
- os.makedirs(extensions_path, exist_ok=True)
2691
-
2692
- # allow overriding builtin extensions
2693
- override_extensions = []
2694
- if os.path.exists(extensions_path):
2695
- override_extensions = os.listdir(extensions_path)
2696
-
2697
- ret = []
2698
- disabled_extensions = get_disabled_extensions()
2699
-
2700
- builtin_extensions_dir = _ROOT / "extensions"
2701
- if os.path.exists(builtin_extensions_dir):
2702
- for item in os.listdir(builtin_extensions_dir):
2703
- if os.path.isdir(os.path.join(builtin_extensions_dir, item)):
2704
- if item in override_extensions:
2705
- continue
2706
- if item in disabled_extensions:
2707
- continue
2708
- ret.append(os.path.join(builtin_extensions_dir, item))
2709
-
2710
- if os.path.exists(extensions_path):
2711
- for item in os.listdir(extensions_path):
2712
- if os.path.isdir(os.path.join(extensions_path, item)):
2713
- if item in disabled_extensions:
2714
- continue
2715
- ret.append(os.path.join(extensions_path, item))
2716
-
2717
- return ret
2718
-
2719
-
2720
- def init_extensions(parser):
2721
- """
2722
- Initializes extensions by loading their __init__.py files and calling the __parser__ function if it exists.
2723
- """
2724
- for item_path in get_extensions_dirs():
2725
- item = os.path.basename(item_path)
2726
-
2727
- if os.path.isdir(item_path):
2046
+ # Check ui.json
2047
+ if ui_path:
2728
2048
  try:
2729
- # check for __parser__ function if exists in __init.__.py and call it with parser
2730
- init_file = os.path.join(item_path, "__init__.py")
2731
- if os.path.exists(init_file):
2732
- spec = importlib.util.spec_from_file_location(item, init_file)
2733
- if spec and spec.loader:
2734
- module = importlib.util.module_from_spec(spec)
2735
- sys.modules[item] = module
2736
- spec.loader.exec_module(module)
2737
-
2738
- parser_func = getattr(module, "__parser__", None)
2739
- if callable(parser_func):
2740
- parser_func(parser)
2741
- _log(f"Extension {item} parser loaded")
2742
- except Exception as e:
2743
- _err(f"Failed to load extension {item} parser", e)
2049
+ if ui_path.is_file():
2050
+ mtime = ui_path.stat().st_mtime
2744
2051
 
2745
-
2746
- def install_extensions():
2747
- """
2748
- Scans ensure ~/.llms/extensions/ for directories with __init__.py and loads them as extensions.
2749
- Calls the `__install__(ctx)` function in the extension module.
2750
- """
2751
-
2752
- extension_dirs = get_extensions_dirs()
2753
- ext_count = len(list(extension_dirs))
2754
- if ext_count == 0:
2755
- _log("No extensions found")
2756
- return
2757
-
2758
- disabled_extensions = get_disabled_extensions()
2759
- if len(disabled_extensions) > 0:
2760
- _log(f"Disabled extensions: {', '.join(disabled_extensions)}")
2761
-
2762
- _log(f"Installing {ext_count} extension{'' if ext_count == 1 else 's'}...")
2763
-
2764
- for item_path in extension_dirs:
2765
- item = os.path.basename(item_path)
2766
-
2767
- if os.path.isdir(item_path):
2768
- sys.path.append(item_path)
2769
- try:
2770
- ctx = ExtensionContext(g_app, item_path)
2771
- init_file = os.path.join(item_path, "__init__.py")
2772
- if os.path.exists(init_file):
2773
- spec = importlib.util.spec_from_file_location(item, init_file)
2774
- if spec and spec.loader:
2775
- module = importlib.util.module_from_spec(spec)
2776
- sys.modules[item] = module
2777
- spec.loader.exec_module(module)
2778
-
2779
- install_func = getattr(module, "__install__", None)
2780
- if callable(install_func):
2781
- install_func(ctx)
2782
- _log(f"Extension {item} installed")
2783
- else:
2784
- _dbg(f"Extension {item} has no __install__ function")
2785
- else:
2786
- _dbg(f"Extension {item} has no __init__.py")
2787
- else:
2788
- _dbg(f"Extension {init_file} not found")
2789
-
2790
- # if ui folder exists, serve as static files at /ext/{item}/
2791
- ui_path = os.path.join(item_path, "ui")
2792
- if os.path.exists(ui_path):
2793
- ctx.add_static_files(ui_path)
2794
-
2795
- # Register UI extension if index.mjs exists (/ext/{item}/index.mjs)
2796
- if os.path.exists(os.path.join(ui_path, "index.mjs")):
2797
- ctx.register_ui_extension("index.mjs")
2798
-
2799
- except Exception as e:
2800
- _err(f"Failed to install extension {item}", e)
2801
- else:
2802
- _dbg(f"Extension {item} not found: {item_path} is not a directory {os.path.exists(item_path)}")
2803
-
2804
-
2805
- def run_extension_cli():
2806
- """
2807
- Run the CLI for an extension.
2808
- """
2809
- for item_path in get_extensions_dirs():
2810
- item = os.path.basename(item_path)
2811
-
2812
- if os.path.isdir(item_path):
2813
- init_file = os.path.join(item_path, "__init__.py")
2814
- if os.path.exists(init_file):
2815
- ctx = ExtensionContext(g_app, item_path)
2816
- try:
2817
- spec = importlib.util.spec_from_file_location(item, init_file)
2818
- if spec and spec.loader:
2819
- module = importlib.util.module_from_spec(spec)
2820
- sys.modules[item] = module
2821
- spec.loader.exec_module(module)
2822
-
2823
- # Check for __run__ function if exists in __init__.py and call it with ctx
2824
- run_func = getattr(module, "__run__", None)
2825
- if callable(run_func):
2826
- _log(f"Running extension {item}...")
2827
- handled = run_func(ctx)
2828
- return handled
2829
-
2830
- except Exception as e:
2831
- _err(f"Failed to run extension {item}", e)
2832
- return False
2052
+ if str(ui_path) not in file_mtimes:
2053
+ file_mtimes[str(ui_path)] = mtime
2054
+ elif file_mtimes[str(ui_path)] != mtime:
2055
+ _log(f"Config file changed: {ui_path.name}")
2056
+ file_mtimes[str(ui_path)] = mtime
2057
+ _log("ui.json reloaded - reload page to update")
2058
+ except FileNotFoundError:
2059
+ pass
2833
2060
 
2834
2061
 
2835
2062
  def main():
2836
- global _ROOT, g_verbose, g_default_model, g_logprefix, g_providers, g_config, g_config_path, g_app
2837
-
2838
- _ROOT = os.getenv("LLMS_ROOT", resolve_root())
2839
- if not _ROOT:
2840
- print("Resource root not found")
2841
- exit(1)
2063
+ global _ROOT, g_verbose, g_default_model, g_logprefix, g_providers, g_config, g_config_path, g_ui_path
2842
2064
 
2843
2065
  parser = argparse.ArgumentParser(description=f"llms v{VERSION}")
2844
2066
  parser.add_argument("--config", default=None, help="Path to config file", metavar="FILE")
@@ -2849,13 +2071,9 @@ def main():
2849
2071
  parser.add_argument(
2850
2072
  "-s", "--system", default=None, help="System prompt to use for chat completion", metavar="PROMPT"
2851
2073
  )
2852
- parser.add_argument(
2853
- "--tools", default=None, help="Tools to use for chat completion (all|none|<tool>,<tool>...)", metavar="TOOLS"
2854
- )
2855
2074
  parser.add_argument("--image", default=None, help="Image input to use in chat completion")
2856
2075
  parser.add_argument("--audio", default=None, help="Audio input to use in chat completion")
2857
2076
  parser.add_argument("--file", default=None, help="File input to use in chat completion")
2858
- parser.add_argument("--out", default=None, help="Image or Video Generation Request", metavar="MODALITY")
2859
2077
  parser.add_argument(
2860
2078
  "--args",
2861
2079
  default=None,
@@ -2878,46 +2096,16 @@ def main():
2878
2096
  parser.add_argument("--default", default=None, help="Configure the default model to use", metavar="MODEL")
2879
2097
 
2880
2098
  parser.add_argument("--init", action="store_true", help="Create a default llms.json")
2881
- parser.add_argument("--update-providers", action="store_true", help="Update local models.dev providers.json")
2099
+ parser.add_argument("--update", action="store_true", help="Update local models.dev providers.json")
2882
2100
 
2101
+ parser.add_argument("--root", default=None, help="Change root directory for UI files", metavar="PATH")
2883
2102
  parser.add_argument("--logprefix", default="", help="Prefix used in log messages", metavar="PREFIX")
2884
2103
  parser.add_argument("--verbose", action="store_true", help="Verbose output")
2885
2104
 
2886
- parser.add_argument(
2887
- "--add",
2888
- nargs="?",
2889
- const="ls",
2890
- default=None,
2891
- help="Install an extension (lists available extensions if no name provided)",
2892
- metavar="EXTENSION",
2893
- )
2894
- parser.add_argument(
2895
- "--remove",
2896
- nargs="?",
2897
- const="ls",
2898
- default=None,
2899
- help="Remove an extension (lists installed extensions if no name provided)",
2900
- metavar="EXTENSION",
2901
- )
2902
-
2903
- parser.add_argument(
2904
- "--update",
2905
- nargs="?",
2906
- const="ls",
2907
- default=None,
2908
- help="Update an extension (use 'all' to update all extensions)",
2909
- metavar="EXTENSION",
2910
- )
2911
-
2912
- # Load parser extensions, go through all extensions and load their parser arguments
2913
- init_extensions(parser)
2914
-
2915
2105
  cli_args, extra_args = parser.parse_known_args()
2916
2106
 
2917
- g_app = AppExtensions(cli_args, extra_args)
2918
-
2919
2107
  # Check for verbose mode from CLI argument or environment variables
2920
- verbose_env = os.getenv("VERBOSE", "").lower()
2108
+ verbose_env = os.environ.get("VERBOSE", "").lower()
2921
2109
  if cli_args.verbose or verbose_env in ("1", "true"):
2922
2110
  g_verbose = True
2923
2111
  # printdump(cli_args)
@@ -2926,9 +2114,14 @@ def main():
2926
2114
  if cli_args.logprefix:
2927
2115
  g_logprefix = cli_args.logprefix
2928
2116
 
2117
+ _ROOT = Path(cli_args.root) if cli_args.root else resolve_root()
2118
+ if not _ROOT:
2119
+ print("Resource root not found")
2120
+ exit(1)
2121
+
2929
2122
  home_config_path = home_llms_path("llms.json")
2123
+ home_ui_path = home_llms_path("ui.json")
2930
2124
  home_providers_path = home_llms_path("providers.json")
2931
- home_providers_extra_path = home_llms_path("providers-extra.json")
2932
2125
 
2933
2126
  if cli_args.init:
2934
2127
  if os.path.exists(home_config_path):
@@ -2937,17 +2130,17 @@ def main():
2937
2130
  asyncio.run(save_default_config(home_config_path))
2938
2131
  print(f"Created default config at {home_config_path}")
2939
2132
 
2133
+ if os.path.exists(home_ui_path):
2134
+ print(f"ui.json already exists at {home_ui_path}")
2135
+ else:
2136
+ asyncio.run(save_text_url(github_url("ui.json"), home_ui_path))
2137
+ print(f"Created default ui config at {home_ui_path}")
2138
+
2940
2139
  if os.path.exists(home_providers_path):
2941
2140
  print(f"providers.json already exists at {home_providers_path}")
2942
2141
  else:
2943
2142
  asyncio.run(save_text_url(github_url("providers.json"), home_providers_path))
2944
2143
  print(f"Created default providers config at {home_providers_path}")
2945
-
2946
- if os.path.exists(home_providers_extra_path):
2947
- print(f"providers-extra.json already exists at {home_providers_extra_path}")
2948
- else:
2949
- asyncio.run(save_text_url(github_url("providers-extra.json"), home_providers_extra_path))
2950
- print(f"Created default extra providers config at {home_providers_extra_path}")
2951
2144
  exit(0)
2952
2145
 
2953
2146
  if cli_args.providers:
@@ -2964,189 +2157,36 @@ def main():
2964
2157
  g_config = load_config_json(config_json)
2965
2158
 
2966
2159
  config_dir = os.path.dirname(g_config_path)
2160
+ # look for ui.json in same directory as config
2161
+ ui_path = os.path.join(config_dir, "ui.json")
2162
+ if os.path.exists(ui_path):
2163
+ g_ui_path = ui_path
2164
+ else:
2165
+ if not os.path.exists(home_ui_path):
2166
+ ui_json = text_from_resource("ui.json")
2167
+ with open(home_ui_path, "w", encoding="utf-8") as f:
2168
+ f.write(ui_json)
2169
+ _log(f"Created default ui config at {home_ui_path}")
2170
+ g_ui_path = home_ui_path
2967
2171
 
2968
2172
  if not g_providers and os.path.exists(os.path.join(config_dir, "providers.json")):
2969
2173
  g_providers = json.loads(text_from_file(os.path.join(config_dir, "providers.json")))
2970
2174
 
2971
2175
  else:
2972
- # ensure llms.json and providers.json exist in home directory
2176
+ # ensure llms.json and ui.json exist in home directory
2973
2177
  asyncio.run(save_home_configs())
2974
2178
  g_config_path = home_config_path
2179
+ g_ui_path = home_ui_path
2975
2180
  g_config = load_config_json(text_from_file(g_config_path))
2976
2181
 
2977
- g_app.set_config(g_config)
2978
-
2979
2182
  if not g_providers:
2980
2183
  g_providers = json.loads(text_from_file(home_providers_path))
2981
2184
 
2982
- if cli_args.update_providers:
2185
+ if cli_args.update:
2983
2186
  asyncio.run(update_providers(home_providers_path))
2984
2187
  print(f"Updated {home_providers_path}")
2985
2188
  exit(0)
2986
2189
 
2987
- # if home_providers_path is older than 1 day, update providers list
2988
- if (
2989
- os.path.exists(home_providers_path)
2990
- and (time.time() - os.path.getmtime(home_providers_path)) > 86400
2991
- and os.getenv("LLMS_DISABLE_UPDATE", "") != "1"
2992
- ):
2993
- try:
2994
- asyncio.run(update_providers(home_providers_path))
2995
- _log(f"Updated {home_providers_path}")
2996
- except Exception as e:
2997
- _err("Failed to update providers", e)
2998
-
2999
- if cli_args.add is not None:
3000
- if cli_args.add == "ls":
3001
-
3002
- async def list_extensions():
3003
- print("\nAvailable extensions:")
3004
- text = await get_text("https://api.github.com/orgs/llmspy/repos?per_page=100&sort=updated")
3005
- repos = json.loads(text)
3006
- max_name_length = 0
3007
- for repo in repos:
3008
- max_name_length = max(max_name_length, len(repo["name"]))
3009
-
3010
- for repo in repos:
3011
- print(f" {repo['name']:<{max_name_length + 2}} {repo['description']}")
3012
-
3013
- print("\nUsage:")
3014
- print(" llms --add <extension>")
3015
- print(" llms --add <github-user>/<repo>")
3016
-
3017
- asyncio.run(list_extensions())
3018
- exit(0)
3019
-
3020
- async def install_extension(name):
3021
- # Determine git URL and target directory name
3022
- if "/" in name:
3023
- git_url = f"https://github.com/{name}"
3024
- target_name = name.split("/")[-1]
3025
- else:
3026
- git_url = f"https://github.com/llmspy/{name}"
3027
- target_name = name
3028
-
3029
- # check extension is not already installed
3030
- extensions_path = get_extensions_path()
3031
- target_path = os.path.join(extensions_path, target_name)
3032
-
3033
- if os.path.exists(target_path):
3034
- print(f"Extension {target_name} is already installed at {target_path}")
3035
- return
3036
-
3037
- print(f"Installing extension: {name}")
3038
- print(f"Cloning from {git_url} to {target_path}...")
3039
-
3040
- try:
3041
- subprocess.run(["git", "clone", git_url, target_path], check=True)
3042
-
3043
- # Check for requirements.txt
3044
- requirements_path = os.path.join(target_path, "requirements.txt")
3045
- if os.path.exists(requirements_path):
3046
- print(f"Installing dependencies from {requirements_path}...")
3047
-
3048
- # Check if uv is installed
3049
- has_uv = False
3050
- try:
3051
- subprocess.run(
3052
- ["uv", "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
3053
- )
3054
- has_uv = True
3055
- except (subprocess.CalledProcessError, FileNotFoundError):
3056
- pass
3057
-
3058
- if has_uv:
3059
- subprocess.run(
3060
- ["uv", "pip", "install", "-p", sys.executable, "-r", "requirements.txt"],
3061
- cwd=target_path,
3062
- check=True,
3063
- )
3064
- else:
3065
- subprocess.run(
3066
- [sys.executable, "-m", "pip", "install", "-r", "requirements.txt"],
3067
- cwd=target_path,
3068
- check=True,
3069
- )
3070
- print("Dependencies installed successfully.")
3071
-
3072
- print(f"Extension {target_name} installed successfully.")
3073
-
3074
- except subprocess.CalledProcessError as e:
3075
- print(f"Failed to install extension: {e}")
3076
- # cleanup if clone failed but directory was created (unlikely with simple git clone but good practice)
3077
- if os.path.exists(target_path) and not os.listdir(target_path):
3078
- os.rmdir(target_path)
3079
-
3080
- asyncio.run(install_extension(cli_args.add))
3081
- exit(0)
3082
-
3083
- if cli_args.remove is not None:
3084
- if cli_args.remove == "ls":
3085
- # List installed extensions
3086
- extensions_path = get_extensions_path()
3087
- extensions = os.listdir(extensions_path)
3088
- if len(extensions) == 0:
3089
- print("No extensions installed.")
3090
- exit(0)
3091
- print("Installed extensions:")
3092
- for extension in extensions:
3093
- print(f" {extension}")
3094
- exit(0)
3095
- # Remove an extension
3096
- extension_name = cli_args.remove
3097
- extensions_path = get_extensions_path()
3098
- target_path = os.path.join(extensions_path, extension_name)
3099
-
3100
- if not os.path.exists(target_path):
3101
- print(f"Extension {extension_name} not found at {target_path}")
3102
- exit(1)
3103
-
3104
- print(f"Removing extension: {extension_name}...")
3105
- try:
3106
- shutil.rmtree(target_path)
3107
- print(f"Extension {extension_name} removed successfully.")
3108
- except Exception as e:
3109
- print(f"Failed to remove extension: {e}")
3110
- exit(1)
3111
-
3112
- exit(0)
3113
-
3114
- if cli_args.update:
3115
- if cli_args.update == "ls":
3116
- # List installed extensions
3117
- extensions_path = get_extensions_path()
3118
- extensions = os.listdir(extensions_path)
3119
- if len(extensions) == 0:
3120
- print("No extensions installed.")
3121
- exit(0)
3122
- print("Installed extensions:")
3123
- for extension in extensions:
3124
- print(f" {extension}")
3125
-
3126
- print("\nUsage:")
3127
- print(" llms --update <extension>")
3128
- print(" llms --update all")
3129
- exit(0)
3130
-
3131
- async def update_extensions(extension_name):
3132
- extensions_path = get_extensions_path()
3133
- for extension in os.listdir(extensions_path):
3134
- extension_path = os.path.join(extensions_path, extension)
3135
- if os.path.isdir(extension_path):
3136
- if extension_name != "all" and extension != extension_name:
3137
- continue
3138
- result = subprocess.run(["git", "pull"], cwd=extension_path, capture_output=True)
3139
- if result.returncode != 0:
3140
- print(f"Failed to update extension {extension}: {result.stderr.decode('utf-8')}")
3141
- continue
3142
- print(f"Updated extension {extension}")
3143
- _log(result.stdout.decode("utf-8"))
3144
-
3145
- asyncio.run(update_extensions(cli_args.update))
3146
- exit(0)
3147
-
3148
- install_extensions()
3149
-
3150
2190
  asyncio.run(reload_providers())
3151
2191
 
3152
2192
  # print names
@@ -3194,14 +2234,14 @@ def main():
3194
2234
  print(f"\n{model_count} models available from {provider_count} providers")
3195
2235
 
3196
2236
  print_status()
3197
- g_app.exit(0)
2237
+ exit(0)
3198
2238
 
3199
2239
  if cli_args.check is not None:
3200
2240
  # Check validity of models for a provider
3201
2241
  provider_name = cli_args.check
3202
2242
  model_names = extra_args if len(extra_args) > 0 else None
3203
2243
  asyncio.run(check_models(provider_name, model_names))
3204
- g_app.exit(0)
2244
+ exit(0)
3205
2245
 
3206
2246
  if cli_args.serve is not None:
3207
2247
  # Disable inactive providers and save to config before starting server
@@ -3221,6 +2261,10 @@ def main():
3221
2261
  # Start server
3222
2262
  port = int(cli_args.serve)
3223
2263
 
2264
+ if not os.path.exists(g_ui_path):
2265
+ print(f"UI not found at {g_ui_path}")
2266
+ exit(1)
2267
+
3224
2268
  # Validate auth configuration if enabled
3225
2269
  auth_enabled = g_config.get("auth", {}).get("enabled", False)
3226
2270
  if auth_enabled:
@@ -3230,19 +2274,11 @@ def main():
3230
2274
 
3231
2275
  # Expand environment variables
3232
2276
  if client_id.startswith("$"):
3233
- client_id = client_id[1:]
2277
+ client_id = os.environ.get(client_id[1:], "")
3234
2278
  if client_secret.startswith("$"):
3235
- client_secret = client_secret[1:]
3236
-
3237
- client_id = os.getenv(client_id, client_id)
3238
- client_secret = os.getenv(client_secret, client_secret)
2279
+ client_secret = os.environ.get(client_secret[1:], "")
3239
2280
 
3240
- if (
3241
- not client_id
3242
- or not client_secret
3243
- or client_id == "GITHUB_CLIENT_ID"
3244
- or client_secret == "GITHUB_CLIENT_SECRET"
3245
- ):
2281
+ if not client_id or not client_secret:
3246
2282
  print("ERROR: Authentication is enabled but GitHub OAuth is not properly configured.")
3247
2283
  print("Please set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET environment variables,")
3248
2284
  print("or disable authentication by setting 'auth.enabled' to false in llms.json")
@@ -3256,25 +2292,55 @@ def main():
3256
2292
  _log(f"client_max_size set to {client_max_size} bytes ({client_max_size / 1024 / 1024:.1f}MB)")
3257
2293
  app = web.Application(client_max_size=client_max_size)
3258
2294
 
2295
+ # Authentication middleware helper
2296
+ def check_auth(request):
2297
+ """Check if request is authenticated. Returns (is_authenticated, user_data)"""
2298
+ if not auth_enabled:
2299
+ return True, None
2300
+
2301
+ # Check for OAuth session token
2302
+ session_token = request.query.get("session") or request.headers.get("X-Session-Token")
2303
+ if session_token and session_token in g_sessions:
2304
+ return True, g_sessions[session_token]
2305
+
2306
+ # Check for API key
2307
+ auth_header = request.headers.get("Authorization", "")
2308
+ if auth_header.startswith("Bearer "):
2309
+ api_key = auth_header[7:]
2310
+ if api_key:
2311
+ return True, {"authProvider": "apikey"}
2312
+
2313
+ return False, None
2314
+
3259
2315
  async def chat_handler(request):
3260
2316
  # Check authentication if enabled
3261
- is_authenticated, user_data = g_app.check_auth(request)
2317
+ is_authenticated, user_data = check_auth(request)
3262
2318
  if not is_authenticated:
3263
- return web.json_response(g_app.error_auth_required, status=401)
2319
+ return web.json_response(
2320
+ {
2321
+ "error": {
2322
+ "message": "Authentication required",
2323
+ "type": "authentication_error",
2324
+ "code": "unauthorized",
2325
+ }
2326
+ },
2327
+ status=401,
2328
+ )
3264
2329
 
3265
2330
  try:
3266
2331
  chat = await request.json()
3267
- context = {"chat": chat, "request": request, "user": g_app.get_username(request)}
3268
- metadata = chat.get("metadata", {})
3269
- context["threadId"] = metadata.get("threadId", None)
3270
- context["tools"] = metadata.get("tools", "all")
3271
- response = await g_app.chat_completion(chat, context)
2332
+ response = await chat_completion(chat)
3272
2333
  return web.json_response(response)
3273
2334
  except Exception as e:
3274
- return web.json_response(to_error_response(e), status=500)
2335
+ return web.json_response({"error": str(e)}, status=500)
3275
2336
 
3276
2337
  app.router.add_post("/v1/chat/completions", chat_handler)
3277
2338
 
2339
+ async def models_handler(request):
2340
+ return web.json_response(get_models())
2341
+
2342
+ app.router.add_get("/models/list", models_handler)
2343
+
3278
2344
  async def active_models_handler(request):
3279
2345
  return web.json_response(get_active_models())
3280
2346
 
@@ -3304,9 +2370,8 @@ def main():
3304
2370
  if provider:
3305
2371
  if data.get("enable", False):
3306
2372
  provider_config, msg = enable_provider(provider)
3307
- _log(f"Enabled provider {provider} {msg}")
3308
- if not msg:
3309
- await load_llms()
2373
+ _log(f"Enabled provider {provider}")
2374
+ await load_llms()
3310
2375
  elif data.get("disable", False):
3311
2376
  disable_provider(provider)
3312
2377
  _log(f"Disabled provider {provider}")
@@ -3323,9 +2388,18 @@ def main():
3323
2388
 
3324
2389
  async def upload_handler(request):
3325
2390
  # Check authentication if enabled
3326
- is_authenticated, user_data = g_app.check_auth(request)
2391
+ is_authenticated, user_data = check_auth(request)
3327
2392
  if not is_authenticated:
3328
- return web.json_response(g_app.error_auth_required, status=401)
2393
+ return web.json_response(
2394
+ {
2395
+ "error": {
2396
+ "message": "Authentication required",
2397
+ "type": "authentication_error",
2398
+ "code": "unauthorized",
2399
+ }
2400
+ },
2401
+ status=401,
2402
+ )
3329
2403
 
3330
2404
  reader = await request.multipart()
3331
2405
 
@@ -3335,7 +2409,7 @@ def main():
3335
2409
  field = await reader.next()
3336
2410
 
3337
2411
  if not field:
3338
- return web.json_response(create_error_response("No file provided"), status=400)
2412
+ return web.json_response({"error": "No file provided"}, status=400)
3339
2413
 
3340
2414
  filename = field.filename or "file"
3341
2415
  content = await field.read()
@@ -3373,10 +2447,9 @@ def main():
3373
2447
  with open(full_path, "wb") as f:
3374
2448
  f.write(content)
3375
2449
 
3376
- url = f"/~cache/{relative_path}"
3377
2450
  response_data = {
3378
2451
  "date": int(time.time()),
3379
- "url": url,
2452
+ "url": f"/~cache/{relative_path}",
3380
2453
  "size": len(content),
3381
2454
  "type": mimetype,
3382
2455
  "name": filename,
@@ -3396,22 +2469,10 @@ def main():
3396
2469
  with open(info_path, "w") as f:
3397
2470
  json.dump(response_data, f)
3398
2471
 
3399
- g_app.on_cache_saved_filters({"url": url, "info": response_data})
3400
-
3401
2472
  return web.json_response(response_data)
3402
2473
 
3403
2474
  app.router.add_post("/upload", upload_handler)
3404
2475
 
3405
- async def extensions_handler(request):
3406
- return web.json_response(g_app.ui_extensions)
3407
-
3408
- app.router.add_get("/ext", extensions_handler)
3409
-
3410
- async def tools_handler(request):
3411
- return web.json_response(g_app.tool_definitions)
3412
-
3413
- app.router.add_get("/ext/tools", tools_handler)
3414
-
3415
2476
  async def cache_handler(request):
3416
2477
  path = request.match_info["tail"]
3417
2478
  full_path = get_cache_path(path)
@@ -3423,14 +2484,14 @@ def main():
3423
2484
 
3424
2485
  # Check for directory traversal for info path
3425
2486
  try:
3426
- cache_root = Path(get_cache_path())
2487
+ cache_root = Path(get_cache_path(""))
3427
2488
  requested_path = Path(info_path).resolve()
3428
2489
  if not str(requested_path).startswith(str(cache_root)):
3429
2490
  return web.Response(text="403: Forbidden", status=403)
3430
2491
  except Exception:
3431
2492
  return web.Response(text="403: Forbidden", status=403)
3432
2493
 
3433
- with open(info_path) as f:
2494
+ with open(info_path, "r") as f:
3434
2495
  content = f.read()
3435
2496
  return web.Response(text=content, content_type="application/json")
3436
2497
 
@@ -3439,7 +2500,7 @@ def main():
3439
2500
 
3440
2501
  # Check for directory traversal
3441
2502
  try:
3442
- cache_root = Path(get_cache_path())
2503
+ cache_root = Path(get_cache_path(""))
3443
2504
  requested_path = Path(full_path).resolve()
3444
2505
  if not str(requested_path).startswith(str(cache_root)):
3445
2506
  return web.Response(text="403: Forbidden", status=403)
@@ -3458,7 +2519,7 @@ def main():
3458
2519
  async def github_auth_handler(request):
3459
2520
  """Initiate GitHub OAuth flow"""
3460
2521
  if "auth" not in g_config or "github" not in g_config["auth"]:
3461
- return web.json_response(create_error_response("GitHub OAuth not configured"), status=500)
2522
+ return web.json_response({"error": "GitHub OAuth not configured"}, status=500)
3462
2523
 
3463
2524
  auth_config = g_config["auth"]["github"]
3464
2525
  client_id = auth_config.get("client_id", "")
@@ -3466,15 +2527,12 @@ def main():
3466
2527
 
3467
2528
  # Expand environment variables
3468
2529
  if client_id.startswith("$"):
3469
- client_id = client_id[1:]
2530
+ client_id = os.environ.get(client_id[1:], "")
3470
2531
  if redirect_uri.startswith("$"):
3471
- redirect_uri = redirect_uri[1:]
3472
-
3473
- client_id = os.getenv(client_id, client_id)
3474
- redirect_uri = os.getenv(redirect_uri, redirect_uri)
2532
+ redirect_uri = os.environ.get(redirect_uri[1:], "")
3475
2533
 
3476
2534
  if not client_id:
3477
- return web.json_response(create_error_response("GitHub client_id not configured"), status=500)
2535
+ return web.json_response({"error": "GitHub client_id not configured"}, status=500)
3478
2536
 
3479
2537
  # Generate CSRF state token
3480
2538
  state = secrets.token_urlsafe(32)
@@ -3504,9 +2562,7 @@ def main():
3504
2562
 
3505
2563
  # Expand environment variables
3506
2564
  if restrict_to.startswith("$"):
3507
- restrict_to = restrict_to[1:]
3508
-
3509
- restrict_to = os.getenv(restrict_to, None if restrict_to == "GITHUB_USERS" else restrict_to)
2565
+ restrict_to = os.environ.get(restrict_to[1:], "")
3510
2566
 
3511
2567
  # If restrict_to is configured, validate the user
3512
2568
  if restrict_to:
@@ -3527,14 +2583,6 @@ def main():
3527
2583
  code = request.query.get("code")
3528
2584
  state = request.query.get("state")
3529
2585
 
3530
- # Handle malformed URLs where query params are appended with & instead of ?
3531
- if not code and "tail" in request.match_info:
3532
- tail = request.match_info["tail"]
3533
- if tail.startswith("&"):
3534
- params = parse_qs(tail[1:])
3535
- code = params.get("code", [None])[0]
3536
- state = params.get("state", [None])[0]
3537
-
3538
2586
  if not code or not state:
3539
2587
  return web.Response(text="Missing code or state parameter", status=400)
3540
2588
 
@@ -3545,7 +2593,7 @@ def main():
3545
2593
  g_oauth_states.pop(state)
3546
2594
 
3547
2595
  if "auth" not in g_config or "github" not in g_config["auth"]:
3548
- return web.json_response(create_error_response("GitHub OAuth not configured"), status=500)
2596
+ return web.json_response({"error": "GitHub OAuth not configured"}, status=500)
3549
2597
 
3550
2598
  auth_config = g_config["auth"]["github"]
3551
2599
  client_id = auth_config.get("client_id", "")
@@ -3554,18 +2602,14 @@ def main():
3554
2602
 
3555
2603
  # Expand environment variables
3556
2604
  if client_id.startswith("$"):
3557
- client_id = client_id[1:]
2605
+ client_id = os.environ.get(client_id[1:], "")
3558
2606
  if client_secret.startswith("$"):
3559
- client_secret = client_secret[1:]
2607
+ client_secret = os.environ.get(client_secret[1:], "")
3560
2608
  if redirect_uri.startswith("$"):
3561
- redirect_uri = redirect_uri[1:]
3562
-
3563
- client_id = os.getenv(client_id, client_id)
3564
- client_secret = os.getenv(client_secret, client_secret)
3565
- redirect_uri = os.getenv(redirect_uri, redirect_uri)
2609
+ redirect_uri = os.environ.get(redirect_uri[1:], "")
3566
2610
 
3567
2611
  if not client_id or not client_secret:
3568
- return web.json_response(create_error_response("GitHub OAuth credentials not configured"), status=500)
2612
+ return web.json_response({"error": "GitHub OAuth credentials not configured"}, status=500)
3569
2613
 
3570
2614
  # Exchange code for access token
3571
2615
  async with aiohttp.ClientSession() as session:
@@ -3584,7 +2628,7 @@ def main():
3584
2628
 
3585
2629
  if not access_token:
3586
2630
  error = token_response.get("error_description", "Failed to get access token")
3587
- return web.json_response(create_error_response(f"OAuth error: {error}"), status=400)
2631
+ return web.Response(text=f"OAuth error: {error}", status=400)
3588
2632
 
3589
2633
  # Fetch user info
3590
2634
  user_url = "https://api.github.com/user"
@@ -3610,16 +2654,14 @@ def main():
3610
2654
  }
3611
2655
 
3612
2656
  # Redirect to UI with session token
3613
- response = web.HTTPFound(f"/?session={session_token}")
3614
- response.set_cookie("llms-token", session_token, httponly=True, path="/", max_age=86400)
3615
- return response
2657
+ return web.HTTPFound(f"/?session={session_token}")
3616
2658
 
3617
2659
  async def session_handler(request):
3618
2660
  """Validate and return session info"""
3619
- session_token = get_session_token(request)
2661
+ session_token = request.query.get("session") or request.headers.get("X-Session-Token")
3620
2662
 
3621
2663
  if not session_token or session_token not in g_sessions:
3622
- return web.json_response(create_error_response("Invalid or expired session"), status=401)
2664
+ return web.json_response({"error": "Invalid or expired session"}, status=401)
3623
2665
 
3624
2666
  session_data = g_sessions[session_token]
3625
2667
 
@@ -3633,19 +2675,17 @@ def main():
3633
2675
 
3634
2676
  async def logout_handler(request):
3635
2677
  """End OAuth session"""
3636
- session_token = get_session_token(request)
2678
+ session_token = request.query.get("session") or request.headers.get("X-Session-Token")
3637
2679
 
3638
2680
  if session_token and session_token in g_sessions:
3639
2681
  del g_sessions[session_token]
3640
2682
 
3641
- response = web.json_response({"success": True})
3642
- response.del_cookie("llms-token")
3643
- return response
2683
+ return web.json_response({"success": True})
3644
2684
 
3645
2685
  async def auth_handler(request):
3646
2686
  """Check authentication status and return user info"""
3647
2687
  # Check for OAuth session token
3648
- session_token = get_session_token(request)
2688
+ session_token = request.query.get("session") or request.headers.get("X-Session-Token")
3649
2689
 
3650
2690
  if session_token and session_token in g_sessions:
3651
2691
  session_data = g_sessions[session_token]
@@ -3675,12 +2715,13 @@ def main():
3675
2715
  # })
3676
2716
 
3677
2717
  # Not authenticated - return error in expected format
3678
- return web.json_response(g_app.error_auth_required, status=401)
2718
+ return web.json_response(
2719
+ {"responseStatus": {"errorCode": "Unauthorized", "message": "Not authenticated"}}, status=401
2720
+ )
3679
2721
 
3680
2722
  app.router.add_get("/auth", auth_handler)
3681
2723
  app.router.add_get("/auth/github", github_auth_handler)
3682
2724
  app.router.add_get("/auth/github/callback", github_callback_handler)
3683
- app.router.add_get("/auth/github/callback{tail:.*}", github_callback_handler)
3684
2725
  app.router.add_get("/auth/session", session_handler)
3685
2726
  app.router.add_post("/auth/logout", logout_handler)
3686
2727
 
@@ -3715,101 +2756,30 @@ def main():
3715
2756
 
3716
2757
  app.router.add_get("/ui/{path:.*}", ui_static, name="ui_static")
3717
2758
 
3718
- async def config_handler(request):
3719
- ret = {}
3720
- if "defaults" not in ret:
3721
- ret["defaults"] = g_config["defaults"]
3722
- enabled, disabled = provider_status()
3723
- ret["status"] = {"all": list(g_config["providers"].keys()), "enabled": enabled, "disabled": disabled}
3724
- # Add auth configuration
3725
- ret["requiresAuth"] = auth_enabled
3726
- ret["authType"] = "oauth" if auth_enabled else "apikey"
3727
- return web.json_response(ret)
2759
+ async def ui_config_handler(request):
2760
+ with open(g_ui_path, encoding="utf-8") as f:
2761
+ ui = json.load(f)
2762
+ if "defaults" not in ui:
2763
+ ui["defaults"] = g_config["defaults"]
2764
+ enabled, disabled = provider_status()
2765
+ ui["status"] = {"all": list(g_config["providers"].keys()), "enabled": enabled, "disabled": disabled}
2766
+ # Add auth configuration
2767
+ ui["requiresAuth"] = auth_enabled
2768
+ ui["authType"] = "oauth" if auth_enabled else "apikey"
2769
+ return web.json_response(ui)
3728
2770
 
3729
- app.router.add_get("/config", config_handler)
2771
+ app.router.add_get("/config", ui_config_handler)
3730
2772
 
3731
2773
  async def not_found_handler(request):
3732
2774
  return web.Response(text="404: Not Found", status=404)
3733
2775
 
3734
2776
  app.router.add_get("/favicon.ico", not_found_handler)
3735
2777
 
3736
- # go through and register all g_app extensions
3737
- for handler in g_app.server_add_get:
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_get(handler[0], managed_handler, **handler[2])
3747
- for handler in g_app.server_add_post:
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_post(handler[0], managed_handler, **handler[2])
3757
- for handler in g_app.server_add_put:
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_put(handler[0], managed_handler, **handler[2])
3767
- for handler in g_app.server_add_delete:
3768
- handler_fn = handler[1]
3769
-
3770
- async def managed_handler(request, handler_fn=handler_fn):
3771
- try:
3772
- return await handler_fn(request)
3773
- except Exception as e:
3774
- return web.json_response(to_error_response(e, stacktrace=g_verbose), status=500)
3775
-
3776
- app.router.add_delete(handler[0], managed_handler, **handler[2])
3777
- for handler in g_app.server_add_patch:
3778
- handler_fn = handler[1]
3779
-
3780
- async def managed_handler(request, handler_fn=handler_fn):
3781
- try:
3782
- return await handler_fn(request)
3783
- except Exception as e:
3784
- return web.json_response(to_error_response(e, stacktrace=g_verbose), status=500)
3785
-
3786
- app.router.add_patch(handler[0], managed_handler, **handler[2])
3787
-
3788
2778
  # Serve index.html from root
3789
2779
  async def index_handler(request):
3790
2780
  index_content = read_resource_file_bytes("index.html")
3791
-
3792
- importmaps = {"imports": g_app.import_maps}
3793
- importmaps_script = '<script type="importmap">\n' + json.dumps(importmaps, indent=4) + "\n</script>"
3794
- index_content = index_content.replace(
3795
- b'<script type="importmap"></script>',
3796
- importmaps_script.encode("utf-8"),
3797
- )
3798
-
3799
- if len(g_app.index_headers) > 0:
3800
- html_header = ""
3801
- for header in g_app.index_headers:
3802
- html_header += header
3803
- # replace </head> with html_header
3804
- index_content = index_content.replace(b"</head>", html_header.encode("utf-8") + b"\n</head>")
3805
-
3806
- if len(g_app.index_footers) > 0:
3807
- html_footer = ""
3808
- for footer in g_app.index_footers:
3809
- html_footer += footer
3810
- # replace </body> with html_footer
3811
- index_content = index_content.replace(b"</body>", html_footer.encode("utf-8") + b"\n</body>")
3812
-
2781
+ if index_content is None:
2782
+ raise web.HTTPNotFound
3813
2783
  return web.Response(body=index_content, content_type="text/html")
3814
2784
 
3815
2785
  app.router.add_get("/", index_handler)
@@ -3821,15 +2791,13 @@ def main():
3821
2791
  async def start_background_tasks(app):
3822
2792
  """Start background tasks when the app starts"""
3823
2793
  # Start watching config files in the background
3824
- asyncio.create_task(watch_config_files(g_config_path, home_providers_path))
2794
+ asyncio.create_task(watch_config_files(g_config_path, g_ui_path))
3825
2795
 
3826
2796
  app.on_startup.append(start_background_tasks)
3827
2797
 
3828
- # go through and register all g_app extensions
3829
-
3830
2798
  print(f"Starting server on port {port}...")
3831
2799
  web.run_app(app, host="0.0.0.0", port=port, print=_log)
3832
- g_app.exit(0)
2800
+ exit(0)
3833
2801
 
3834
2802
  if cli_args.enable is not None:
3835
2803
  if cli_args.enable.endswith(","):
@@ -3846,7 +2814,7 @@ def main():
3846
2814
 
3847
2815
  for provider in enable_providers:
3848
2816
  if provider not in g_config["providers"]:
3849
- print(f"Provider '{provider}' not found")
2817
+ print(f"Provider {provider} not found")
3850
2818
  print(f"Available providers: {', '.join(g_config['providers'].keys())}")
3851
2819
  exit(1)
3852
2820
  if provider in g_config["providers"]:
@@ -3859,7 +2827,7 @@ def main():
3859
2827
  print_status()
3860
2828
  if len(msgs) > 0:
3861
2829
  print("\n" + "\n".join(msgs))
3862
- g_app.exit(0)
2830
+ exit(0)
3863
2831
 
3864
2832
  if cli_args.disable is not None:
3865
2833
  if cli_args.disable.endswith(","):
@@ -3882,7 +2850,7 @@ def main():
3882
2850
  print(f"\nDisabled provider {provider}")
3883
2851
 
3884
2852
  print_status()
3885
- g_app.exit(0)
2853
+ exit(0)
3886
2854
 
3887
2855
  if cli_args.default is not None:
3888
2856
  default_model = cli_args.default
@@ -3894,14 +2862,13 @@ def main():
3894
2862
  default_text["model"] = default_model
3895
2863
  save_config(g_config)
3896
2864
  print(f"\nDefault model set to: {default_model}")
3897
- g_app.exit(0)
2865
+ exit(0)
3898
2866
 
3899
2867
  if (
3900
2868
  cli_args.chat is not None
3901
2869
  or cli_args.image is not None
3902
2870
  or cli_args.audio is not None
3903
2871
  or cli_args.file is not None
3904
- or cli_args.out is not None
3905
2872
  or len(extra_args) > 0
3906
2873
  ):
3907
2874
  try:
@@ -3912,12 +2879,6 @@ def main():
3912
2879
  chat = g_config["defaults"]["audio"]
3913
2880
  elif cli_args.file is not None:
3914
2881
  chat = g_config["defaults"]["file"]
3915
- elif cli_args.out is not None:
3916
- template = f"out:{cli_args.out}"
3917
- if template not in g_config["defaults"]:
3918
- print(f"Template for output modality '{cli_args.out}' not found")
3919
- exit(1)
3920
- chat = g_config["defaults"][template]
3921
2882
  if cli_args.chat is not None:
3922
2883
  chat_path = os.path.join(os.path.dirname(__file__), cli_args.chat)
3923
2884
  if not os.path.exists(chat_path):
@@ -3934,9 +2895,6 @@ def main():
3934
2895
 
3935
2896
  if len(extra_args) > 0:
3936
2897
  prompt = " ".join(extra_args)
3937
- if not chat["messages"] or len(chat["messages"]) == 0:
3938
- chat["messages"] = [{"role": "user", "content": [{"type": "text", "text": ""}]}]
3939
-
3940
2898
  # replace content of last message if exists, else add
3941
2899
  last_msg = chat["messages"][-1] if "messages" in chat else None
3942
2900
  if last_msg and last_msg["role"] == "user":
@@ -3954,31 +2912,19 @@ def main():
3954
2912
 
3955
2913
  asyncio.run(
3956
2914
  cli_chat(
3957
- chat,
3958
- tools=cli_args.tools,
3959
- image=cli_args.image,
3960
- audio=cli_args.audio,
3961
- file=cli_args.file,
3962
- args=args,
3963
- raw=cli_args.raw,
2915
+ chat, image=cli_args.image, audio=cli_args.audio, file=cli_args.file, args=args, raw=cli_args.raw
3964
2916
  )
3965
2917
  )
3966
- g_app.exit(0)
2918
+ exit(0)
3967
2919
  except Exception as e:
3968
2920
  print(f"{cli_args.logprefix}Error: {e}")
3969
2921
  if cli_args.verbose:
3970
2922
  traceback.print_exc()
3971
- g_app.exit(1)
3972
-
3973
- handled = run_extension_cli()
2923
+ exit(1)
3974
2924
 
3975
- if not handled:
3976
- # show usage from ArgumentParser
3977
- parser.print_help()
3978
- g_app.exit(0)
2925
+ # show usage from ArgumentParser
2926
+ parser.print_help()
3979
2927
 
3980
2928
 
3981
2929
  if __name__ == "__main__":
3982
- if MOCK or DEBUG:
3983
- print(f"MOCK={MOCK} or DEBUG={DEBUG}")
3984
2930
  main()