llms-py 3.0.0__py3-none-any.whl → 3.0.0b2__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 (195) hide show
  1. llms/__pycache__/main.cpython-314.pyc +0 -0
  2. llms/index.html +37 -26
  3. llms/llms.json +21 -70
  4. llms/main.py +731 -1426
  5. llms/providers.json +1 -1
  6. llms/{extensions/analytics/ui/index.mjs → ui/Analytics.mjs} +238 -154
  7. llms/ui/App.mjs +63 -133
  8. llms/ui/Avatar.mjs +86 -0
  9. llms/ui/Brand.mjs +52 -0
  10. llms/ui/ChatPrompt.mjs +597 -0
  11. llms/ui/Main.mjs +862 -0
  12. llms/ui/OAuthSignIn.mjs +61 -0
  13. llms/ui/ProviderIcon.mjs +36 -0
  14. llms/ui/ProviderStatus.mjs +104 -0
  15. llms/{extensions/app/ui → ui}/Recents.mjs +57 -82
  16. llms/ui/{modules/chat/SettingsDialog.mjs → SettingsDialog.mjs} +9 -9
  17. llms/{extensions/app/ui/index.mjs → ui/Sidebar.mjs} +57 -122
  18. llms/ui/SignIn.mjs +65 -0
  19. llms/ui/Welcome.mjs +8 -0
  20. llms/ui/ai.mjs +13 -117
  21. llms/ui/app.css +49 -1776
  22. llms/ui/index.mjs +171 -87
  23. llms/ui/lib/charts.mjs +13 -9
  24. llms/ui/lib/servicestack-vue.mjs +3 -3
  25. llms/ui/lib/vue.min.mjs +9 -10
  26. llms/ui/lib/vue.mjs +1602 -1763
  27. llms/ui/markdown.mjs +2 -10
  28. llms/ui/model-selector.mjs +686 -0
  29. llms/ui/tailwind.input.css +1 -55
  30. llms/ui/threadStore.mjs +583 -0
  31. llms/ui/utils.mjs +118 -113
  32. llms/ui.json +1069 -0
  33. {llms_py-3.0.0.dist-info → llms_py-3.0.0b2.dist-info}/METADATA +1 -1
  34. llms_py-3.0.0b2.dist-info/RECORD +58 -0
  35. llms/extensions/app/README.md +0 -20
  36. llms/extensions/app/__init__.py +0 -530
  37. llms/extensions/app/__pycache__/__init__.cpython-314.pyc +0 -0
  38. llms/extensions/app/__pycache__/db.cpython-314.pyc +0 -0
  39. llms/extensions/app/__pycache__/db_manager.cpython-314.pyc +0 -0
  40. llms/extensions/app/db.py +0 -644
  41. llms/extensions/app/db_manager.py +0 -195
  42. llms/extensions/app/requests.json +0 -9073
  43. llms/extensions/app/threads.json +0 -15290
  44. llms/extensions/app/ui/threadStore.mjs +0 -411
  45. llms/extensions/core_tools/CALCULATOR.md +0 -32
  46. llms/extensions/core_tools/__init__.py +0 -598
  47. llms/extensions/core_tools/__pycache__/__init__.cpython-314.pyc +0 -0
  48. llms/extensions/core_tools/ui/codemirror/addon/edit/closebrackets.js +0 -201
  49. llms/extensions/core_tools/ui/codemirror/addon/edit/closetag.js +0 -185
  50. llms/extensions/core_tools/ui/codemirror/addon/edit/continuelist.js +0 -101
  51. llms/extensions/core_tools/ui/codemirror/addon/edit/matchbrackets.js +0 -160
  52. llms/extensions/core_tools/ui/codemirror/addon/edit/matchtags.js +0 -66
  53. llms/extensions/core_tools/ui/codemirror/addon/edit/trailingspace.js +0 -27
  54. llms/extensions/core_tools/ui/codemirror/addon/selection/active-line.js +0 -72
  55. llms/extensions/core_tools/ui/codemirror/addon/selection/mark-selection.js +0 -119
  56. llms/extensions/core_tools/ui/codemirror/addon/selection/selection-pointer.js +0 -98
  57. llms/extensions/core_tools/ui/codemirror/doc/docs.css +0 -225
  58. llms/extensions/core_tools/ui/codemirror/doc/source_sans.woff +0 -0
  59. llms/extensions/core_tools/ui/codemirror/lib/codemirror.css +0 -344
  60. llms/extensions/core_tools/ui/codemirror/lib/codemirror.js +0 -9884
  61. llms/extensions/core_tools/ui/codemirror/mode/clike/clike.js +0 -942
  62. llms/extensions/core_tools/ui/codemirror/mode/javascript/index.html +0 -118
  63. llms/extensions/core_tools/ui/codemirror/mode/javascript/javascript.js +0 -962
  64. llms/extensions/core_tools/ui/codemirror/mode/javascript/typescript.html +0 -62
  65. llms/extensions/core_tools/ui/codemirror/mode/python/python.js +0 -402
  66. llms/extensions/core_tools/ui/codemirror/theme/dracula.css +0 -40
  67. llms/extensions/core_tools/ui/codemirror/theme/mocha.css +0 -135
  68. llms/extensions/core_tools/ui/index.mjs +0 -650
  69. llms/extensions/gallery/README.md +0 -61
  70. llms/extensions/gallery/__init__.py +0 -61
  71. llms/extensions/gallery/__pycache__/__init__.cpython-314.pyc +0 -0
  72. llms/extensions/gallery/__pycache__/db.cpython-314.pyc +0 -0
  73. llms/extensions/gallery/db.py +0 -298
  74. llms/extensions/gallery/ui/index.mjs +0 -482
  75. llms/extensions/katex/README.md +0 -39
  76. llms/extensions/katex/__init__.py +0 -6
  77. llms/extensions/katex/__pycache__/__init__.cpython-314.pyc +0 -0
  78. llms/extensions/katex/ui/README.md +0 -125
  79. llms/extensions/katex/ui/contrib/auto-render.js +0 -338
  80. llms/extensions/katex/ui/contrib/auto-render.min.js +0 -1
  81. llms/extensions/katex/ui/contrib/auto-render.mjs +0 -244
  82. llms/extensions/katex/ui/contrib/copy-tex.js +0 -127
  83. llms/extensions/katex/ui/contrib/copy-tex.min.js +0 -1
  84. llms/extensions/katex/ui/contrib/copy-tex.mjs +0 -105
  85. llms/extensions/katex/ui/contrib/mathtex-script-type.js +0 -109
  86. llms/extensions/katex/ui/contrib/mathtex-script-type.min.js +0 -1
  87. llms/extensions/katex/ui/contrib/mathtex-script-type.mjs +0 -24
  88. llms/extensions/katex/ui/contrib/mhchem.js +0 -3213
  89. llms/extensions/katex/ui/contrib/mhchem.min.js +0 -1
  90. llms/extensions/katex/ui/contrib/mhchem.mjs +0 -3109
  91. llms/extensions/katex/ui/contrib/render-a11y-string.js +0 -887
  92. llms/extensions/katex/ui/contrib/render-a11y-string.min.js +0 -1
  93. llms/extensions/katex/ui/contrib/render-a11y-string.mjs +0 -800
  94. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.ttf +0 -0
  95. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.woff +0 -0
  96. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  97. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  98. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  99. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  100. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  101. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  102. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  103. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  104. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  105. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  106. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  107. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  108. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  109. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.ttf +0 -0
  110. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.woff +0 -0
  111. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.woff2 +0 -0
  112. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  113. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  114. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  115. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.ttf +0 -0
  116. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.woff +0 -0
  117. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.woff2 +0 -0
  118. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.ttf +0 -0
  119. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.woff +0 -0
  120. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.woff2 +0 -0
  121. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  122. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  123. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  124. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.ttf +0 -0
  125. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.woff +0 -0
  126. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.woff2 +0 -0
  127. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  128. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  129. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  130. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  131. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  132. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  133. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  134. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  135. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  136. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.ttf +0 -0
  137. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.woff +0 -0
  138. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.woff2 +0 -0
  139. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.ttf +0 -0
  140. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.woff +0 -0
  141. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  142. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.ttf +0 -0
  143. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.woff +0 -0
  144. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  145. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.ttf +0 -0
  146. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.woff +0 -0
  147. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  148. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.ttf +0 -0
  149. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.woff +0 -0
  150. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  151. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  152. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  153. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  154. llms/extensions/katex/ui/index.mjs +0 -92
  155. llms/extensions/katex/ui/katex-swap.css +0 -1230
  156. llms/extensions/katex/ui/katex-swap.min.css +0 -1
  157. llms/extensions/katex/ui/katex.css +0 -1230
  158. llms/extensions/katex/ui/katex.js +0 -19080
  159. llms/extensions/katex/ui/katex.min.css +0 -1
  160. llms/extensions/katex/ui/katex.min.js +0 -1
  161. llms/extensions/katex/ui/katex.min.mjs +0 -1
  162. llms/extensions/katex/ui/katex.mjs +0 -18547
  163. llms/extensions/providers/__init__.py +0 -18
  164. llms/extensions/providers/__pycache__/__init__.cpython-314.pyc +0 -0
  165. llms/extensions/providers/__pycache__/anthropic.cpython-314.pyc +0 -0
  166. llms/extensions/providers/__pycache__/chutes.cpython-314.pyc +0 -0
  167. llms/extensions/providers/__pycache__/google.cpython-314.pyc +0 -0
  168. llms/extensions/providers/__pycache__/nvidia.cpython-314.pyc +0 -0
  169. llms/extensions/providers/__pycache__/openai.cpython-314.pyc +0 -0
  170. llms/extensions/providers/__pycache__/openrouter.cpython-314.pyc +0 -0
  171. llms/extensions/providers/anthropic.py +0 -229
  172. llms/extensions/providers/chutes.py +0 -155
  173. llms/extensions/providers/google.py +0 -378
  174. llms/extensions/providers/nvidia.py +0 -105
  175. llms/extensions/providers/openai.py +0 -156
  176. llms/extensions/providers/openrouter.py +0 -72
  177. llms/extensions/system_prompts/README.md +0 -22
  178. llms/extensions/system_prompts/__init__.py +0 -45
  179. llms/extensions/system_prompts/__pycache__/__init__.cpython-314.pyc +0 -0
  180. llms/extensions/system_prompts/ui/index.mjs +0 -280
  181. llms/extensions/system_prompts/ui/prompts.json +0 -1067
  182. llms/extensions/tools/__init__.py +0 -5
  183. llms/extensions/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  184. llms/extensions/tools/ui/index.mjs +0 -204
  185. llms/providers-extra.json +0 -356
  186. llms/ui/ctx.mjs +0 -365
  187. llms/ui/modules/chat/ChatBody.mjs +0 -691
  188. llms/ui/modules/chat/index.mjs +0 -828
  189. llms/ui/modules/layout.mjs +0 -243
  190. llms/ui/modules/model-selector.mjs +0 -851
  191. llms_py-3.0.0.dist-info/RECORD +0 -202
  192. {llms_py-3.0.0.dist-info → llms_py-3.0.0b2.dist-info}/WHEEL +0 -0
  193. {llms_py-3.0.0.dist-info → llms_py-3.0.0b2.dist-info}/entry_points.txt +0 -0
  194. {llms_py-3.0.0.dist-info → llms_py-3.0.0b2.dist-info}/licenses/LICENSE +0 -0
  195. {llms_py-3.0.0.dist-info → llms_py-3.0.0b2.dist-info}/top_level.txt +0 -0
llms/main.py CHANGED
@@ -9,10 +9,8 @@
9
9
  import argparse
10
10
  import asyncio
11
11
  import base64
12
- import contextlib
13
12
  import hashlib
14
13
  import importlib.util
15
- import inspect
16
14
  import json
17
15
  import mimetypes
18
16
  import os
@@ -28,8 +26,7 @@ from datetime import datetime
28
26
  from importlib import resources # Py≥3.9 (pip install importlib_resources for 3.7/3.8)
29
27
  from io import BytesIO
30
28
  from pathlib import Path
31
- from typing import get_type_hints
32
- from urllib.parse import parse_qs, urlencode, urljoin
29
+ from urllib.parse import parse_qs, urlencode
33
30
 
34
31
  import aiohttp
35
32
  from aiohttp import web
@@ -41,13 +38,11 @@ try:
41
38
  except ImportError:
42
39
  HAS_PIL = False
43
40
 
44
- VERSION = "3.0.0"
41
+ VERSION = "3.0.0b2"
45
42
  _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(",")
43
+ DEBUG = True # os.getenv("PYPI_SERVICESTACK") is not None
50
44
  g_config_path = None
45
+ g_ui_path = None
51
46
  g_config = None
52
47
  g_providers = None
53
48
  g_handlers = {}
@@ -107,6 +102,17 @@ def chat_summary(chat):
107
102
  return json.dumps(clone, indent=2)
108
103
 
109
104
 
105
+ def gemini_chat_summary(gemini_chat):
106
+ """Summarize Gemini chat completion request for logging. Replace inline_data with size of content only"""
107
+ clone = json.loads(json.dumps(gemini_chat))
108
+ for content in clone["contents"]:
109
+ for part in content["parts"]:
110
+ if "inline_data" in part:
111
+ data = part["inline_data"]["data"]
112
+ part["inline_data"]["data"] = f"({len(data)})"
113
+ return json.dumps(clone, indent=2)
114
+
115
+
110
116
  image_exts = ["png", "webp", "jpg", "jpeg", "gif", "bmp", "svg", "tiff", "ico"]
111
117
  audio_exts = ["mp3", "wav", "ogg", "flac", "m4a", "opus", "webm"]
112
118
 
@@ -200,16 +206,6 @@ def is_base_64(data):
200
206
  return False
201
207
 
202
208
 
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
209
  def get_file_mime_type(filename):
214
210
  mime_type, _ = mimetypes.guess_type(filename)
215
211
  return mime_type or "application/octet-stream"
@@ -331,52 +327,11 @@ def convert_image_if_needed(image_bytes, mimetype="image/png"):
331
327
  return image_bytes, mimetype
332
328
 
333
329
 
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
330
  async def process_chat(chat, provider_id=None):
373
331
  if not chat:
374
332
  raise Exception("No chat provided")
375
333
  if "stream" not in chat:
376
334
  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
335
  if "messages" not in chat:
381
336
  return chat
382
337
 
@@ -503,92 +458,6 @@ async def process_chat(chat, provider_id=None):
503
458
  return chat
504
459
 
505
460
 
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
461
  class HTTPError(Exception):
593
462
  def __init__(self, status, reason, body, headers=None):
594
463
  self.status = status
@@ -598,302 +467,15 @@ class HTTPError(Exception):
598
467
  super().__init__(f"HTTP {status} {reason}")
599
468
 
600
469
 
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
470
  async def response_json(response):
710
471
  text = await response.text()
711
472
  if response.status >= 400:
712
- _dbg(f"HTTP {response.status} {response.reason}: {text}")
713
473
  raise HTTPError(response.status, reason=response.reason, body=text, headers=dict(response.headers))
714
474
  response.raise_for_status()
715
475
  body = json.loads(text)
716
476
  return body
717
477
 
718
478
 
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
479
  class OpenAiCompatible:
898
480
  sdk = "@ai-sdk/openai-compatible"
899
481
 
@@ -905,9 +487,8 @@ class OpenAiCompatible:
905
487
 
906
488
  self.id = kwargs.get("id")
907
489
  self.api = kwargs.get("api").strip("/")
908
- self.env = kwargs.get("env", [])
909
490
  self.api_key = kwargs.get("api_key")
910
- self.name = kwargs.get("name", id_to_name(self.id))
491
+ self.name = kwargs.get("name", self.id.replace("-", " ").title().replace(" ", ""))
911
492
  self.set_models(**kwargs)
912
493
 
913
494
  self.chat_url = f"{self.api}/chat/completions"
@@ -935,7 +516,6 @@ class OpenAiCompatible:
935
516
  self.stream = bool(kwargs["stream"]) if "stream" in kwargs else None
936
517
  self.enable_thinking = bool(kwargs["enable_thinking"]) if "enable_thinking" in kwargs else None
937
518
  self.check = kwargs.get("check")
938
- self.modalities = kwargs.get("modalities", {})
939
519
 
940
520
  def set_models(self, **kwargs):
941
521
  models = kwargs.get("models", {})
@@ -961,34 +541,23 @@ class OpenAiCompatible:
961
541
  _log(f"Filtering {len(self.models)} models, excluding models that match regex: {exclude_models}")
962
542
  self.models = {k: v for k, v in self.models.items() if not re.search(exclude_models, k)}
963
543
 
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
544
  def test(self, **kwargs):
971
- error_msg = self.validate(**kwargs)
972
- if error_msg:
973
- _log(error_msg)
974
- return False
975
- return True
545
+ ret = self.api and self.api_key and (len(self.models) > 0)
546
+ if not ret:
547
+ _log(f"Provider {self.name} Missing: {self.api}, {self.api_key}, {len(self.models)}")
548
+ return ret
976
549
 
977
550
  async def load(self):
978
551
  if not self.models:
979
552
  await self.load_models()
980
553
 
981
- def model_info(self, model):
554
+ def model_cost(self, model):
982
555
  provider_model = self.provider_model(model) or model
983
556
  for model_id, model_info in self.models.items():
984
557
  if model_id.lower() == provider_model.lower():
985
- return model_info
558
+ return model_info.get("cost")
986
559
  return None
987
560
 
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
561
  def provider_model(self, model):
993
562
  # convert model to lowercase for case-insensitive comparison
994
563
  model_lower = model.lower()
@@ -1024,12 +593,8 @@ class OpenAiCompatible:
1024
593
  if "/" in model:
1025
594
  last_part = model.split("/")[-1]
1026
595
  return self.provider_model(last_part)
1027
-
1028
596
  return None
1029
597
 
1030
- def response_json(self, response):
1031
- return response_json(response)
1032
-
1033
598
  def to_response(self, response, chat, started_at):
1034
599
  if "metadata" not in response:
1035
600
  response["metadata"] = {}
@@ -1038,28 +603,12 @@ class OpenAiCompatible:
1038
603
  pricing = self.model_cost(chat["model"])
1039
604
  if pricing and "input" in pricing and "output" in pricing:
1040
605
  response["metadata"]["pricing"] = f"{pricing['input']}/{pricing['output']}"
606
+ _log(json.dumps(response, indent=2))
1041
607
  return response
1042
608
 
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
609
  async def chat(self, chat):
1050
610
  chat["model"] = self.provider_model(chat["model"]) or chat["model"]
1051
611
 
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")
1062
-
1063
612
  # with open(os.path.join(os.path.dirname(__file__), 'chat.wip.json'), "w") as f:
1064
613
  # f.write(json.dumps(chat, indent=2))
1065
614
 
@@ -1102,17 +651,203 @@ class OpenAiCompatible:
1102
651
  _log(f"POST {self.chat_url}")
1103
652
  _log(chat_summary(chat))
1104
653
  # remove metadata if any (conflicts with some providers, e.g. Z.ai)
1105
- metadata = chat.pop("metadata", None)
654
+ chat.pop("metadata", None)
1106
655
 
1107
656
  async with aiohttp.ClientSession() as session:
1108
657
  started_at = time.time()
1109
658
  async with session.post(
1110
659
  self.chat_url, headers=self.headers, data=json.dumps(chat), timeout=aiohttp.ClientTimeout(total=120)
1111
660
  ) as response:
1112
- chat["metadata"] = metadata
1113
661
  return self.to_response(await response_json(response), chat, started_at)
1114
662
 
1115
663
 
664
+ class OpenAiProvider(OpenAiCompatible):
665
+ sdk = "@ai-sdk/openai"
666
+
667
+ def __init__(self, **kwargs):
668
+ if "api" not in kwargs:
669
+ kwargs["api"] = "https://api.openai.com/v1"
670
+ super().__init__(**kwargs)
671
+
672
+
673
+ class AnthropicProvider(OpenAiCompatible):
674
+ sdk = "@ai-sdk/anthropic"
675
+
676
+ def __init__(self, **kwargs):
677
+ if "api" not in kwargs:
678
+ kwargs["api"] = "https://api.anthropic.com/v1"
679
+ super().__init__(**kwargs)
680
+
681
+ # Anthropic uses x-api-key header instead of Authorization
682
+ if self.api_key:
683
+ self.headers = self.headers.copy()
684
+ if "Authorization" in self.headers:
685
+ del self.headers["Authorization"]
686
+ self.headers["x-api-key"] = self.api_key
687
+
688
+ if "anthropic-version" not in self.headers:
689
+ self.headers = self.headers.copy()
690
+ self.headers["anthropic-version"] = "2023-06-01"
691
+ self.chat_url = f"{self.api}/messages"
692
+
693
+ async def chat(self, chat):
694
+ chat["model"] = self.provider_model(chat["model"]) or chat["model"]
695
+
696
+ chat = await process_chat(chat, provider_id=self.id)
697
+
698
+ # Transform OpenAI format to Anthropic format
699
+ anthropic_request = {
700
+ "model": chat["model"],
701
+ "messages": [],
702
+ }
703
+
704
+ # Extract system message (Anthropic uses top-level 'system' parameter)
705
+ system_messages = []
706
+ for message in chat.get("messages", []):
707
+ if message.get("role") == "system":
708
+ content = message.get("content", "")
709
+ if isinstance(content, str):
710
+ system_messages.append(content)
711
+ elif isinstance(content, list):
712
+ for item in content:
713
+ if item.get("type") == "text":
714
+ system_messages.append(item.get("text", ""))
715
+
716
+ if system_messages:
717
+ anthropic_request["system"] = "\n".join(system_messages)
718
+
719
+ # Transform messages (exclude system messages)
720
+ for message in chat.get("messages", []):
721
+ if message.get("role") == "system":
722
+ continue
723
+
724
+ anthropic_message = {"role": message.get("role"), "content": []}
725
+
726
+ content = message.get("content", "")
727
+ if isinstance(content, str):
728
+ anthropic_message["content"] = content
729
+ elif isinstance(content, list):
730
+ for item in content:
731
+ if item.get("type") == "text":
732
+ anthropic_message["content"].append({"type": "text", "text": item.get("text", "")})
733
+ elif item.get("type") == "image_url" and "image_url" in item:
734
+ # Transform OpenAI image_url format to Anthropic format
735
+ image_url = item["image_url"].get("url", "")
736
+ if image_url.startswith("data:"):
737
+ # Extract media type and base64 data
738
+ parts = image_url.split(";base64,", 1)
739
+ if len(parts) == 2:
740
+ media_type = parts[0].replace("data:", "")
741
+ base64_data = parts[1]
742
+ anthropic_message["content"].append(
743
+ {
744
+ "type": "image",
745
+ "source": {"type": "base64", "media_type": media_type, "data": base64_data},
746
+ }
747
+ )
748
+
749
+ anthropic_request["messages"].append(anthropic_message)
750
+
751
+ # Handle max_tokens (required by Anthropic, uses max_tokens not max_completion_tokens)
752
+ if "max_completion_tokens" in chat:
753
+ anthropic_request["max_tokens"] = chat["max_completion_tokens"]
754
+ elif "max_tokens" in chat:
755
+ anthropic_request["max_tokens"] = chat["max_tokens"]
756
+ else:
757
+ # Anthropic requires max_tokens, set a default
758
+ anthropic_request["max_tokens"] = 4096
759
+
760
+ # Copy other supported parameters
761
+ if "temperature" in chat:
762
+ anthropic_request["temperature"] = chat["temperature"]
763
+ if "top_p" in chat:
764
+ anthropic_request["top_p"] = chat["top_p"]
765
+ if "top_k" in chat:
766
+ anthropic_request["top_k"] = chat["top_k"]
767
+ if "stop" in chat:
768
+ anthropic_request["stop_sequences"] = chat["stop"] if isinstance(chat["stop"], list) else [chat["stop"]]
769
+ if "stream" in chat:
770
+ anthropic_request["stream"] = chat["stream"]
771
+ if "tools" in chat:
772
+ anthropic_request["tools"] = chat["tools"]
773
+ if "tool_choice" in chat:
774
+ anthropic_request["tool_choice"] = chat["tool_choice"]
775
+
776
+ _log(f"POST {self.chat_url}")
777
+ _log(f"Anthropic Request: {json.dumps(anthropic_request, indent=2)}")
778
+
779
+ async with aiohttp.ClientSession() as session:
780
+ started_at = time.time()
781
+ async with session.post(
782
+ self.chat_url,
783
+ headers=self.headers,
784
+ data=json.dumps(anthropic_request),
785
+ timeout=aiohttp.ClientTimeout(total=120),
786
+ ) as response:
787
+ return self.to_response(await response_json(response), chat, started_at)
788
+
789
+ def to_response(self, response, chat, started_at):
790
+ """Convert Anthropic response format to OpenAI-compatible format."""
791
+ # Transform Anthropic response to OpenAI format
792
+ openai_response = {
793
+ "id": response.get("id", ""),
794
+ "object": "chat.completion",
795
+ "created": int(started_at),
796
+ "model": response.get("model", ""),
797
+ "choices": [],
798
+ "usage": {},
799
+ }
800
+
801
+ # Transform content blocks to message content
802
+ content_parts = []
803
+ thinking_parts = []
804
+
805
+ for block in response.get("content", []):
806
+ if block.get("type") == "text":
807
+ content_parts.append(block.get("text", ""))
808
+ elif block.get("type") == "thinking":
809
+ # Store thinking blocks separately (some models include reasoning)
810
+ thinking_parts.append(block.get("thinking", ""))
811
+
812
+ # Combine all text content
813
+ message_content = "\n".join(content_parts) if content_parts else ""
814
+
815
+ # Create the choice object
816
+ choice = {
817
+ "index": 0,
818
+ "message": {"role": "assistant", "content": message_content},
819
+ "finish_reason": response.get("stop_reason", "stop"),
820
+ }
821
+
822
+ # Add thinking as metadata if present
823
+ if thinking_parts:
824
+ choice["message"]["thinking"] = "\n".join(thinking_parts)
825
+
826
+ openai_response["choices"].append(choice)
827
+
828
+ # Transform usage
829
+ if "usage" in response:
830
+ usage = response["usage"]
831
+ openai_response["usage"] = {
832
+ "prompt_tokens": usage.get("input_tokens", 0),
833
+ "completion_tokens": usage.get("output_tokens", 0),
834
+ "total_tokens": usage.get("input_tokens", 0) + usage.get("output_tokens", 0),
835
+ }
836
+
837
+ # Add metadata
838
+ if "metadata" not in openai_response:
839
+ openai_response["metadata"] = {}
840
+ openai_response["metadata"]["duration"] = int((time.time() - started_at) * 1000)
841
+
842
+ if chat is not None and "model" in chat:
843
+ cost = self.model_cost(chat["model"])
844
+ if cost and "input" in cost and "output" in cost:
845
+ openai_response["metadata"]["pricing"] = f"{cost['input']}/{cost['output']}"
846
+
847
+ _log(json.dumps(openai_response, indent=2))
848
+ return openai_response
849
+
850
+
1116
851
  class MistralProvider(OpenAiCompatible):
1117
852
  sdk = "@ai-sdk/mistral"
1118
853
 
@@ -1208,8 +943,8 @@ class OllamaProvider(OpenAiCompatible):
1208
943
  }
1209
944
  self.models = models
1210
945
 
1211
- def validate(self, **kwargs):
1212
- return None
946
+ def test(self, **kwargs):
947
+ return True
1213
948
 
1214
949
 
1215
950
  class LMStudioProvider(OllamaProvider):
@@ -1238,6 +973,223 @@ class LMStudioProvider(OllamaProvider):
1238
973
  return ret
1239
974
 
1240
975
 
976
+ # class GoogleOpenAiProvider(OpenAiCompatible):
977
+ # sdk = "google-openai-compatible"
978
+
979
+ # def __init__(self, api_key, **kwargs):
980
+ # super().__init__(api="https://generativelanguage.googleapis.com", api_key=api_key, **kwargs)
981
+ # self.chat_url = "https://generativelanguage.googleapis.com/v1beta/chat/completions"
982
+
983
+
984
+ class GoogleProvider(OpenAiCompatible):
985
+ sdk = "@ai-sdk/google"
986
+
987
+ def __init__(self, **kwargs):
988
+ new_kwargs = {"api": "https://generativelanguage.googleapis.com", **kwargs}
989
+ super().__init__(**new_kwargs)
990
+ self.safety_settings = kwargs.get("safety_settings")
991
+ self.thinking_config = kwargs.get("thinking_config")
992
+ self.curl = kwargs.get("curl")
993
+ self.headers = kwargs.get("headers", {"Content-Type": "application/json"})
994
+ # Google fails when using Authorization header, use query string param instead
995
+ if "Authorization" in self.headers:
996
+ del self.headers["Authorization"]
997
+
998
+ async def chat(self, chat):
999
+ chat["model"] = self.provider_model(chat["model"]) or chat["model"]
1000
+
1001
+ chat = await process_chat(chat)
1002
+ generation_config = {}
1003
+
1004
+ # Filter out system messages and convert to proper Gemini format
1005
+ contents = []
1006
+ system_prompt = None
1007
+
1008
+ async with aiohttp.ClientSession() as session:
1009
+ for message in chat["messages"]:
1010
+ if message["role"] == "system":
1011
+ content = message["content"]
1012
+ if isinstance(content, list):
1013
+ for item in content:
1014
+ if "text" in item:
1015
+ system_prompt = item["text"]
1016
+ break
1017
+ elif isinstance(content, str):
1018
+ system_prompt = content
1019
+ elif "content" in message:
1020
+ if isinstance(message["content"], list):
1021
+ parts = []
1022
+ for item in message["content"]:
1023
+ if "type" in item:
1024
+ if item["type"] == "image_url" and "image_url" in item:
1025
+ image_url = item["image_url"]
1026
+ if "url" not in image_url:
1027
+ continue
1028
+ url = image_url["url"]
1029
+ if not url.startswith("data:"):
1030
+ raise (Exception("Image was not downloaded: " + url))
1031
+ # Extract mime type from data uri
1032
+ mimetype = url.split(";", 1)[0].split(":", 1)[1] if ";" in url else "image/png"
1033
+ base64_data = url.split(",", 1)[1]
1034
+ parts.append({"inline_data": {"mime_type": mimetype, "data": base64_data}})
1035
+ elif item["type"] == "input_audio" and "input_audio" in item:
1036
+ input_audio = item["input_audio"]
1037
+ if "data" not in input_audio:
1038
+ continue
1039
+ data = input_audio["data"]
1040
+ format = input_audio["format"]
1041
+ mimetype = f"audio/{format}"
1042
+ parts.append({"inline_data": {"mime_type": mimetype, "data": data}})
1043
+ elif item["type"] == "file" and "file" in item:
1044
+ file = item["file"]
1045
+ if "file_data" not in file:
1046
+ continue
1047
+ data = file["file_data"]
1048
+ if not data.startswith("data:"):
1049
+ raise (Exception("File was not downloaded: " + data))
1050
+ # Extract mime type from data uri
1051
+ mimetype = (
1052
+ data.split(";", 1)[0].split(":", 1)[1]
1053
+ if ";" in data
1054
+ else "application/octet-stream"
1055
+ )
1056
+ base64_data = data.split(",", 1)[1]
1057
+ parts.append({"inline_data": {"mime_type": mimetype, "data": base64_data}})
1058
+ if "text" in item:
1059
+ text = item["text"]
1060
+ parts.append({"text": text})
1061
+ if len(parts) > 0:
1062
+ contents.append(
1063
+ {
1064
+ "role": message["role"]
1065
+ if "role" in message and message["role"] == "user"
1066
+ else "model",
1067
+ "parts": parts,
1068
+ }
1069
+ )
1070
+ else:
1071
+ content = message["content"]
1072
+ contents.append(
1073
+ {
1074
+ "role": message["role"] if "role" in message and message["role"] == "user" else "model",
1075
+ "parts": [{"text": content}],
1076
+ }
1077
+ )
1078
+
1079
+ gemini_chat = {
1080
+ "contents": contents,
1081
+ }
1082
+
1083
+ if self.safety_settings:
1084
+ gemini_chat["safetySettings"] = self.safety_settings
1085
+
1086
+ # Add system instruction if present
1087
+ if system_prompt is not None:
1088
+ gemini_chat["systemInstruction"] = {"parts": [{"text": system_prompt}]}
1089
+
1090
+ if "max_completion_tokens" in chat:
1091
+ generation_config["maxOutputTokens"] = chat["max_completion_tokens"]
1092
+ if "stop" in chat:
1093
+ generation_config["stopSequences"] = [chat["stop"]]
1094
+ if "temperature" in chat:
1095
+ generation_config["temperature"] = chat["temperature"]
1096
+ if "top_p" in chat:
1097
+ generation_config["topP"] = chat["top_p"]
1098
+ if "top_logprobs" in chat:
1099
+ generation_config["topK"] = chat["top_logprobs"]
1100
+
1101
+ if "thinkingConfig" in chat:
1102
+ generation_config["thinkingConfig"] = chat["thinkingConfig"]
1103
+ elif self.thinking_config:
1104
+ generation_config["thinkingConfig"] = self.thinking_config
1105
+
1106
+ if len(generation_config) > 0:
1107
+ gemini_chat["generationConfig"] = generation_config
1108
+
1109
+ started_at = int(time.time() * 1000)
1110
+ gemini_chat_url = f"https://generativelanguage.googleapis.com/v1beta/models/{chat['model']}:generateContent?key={self.api_key}"
1111
+
1112
+ _log(f"POST {gemini_chat_url}")
1113
+ _log(gemini_chat_summary(gemini_chat))
1114
+ started_at = time.time()
1115
+
1116
+ if self.curl:
1117
+ curl_args = [
1118
+ "curl",
1119
+ "-X",
1120
+ "POST",
1121
+ "-H",
1122
+ "Content-Type: application/json",
1123
+ "-d",
1124
+ json.dumps(gemini_chat),
1125
+ gemini_chat_url,
1126
+ ]
1127
+ try:
1128
+ o = subprocess.run(curl_args, check=True, capture_output=True, text=True, timeout=120)
1129
+ obj = json.loads(o.stdout)
1130
+ except Exception as e:
1131
+ raise Exception(f"Error executing curl: {e}") from e
1132
+ else:
1133
+ async with session.post(
1134
+ gemini_chat_url,
1135
+ headers=self.headers,
1136
+ data=json.dumps(gemini_chat),
1137
+ timeout=aiohttp.ClientTimeout(total=120),
1138
+ ) as res:
1139
+ obj = await response_json(res)
1140
+ _log(f"google response:\n{json.dumps(obj, indent=2)}")
1141
+
1142
+ response = {
1143
+ "id": f"chatcmpl-{started_at}",
1144
+ "created": started_at,
1145
+ "model": obj.get("modelVersion", chat["model"]),
1146
+ }
1147
+ choices = []
1148
+ if "error" in obj:
1149
+ _log(f"Error: {obj['error']}")
1150
+ raise Exception(obj["error"]["message"])
1151
+ for i, candidate in enumerate(obj["candidates"]):
1152
+ role = "assistant"
1153
+ if "content" in candidate and "role" in candidate["content"]:
1154
+ role = "assistant" if candidate["content"]["role"] == "model" else candidate["content"]["role"]
1155
+
1156
+ # Safely extract content from all text parts
1157
+ content = ""
1158
+ reasoning = ""
1159
+ if "content" in candidate and "parts" in candidate["content"]:
1160
+ text_parts = []
1161
+ reasoning_parts = []
1162
+ for part in candidate["content"]["parts"]:
1163
+ if "text" in part:
1164
+ if "thought" in part and part["thought"]:
1165
+ reasoning_parts.append(part["text"])
1166
+ else:
1167
+ text_parts.append(part["text"])
1168
+ content = " ".join(text_parts)
1169
+ reasoning = " ".join(reasoning_parts)
1170
+
1171
+ choice = {
1172
+ "index": i,
1173
+ "finish_reason": candidate.get("finishReason", "stop"),
1174
+ "message": {
1175
+ "role": role,
1176
+ "content": content,
1177
+ },
1178
+ }
1179
+ if reasoning:
1180
+ choice["message"]["reasoning"] = reasoning
1181
+ choices.append(choice)
1182
+ response["choices"] = choices
1183
+ if "usageMetadata" in obj:
1184
+ usage = obj["usageMetadata"]
1185
+ response["usage"] = {
1186
+ "completion_tokens": usage["candidatesTokenCount"],
1187
+ "total_tokens": usage["totalTokenCount"],
1188
+ "prompt_tokens": usage["promptTokenCount"],
1189
+ }
1190
+ return self.to_response(response, chat, started_at)
1191
+
1192
+
1241
1193
  def get_provider_model(model_name):
1242
1194
  for provider in g_handlers.values():
1243
1195
  provider_model = provider.provider_model(model_name)
@@ -1281,237 +1233,31 @@ def api_providers():
1281
1233
  return ret
1282
1234
 
1283
1235
 
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
-
1236
+ async def chat_completion(chat):
1237
+ model = chat["model"]
1238
+ # get first provider that has the model
1239
+ candidate_providers = [name for name, provider in g_handlers.items() if provider.provider_model(model)]
1240
+ if len(candidate_providers) == 0:
1241
+ raise (Exception(f"Model {model} not found"))
1309
1242
 
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
1353
-
1354
- started_at = time.time()
1355
1243
  first_exception = None
1356
- provider_name = "Unknown"
1357
1244
  for name in candidate_providers:
1245
+ provider = g_handlers[name]
1246
+ _log(f"provider: {name} {type(provider).__name__}")
1358
1247
  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
-
1248
+ response = await provider.chat(chat.copy())
1249
+ return response
1501
1250
  except Exception as e:
1502
1251
  if first_exception is None:
1503
1252
  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
-
1253
+ _log(f"Provider {name} failed: {e}")
1508
1254
  continue
1509
1255
 
1510
1256
  # If we get here, all providers failed
1511
1257
  raise first_exception
1512
1258
 
1513
1259
 
1514
- async def cli_chat(chat, tools=None, image=None, audio=None, file=None, args=None, raw=False):
1260
+ async def cli_chat(chat, image=None, audio=None, file=None, args=None, raw=False):
1515
1261
  if g_default_model:
1516
1262
  chat["model"] = g_default_model
1517
1263
 
@@ -1586,53 +1332,25 @@ async def cli_chat(chat, tools=None, image=None, audio=None, file=None, args=Non
1586
1332
  printdump(chat)
1587
1333
 
1588
1334
  try:
1589
- context = {
1590
- "tools": tools or "all",
1591
- }
1592
- response = await g_app.chat_completion(chat, context=context)
1593
-
1335
+ response = await chat_completion(chat)
1594
1336
  if raw:
1595
1337
  print(json.dumps(response, indent=2))
1596
1338
  exit(0)
1597
1339
  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
-
1340
+ answer = response["choices"][0]["message"]["content"]
1341
+ print(answer)
1624
1342
  except HTTPError as e:
1625
1343
  # HTTP error (4xx, 5xx)
1626
1344
  print(f"{e}:\n{e.body}")
1627
- g_app.exit(1)
1345
+ exit(1)
1628
1346
  except aiohttp.ClientConnectionError as e:
1629
1347
  # Connection issues
1630
1348
  print(f"Connection error: {e}")
1631
- g_app.exit(1)
1349
+ exit(1)
1632
1350
  except asyncio.TimeoutError as e:
1633
1351
  # Timeout
1634
1352
  print(f"Timeout error: {e}")
1635
- g_app.exit(1)
1353
+ exit(1)
1636
1354
 
1637
1355
 
1638
1356
  def config_str(key):
@@ -1655,33 +1373,29 @@ def init_llms(config, providers):
1655
1373
  # iterate over config and replace $ENV with env value
1656
1374
  for key, value in g_config.items():
1657
1375
  if isinstance(value, str) and value.startswith("$"):
1658
- g_config[key] = os.getenv(value[1:], "")
1376
+ g_config[key] = os.environ.get(value[1:], "")
1659
1377
 
1660
1378
  # if g_verbose:
1661
1379
  # printdump(g_config)
1662
1380
  providers = g_config["providers"]
1663
1381
 
1664
1382
  for id, orig in providers.items():
1665
- if "enabled" in orig and not orig["enabled"]:
1383
+ definition = orig.copy()
1384
+ if "enabled" in definition and not definition["enabled"]:
1666
1385
  continue
1667
1386
 
1668
- provider, constructor_kwargs = create_provider_from_definition(id, orig)
1387
+ provider_id = definition.get("id", id)
1388
+ if "id" not in definition:
1389
+ definition["id"] = provider_id
1390
+ provider = g_providers.get(provider_id)
1391
+ constructor_kwargs = create_provider_kwargs(definition, provider)
1392
+ provider = create_provider(constructor_kwargs)
1393
+
1669
1394
  if provider and provider.test(**constructor_kwargs):
1670
1395
  g_handlers[id] = provider
1671
1396
  return g_handlers
1672
1397
 
1673
1398
 
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
1399
  def create_provider_kwargs(definition, provider=None):
1686
1400
  if provider:
1687
1401
  provider = provider.copy()
@@ -1693,11 +1407,11 @@ def create_provider_kwargs(definition, provider=None):
1693
1407
  if "api_key" in provider:
1694
1408
  value = provider["api_key"]
1695
1409
  if isinstance(value, str) and value.startswith("$"):
1696
- provider["api_key"] = os.getenv(value[1:], "")
1410
+ provider["api_key"] = os.environ.get(value[1:], "")
1697
1411
 
1698
1412
  if "api_key" not in provider and "env" in provider:
1699
1413
  for env_var in provider["env"]:
1700
- val = os.getenv(env_var)
1414
+ val = os.environ.get(env_var)
1701
1415
  if val:
1702
1416
  provider["api_key"] = val
1703
1417
  break
@@ -1709,15 +1423,6 @@ def create_provider_kwargs(definition, provider=None):
1709
1423
  if isinstance(value, (list, dict)):
1710
1424
  constructor_kwargs[key] = value.copy()
1711
1425
  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
1426
  return constructor_kwargs
1722
1427
 
1723
1428
 
@@ -1733,8 +1438,6 @@ def create_provider(provider):
1733
1438
  for provider_type in g_app.all_providers:
1734
1439
  if provider_type.sdk == npm_sdk:
1735
1440
  kwargs = create_provider_kwargs(provider)
1736
- if kwargs is None:
1737
- kwargs = provider
1738
1441
  return provider_type(**kwargs)
1739
1442
 
1740
1443
  _log(f"Could not find provider {provider_label} with npm sdk {npm_sdk}")
@@ -1788,23 +1491,11 @@ async def update_providers(home_providers_path):
1788
1491
  global g_providers
1789
1492
  text = await get_text("https://models.dev/api.json")
1790
1493
  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
1494
 
1797
1495
  filtered_providers = {}
1798
1496
  for id, provider in all_providers.items():
1799
1497
  if id in g_config["providers"]:
1800
1498
  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
1499
 
1809
1500
  os.makedirs(os.path.dirname(home_providers_path), exist_ok=True)
1810
1501
  with open(home_providers_path, "w", encoding="utf-8") as f:
@@ -1834,11 +1525,11 @@ def print_status():
1834
1525
 
1835
1526
 
1836
1527
  def home_llms_path(filename):
1837
- return f"{os.getenv('HOME')}/.llms/{filename}"
1528
+ return f"{os.environ.get('HOME')}/.llms/{filename}"
1838
1529
 
1839
1530
 
1840
- def get_cache_path(path=""):
1841
- return home_llms_path(f"cache/{path}") if path else home_llms_path("cache")
1531
+ def get_cache_path(filename):
1532
+ return home_llms_path(f"cache/{filename}")
1842
1533
 
1843
1534
 
1844
1535
  def get_config_path():
@@ -1847,8 +1538,8 @@ def get_config_path():
1847
1538
  "./llms.json",
1848
1539
  home_config_path,
1849
1540
  ]
1850
- if os.getenv("LLMS_CONFIG_PATH"):
1851
- check_paths.insert(0, os.getenv("LLMS_CONFIG_PATH"))
1541
+ if os.environ.get("LLMS_CONFIG_PATH"):
1542
+ check_paths.insert(0, os.environ.get("LLMS_CONFIG_PATH"))
1852
1543
 
1853
1544
  for check_path in check_paths:
1854
1545
  g_config_path = os.path.normpath(os.path.join(os.path.dirname(__file__), check_path))
@@ -1857,18 +1548,26 @@ def get_config_path():
1857
1548
  return None
1858
1549
 
1859
1550
 
1551
+ def get_ui_path():
1552
+ ui_paths = [home_llms_path("ui.json"), "ui.json"]
1553
+ for ui_path in ui_paths:
1554
+ if os.path.exists(ui_path):
1555
+ return ui_path
1556
+ return None
1557
+
1558
+
1860
1559
  def enable_provider(provider):
1861
1560
  msg = None
1862
1561
  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
1562
  provider_config["enabled"] = True
1563
+ if "api_key" in provider_config:
1564
+ api_key = provider_config["api_key"]
1565
+ if isinstance(api_key, str):
1566
+ if api_key.startswith("$"):
1567
+ if not os.environ.get(api_key[1:], ""):
1568
+ msg = f"WARNING: {provider} requires missing API Key in Environment Variable {api_key}"
1569
+ else:
1570
+ msg = f"WARNING: {provider} is not configured with an API Key"
1872
1571
  save_config(g_config)
1873
1572
  init_llms(g_config, g_providers)
1874
1573
  return provider_config, msg
@@ -2193,14 +1892,9 @@ async def text_from_resource_or_url(filename):
2193
1892
 
2194
1893
  async def save_home_configs():
2195
1894
  home_config_path = home_llms_path("llms.json")
1895
+ home_ui_path = home_llms_path("ui.json")
2196
1896
  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
- ):
1897
+ if os.path.exists(home_config_path) and os.path.exists(home_ui_path) and os.path.exists(home_providers_path):
2204
1898
  return
2205
1899
 
2206
1900
  llms_home = os.path.dirname(home_config_path)
@@ -2212,17 +1906,17 @@ async def save_home_configs():
2212
1906
  f.write(config_json)
2213
1907
  _log(f"Created default config at {home_config_path}")
2214
1908
 
1909
+ if not os.path.exists(home_ui_path):
1910
+ ui_json = await text_from_resource_or_url("ui.json")
1911
+ with open(home_ui_path, "w", encoding="utf-8") as f:
1912
+ f.write(ui_json)
1913
+ _log(f"Created default ui config at {home_ui_path}")
1914
+
2215
1915
  if not os.path.exists(home_providers_path):
2216
1916
  providers_json = await text_from_resource_or_url("providers.json")
2217
1917
  with open(home_providers_path, "w", encoding="utf-8") as f:
2218
1918
  f.write(providers_json)
2219
1919
  _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
1920
  except Exception:
2227
1921
  print("Could not create llms.json. Create one with --init or use --config <path>")
2228
1922
  exit(1)
@@ -2259,52 +1953,59 @@ async def reload_providers():
2259
1953
  return g_handlers
2260
1954
 
2261
1955
 
2262
- async def watch_config_files(config_path, providers_path, interval=1):
1956
+ async def watch_config_files(config_path, ui_path, interval=1):
2263
1957
  """Watch config files and reload providers when they change"""
2264
1958
  global g_config
2265
1959
 
2266
1960
  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}")
1961
+ ui_path = Path(ui_path) if ui_path else None
2271
1962
 
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
1963
+ file_mtimes = {}
2282
1964
 
2283
- latest_mtime, name = get_latest_mtime()
1965
+ _log(f"Watching config files: {config_path}" + (f", {ui_path}" if ui_path else ""))
2284
1966
 
2285
1967
  while True:
2286
1968
  await asyncio.sleep(interval)
2287
1969
 
2288
1970
  # Check llms.json
2289
1971
  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
1972
+ if config_path.is_file():
1973
+ mtime = config_path.stat().st_mtime
2294
1974
 
2295
- try:
2296
- # Reload llms.json
2297
- with open(config_path) as f:
2298
- g_config = json.load(f)
1975
+ if str(config_path) not in file_mtimes:
1976
+ file_mtimes[str(config_path)] = mtime
1977
+ elif file_mtimes[str(config_path)] != mtime:
1978
+ _log(f"Config file changed: {config_path.name}")
1979
+ file_mtimes[str(config_path)] = mtime
2299
1980
 
2300
- # Reload providers
2301
- await reload_providers()
2302
- _log("Providers reloaded successfully")
2303
- except Exception as e:
2304
- _log(f"Error reloading config: {e}")
1981
+ try:
1982
+ # Reload llms.json
1983
+ with open(config_path) as f:
1984
+ g_config = json.load(f)
1985
+
1986
+ # Reload providers
1987
+ await reload_providers()
1988
+ _log("Providers reloaded successfully")
1989
+ except Exception as e:
1990
+ _log(f"Error reloading config: {e}")
2305
1991
  except FileNotFoundError:
2306
1992
  pass
2307
1993
 
1994
+ # Check ui.json
1995
+ if ui_path:
1996
+ try:
1997
+ if ui_path.is_file():
1998
+ mtime = ui_path.stat().st_mtime
1999
+
2000
+ if str(ui_path) not in file_mtimes:
2001
+ file_mtimes[str(ui_path)] = mtime
2002
+ elif file_mtimes[str(ui_path)] != mtime:
2003
+ _log(f"Config file changed: {ui_path.name}")
2004
+ file_mtimes[str(ui_path)] = mtime
2005
+ _log("ui.json reloaded - reload page to update")
2006
+ except FileNotFoundError:
2007
+ pass
2008
+
2308
2009
 
2309
2010
  def get_session_token(request):
2310
2011
  return request.query.get("session") or request.headers.get("X-Session-Token") or request.cookies.get("llms-token")
@@ -2318,236 +2019,46 @@ class AppExtensions:
2318
2019
  def __init__(self, cli_args, extra_args):
2319
2020
  self.cli_args = cli_args
2320
2021
  self.extra_args = extra_args
2321
- self.config = None
2322
- self.error_auth_required = create_error_response("Authentication required", "Unauthorized")
2323
2022
  self.ui_extensions = []
2324
2023
  self.chat_request_filters = []
2325
- self.chat_tool_filters = []
2326
2024
  self.chat_response_filters = []
2327
- self.chat_error_filters = []
2328
2025
  self.server_add_get = []
2329
2026
  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
2027
  self.all_providers = [
2357
2028
  OpenAiCompatible,
2029
+ OpenAiProvider,
2030
+ AnthropicProvider,
2358
2031
  MistralProvider,
2359
2032
  GroqProvider,
2360
2033
  XaiProvider,
2361
2034
  CodestralProvider,
2035
+ GoogleProvider,
2362
2036
  OllamaProvider,
2363
2037
  LMStudioProvider,
2364
2038
  ]
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
2039
 
2481
2040
 
2482
2041
  class ExtensionContext:
2483
2042
  def __init__(self, app, path):
2484
2043
  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
2044
  self.path = path
2489
2045
  self.name = os.path.basename(path)
2490
- if self.name.endswith(".py"):
2491
- self.name = self.name[:-3]
2492
2046
  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
2047
 
2524
2048
  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
2049
+ print(f"[{self.name}] {message}", flush=True)
2533
2050
 
2534
2051
  def dbg(self, message):
2535
- if self.debug:
2052
+ if DEBUG:
2536
2053
  print(f"DEBUG [{self.name}]: {message}", flush=True)
2537
2054
 
2538
2055
  def err(self, message, e):
2539
2056
  print(f"ERROR [{self.name}]: {message}", e)
2540
- if self.verbose:
2057
+ if g_verbose:
2541
2058
  print(traceback.format_exc(), flush=True)
2542
2059
 
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
2060
  def add_provider(self, provider):
2550
- self.log(f"Registered provider: {provider.__name__}")
2061
+ self.log(f"Registered provider: {provider}")
2551
2062
  self.app.all_providers.append(provider)
2552
2063
 
2553
2064
  def register_ui_extension(self, index):
@@ -2556,29 +2067,13 @@ class ExtensionContext:
2556
2067
  self.app.ui_extensions.append({"id": self.name, "path": path})
2557
2068
 
2558
2069
  def register_chat_request_filter(self, handler):
2559
- self.log(f"Registered chat request filter: {handler_name(handler)}")
2070
+ self.log(f"Registered chat request filter: {handler}")
2560
2071
  self.app.chat_request_filters.append(handler)
2561
2072
 
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
2073
  def register_chat_response_filter(self, handler):
2567
- self.log(f"Registered chat response filter: {handler_name(handler)}")
2074
+ self.log(f"Registered chat response filter: {handler}")
2568
2075
  self.app.chat_response_filters.append(handler)
2569
2076
 
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
2077
  def add_static_files(self, ext_dir):
2583
2078
  self.log(f"Registered static files: {ext_dir}")
2584
2079
 
@@ -2599,38 +2094,11 @@ class ExtensionContext:
2599
2094
  self.dbg(f"Registered POST: {os.path.join(self.ext_prefix, path)}")
2600
2095
  self.app.server_add_post.append((os.path.join(self.ext_prefix, path), handler, kwargs))
2601
2096
 
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
2097
  def get_config(self):
2624
2098
  return g_config
2625
2099
 
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)
2100
+ def chat_completion(self, chat):
2101
+ return chat_completion(chat)
2634
2102
 
2635
2103
  def get_providers(self):
2636
2104
  return g_handlers
@@ -2638,92 +2106,32 @@ class ExtensionContext:
2638
2106
  def get_provider(self, name):
2639
2107
  return g_handlers.get(name)
2640
2108
 
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
2109
  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)
2110
+ session_token = get_session_token(request)
2661
2111
 
2662
- def should_cancel_thread(self, context):
2663
- return should_cancel_thread(context)
2112
+ if not session_token or session_token not in g_sessions:
2113
+ return None
2664
2114
 
2665
- def cache_message_inline_data(self, message):
2666
- return cache_message_inline_data(message)
2115
+ session_data = g_sessions[session_token]
2116
+ return session_data
2667
2117
 
2668
- def to_content(self, result):
2669
- return to_content(result)
2118
+ def get_username(self, request):
2119
+ session = self.get_session(request)
2120
+ if session:
2121
+ return session.get("userName")
2122
+ return None
2670
2123
 
2671
2124
 
2672
2125
  def get_extensions_path():
2673
- return os.getenv("LLMS_EXTENSIONS_DIR", os.path.join(Path.home(), ".llms", "extensions"))
2126
+ return os.path.join(Path.home(), ".llms", "extensions")
2674
2127
 
2675
2128
 
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
- """
2129
+ def init_extensions(parser):
2689
2130
  extensions_path = get_extensions_path()
2690
2131
  os.makedirs(extensions_path, exist_ok=True)
2691
2132
 
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
-
2133
+ for item in os.listdir(extensions_path):
2134
+ item_path = os.path.join(extensions_path, item)
2727
2135
  if os.path.isdir(item_path):
2728
2136
  try:
2729
2137
  # check for __parser__ function if exists in __init.__.py and call it with parser
@@ -2748,28 +2156,25 @@ def install_extensions():
2748
2156
  Scans ensure ~/.llms/extensions/ for directories with __init__.py and loads them as extensions.
2749
2157
  Calls the `__install__(ctx)` function in the extension module.
2750
2158
  """
2159
+ extensions_path = get_extensions_path()
2160
+ os.makedirs(extensions_path, exist_ok=True)
2751
2161
 
2752
- extension_dirs = get_extensions_dirs()
2753
- ext_count = len(list(extension_dirs))
2162
+ ext_count = len(os.listdir(extensions_path))
2754
2163
  if ext_count == 0:
2755
2164
  _log("No extensions found")
2756
2165
  return
2757
2166
 
2758
- disabled_extensions = get_disabled_extensions()
2759
- if len(disabled_extensions) > 0:
2760
- _log(f"Disabled extensions: {', '.join(disabled_extensions)}")
2761
-
2762
2167
  _log(f"Installing {ext_count} extension{'' if ext_count == 1 else 's'}...")
2763
2168
 
2764
- for item_path in extension_dirs:
2765
- item = os.path.basename(item_path)
2169
+ sys.path.append(extensions_path)
2766
2170
 
2171
+ for item in os.listdir(extensions_path):
2172
+ item_path = os.path.join(extensions_path, item)
2767
2173
  if os.path.isdir(item_path):
2768
- sys.path.append(item_path)
2769
- try:
2174
+ init_file = os.path.join(item_path, "__init__.py")
2175
+ if os.path.exists(init_file):
2770
2176
  ctx = ExtensionContext(g_app, item_path)
2771
- init_file = os.path.join(item_path, "__init__.py")
2772
- if os.path.exists(init_file):
2177
+ try:
2773
2178
  spec = importlib.util.spec_from_file_location(item, init_file)
2774
2179
  if spec and spec.loader:
2775
2180
  module = importlib.util.module_from_spec(spec)
@@ -2784,20 +2189,20 @@ def install_extensions():
2784
2189
  _dbg(f"Extension {item} has no __install__ function")
2785
2190
  else:
2786
2191
  _dbg(f"Extension {item} has no __init__.py")
2787
- else:
2788
- _dbg(f"Extension {init_file} not found")
2789
2192
 
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)
2193
+ # if ui folder exists, serve as static files at /ext/{item}/
2194
+ ui_path = os.path.join(item_path, "ui")
2195
+ if os.path.exists(ui_path):
2196
+ ctx.add_static_files(ui_path)
2794
2197
 
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")
2198
+ # Register UI extension if index.mjs exists (/ext/{item}/index.mjs)
2199
+ if os.path.exists(os.path.join(ui_path, "index.mjs")):
2200
+ ctx.register_ui_extension("index.mjs")
2798
2201
 
2799
- except Exception as e:
2800
- _err(f"Failed to install extension {item}", e)
2202
+ except Exception as e:
2203
+ _err(f"Failed to install extension {item}", e)
2204
+ else:
2205
+ _dbg(f"Extension {init_file} not found")
2801
2206
  else:
2802
2207
  _dbg(f"Extension {item} not found: {item_path} is not a directory {os.path.exists(item_path)}")
2803
2208
 
@@ -2806,9 +2211,11 @@ def run_extension_cli():
2806
2211
  """
2807
2212
  Run the CLI for an extension.
2808
2213
  """
2809
- for item_path in get_extensions_dirs():
2810
- item = os.path.basename(item_path)
2214
+ extensions_path = get_extensions_path()
2215
+ os.makedirs(extensions_path, exist_ok=True)
2811
2216
 
2217
+ for item in os.listdir(extensions_path):
2218
+ item_path = os.path.join(extensions_path, item)
2812
2219
  if os.path.isdir(item_path):
2813
2220
  init_file = os.path.join(item_path, "__init__.py")
2814
2221
  if os.path.exists(init_file):
@@ -2823,8 +2230,8 @@ def run_extension_cli():
2823
2230
  # Check for __run__ function if exists in __init__.py and call it with ctx
2824
2231
  run_func = getattr(module, "__run__", None)
2825
2232
  if callable(run_func):
2826
- _log(f"Running extension {item}...")
2827
2233
  handled = run_func(ctx)
2234
+ _log(f"Extension {item} was run")
2828
2235
  return handled
2829
2236
 
2830
2237
  except Exception as e:
@@ -2833,12 +2240,7 @@ def run_extension_cli():
2833
2240
 
2834
2241
 
2835
2242
  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)
2243
+ global _ROOT, g_verbose, g_default_model, g_logprefix, g_providers, g_config, g_config_path, g_ui_path, g_app
2842
2244
 
2843
2245
  parser = argparse.ArgumentParser(description=f"llms v{VERSION}")
2844
2246
  parser.add_argument("--config", default=None, help="Path to config file", metavar="FILE")
@@ -2849,13 +2251,9 @@ def main():
2849
2251
  parser.add_argument(
2850
2252
  "-s", "--system", default=None, help="System prompt to use for chat completion", metavar="PROMPT"
2851
2253
  )
2852
- parser.add_argument(
2853
- "--tools", default=None, help="Tools to use for chat completion (all|none|<tool>,<tool>...)", metavar="TOOLS"
2854
- )
2855
2254
  parser.add_argument("--image", default=None, help="Image input to use in chat completion")
2856
2255
  parser.add_argument("--audio", default=None, help="Audio input to use in chat completion")
2857
2256
  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
2257
  parser.add_argument(
2860
2258
  "--args",
2861
2259
  default=None,
@@ -2878,8 +2276,9 @@ def main():
2878
2276
  parser.add_argument("--default", default=None, help="Configure the default model to use", metavar="MODEL")
2879
2277
 
2880
2278
  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")
2279
+ parser.add_argument("--update", action="store_true", help="Update local models.dev providers.json")
2882
2280
 
2281
+ parser.add_argument("--root", default=None, help="Change root directory for UI files", metavar="PATH")
2883
2282
  parser.add_argument("--logprefix", default="", help="Prefix used in log messages", metavar="PREFIX")
2884
2283
  parser.add_argument("--verbose", action="store_true", help="Verbose output")
2885
2284
 
@@ -2900,15 +2299,6 @@ def main():
2900
2299
  metavar="EXTENSION",
2901
2300
  )
2902
2301
 
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
2302
  # Load parser extensions, go through all extensions and load their parser arguments
2913
2303
  init_extensions(parser)
2914
2304
 
@@ -2917,7 +2307,7 @@ def main():
2917
2307
  g_app = AppExtensions(cli_args, extra_args)
2918
2308
 
2919
2309
  # Check for verbose mode from CLI argument or environment variables
2920
- verbose_env = os.getenv("VERBOSE", "").lower()
2310
+ verbose_env = os.environ.get("VERBOSE", "").lower()
2921
2311
  if cli_args.verbose or verbose_env in ("1", "true"):
2922
2312
  g_verbose = True
2923
2313
  # printdump(cli_args)
@@ -2926,9 +2316,14 @@ def main():
2926
2316
  if cli_args.logprefix:
2927
2317
  g_logprefix = cli_args.logprefix
2928
2318
 
2319
+ _ROOT = Path(cli_args.root) if cli_args.root else resolve_root()
2320
+ if not _ROOT:
2321
+ print("Resource root not found")
2322
+ exit(1)
2323
+
2929
2324
  home_config_path = home_llms_path("llms.json")
2325
+ home_ui_path = home_llms_path("ui.json")
2930
2326
  home_providers_path = home_llms_path("providers.json")
2931
- home_providers_extra_path = home_llms_path("providers-extra.json")
2932
2327
 
2933
2328
  if cli_args.init:
2934
2329
  if os.path.exists(home_config_path):
@@ -2937,17 +2332,17 @@ def main():
2937
2332
  asyncio.run(save_default_config(home_config_path))
2938
2333
  print(f"Created default config at {home_config_path}")
2939
2334
 
2335
+ if os.path.exists(home_ui_path):
2336
+ print(f"ui.json already exists at {home_ui_path}")
2337
+ else:
2338
+ asyncio.run(save_text_url(github_url("ui.json"), home_ui_path))
2339
+ print(f"Created default ui config at {home_ui_path}")
2340
+
2940
2341
  if os.path.exists(home_providers_path):
2941
2342
  print(f"providers.json already exists at {home_providers_path}")
2942
2343
  else:
2943
2344
  asyncio.run(save_text_url(github_url("providers.json"), home_providers_path))
2944
2345
  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
2346
  exit(0)
2952
2347
 
2953
2348
  if cli_args.providers:
@@ -2964,38 +2359,36 @@ def main():
2964
2359
  g_config = load_config_json(config_json)
2965
2360
 
2966
2361
  config_dir = os.path.dirname(g_config_path)
2362
+ # look for ui.json in same directory as config
2363
+ ui_path = os.path.join(config_dir, "ui.json")
2364
+ if os.path.exists(ui_path):
2365
+ g_ui_path = ui_path
2366
+ else:
2367
+ if not os.path.exists(home_ui_path):
2368
+ ui_json = text_from_resource("ui.json")
2369
+ with open(home_ui_path, "w", encoding="utf-8") as f:
2370
+ f.write(ui_json)
2371
+ _log(f"Created default ui config at {home_ui_path}")
2372
+ g_ui_path = home_ui_path
2967
2373
 
2968
2374
  if not g_providers and os.path.exists(os.path.join(config_dir, "providers.json")):
2969
2375
  g_providers = json.loads(text_from_file(os.path.join(config_dir, "providers.json")))
2970
2376
 
2971
2377
  else:
2972
- # ensure llms.json and providers.json exist in home directory
2378
+ # ensure llms.json and ui.json exist in home directory
2973
2379
  asyncio.run(save_home_configs())
2974
2380
  g_config_path = home_config_path
2381
+ g_ui_path = home_ui_path
2975
2382
  g_config = load_config_json(text_from_file(g_config_path))
2976
2383
 
2977
- g_app.set_config(g_config)
2978
-
2979
2384
  if not g_providers:
2980
2385
  g_providers = json.loads(text_from_file(home_providers_path))
2981
2386
 
2982
- if cli_args.update_providers:
2387
+ if cli_args.update:
2983
2388
  asyncio.run(update_providers(home_providers_path))
2984
2389
  print(f"Updated {home_providers_path}")
2985
2390
  exit(0)
2986
2391
 
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
2392
  if cli_args.add is not None:
3000
2393
  if cli_args.add == "ls":
3001
2394
 
@@ -3044,29 +2437,9 @@ def main():
3044
2437
  requirements_path = os.path.join(target_path, "requirements.txt")
3045
2438
  if os.path.exists(requirements_path):
3046
2439
  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
- )
2440
+ subprocess.run(
2441
+ [sys.executable, "-m", "pip", "install", "-r", "requirements.txt"], cwd=target_path, check=True
2442
+ )
3070
2443
  print("Dependencies installed successfully.")
3071
2444
 
3072
2445
  print(f"Extension {target_name} installed successfully.")
@@ -3111,44 +2484,10 @@ def main():
3111
2484
 
3112
2485
  exit(0)
3113
2486
 
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)
2487
+ asyncio.run(reload_providers())
3147
2488
 
3148
2489
  install_extensions()
3149
2490
 
3150
- asyncio.run(reload_providers())
3151
-
3152
2491
  # print names
3153
2492
  _log(f"enabled providers: {', '.join(g_handlers.keys())}")
3154
2493
 
@@ -3194,14 +2533,14 @@ def main():
3194
2533
  print(f"\n{model_count} models available from {provider_count} providers")
3195
2534
 
3196
2535
  print_status()
3197
- g_app.exit(0)
2536
+ exit(0)
3198
2537
 
3199
2538
  if cli_args.check is not None:
3200
2539
  # Check validity of models for a provider
3201
2540
  provider_name = cli_args.check
3202
2541
  model_names = extra_args if len(extra_args) > 0 else None
3203
2542
  asyncio.run(check_models(provider_name, model_names))
3204
- g_app.exit(0)
2543
+ exit(0)
3205
2544
 
3206
2545
  if cli_args.serve is not None:
3207
2546
  # Disable inactive providers and save to config before starting server
@@ -3221,6 +2560,10 @@ def main():
3221
2560
  # Start server
3222
2561
  port = int(cli_args.serve)
3223
2562
 
2563
+ if not os.path.exists(g_ui_path):
2564
+ print(f"UI not found at {g_ui_path}")
2565
+ exit(1)
2566
+
3224
2567
  # Validate auth configuration if enabled
3225
2568
  auth_enabled = g_config.get("auth", {}).get("enabled", False)
3226
2569
  if auth_enabled:
@@ -3234,8 +2577,8 @@ def main():
3234
2577
  if client_secret.startswith("$"):
3235
2578
  client_secret = client_secret[1:]
3236
2579
 
3237
- client_id = os.getenv(client_id, client_id)
3238
- client_secret = os.getenv(client_secret, client_secret)
2580
+ client_id = os.environ.get(client_id, client_id)
2581
+ client_secret = os.environ.get(client_secret, client_secret)
3239
2582
 
3240
2583
  if (
3241
2584
  not client_id
@@ -3256,25 +2599,74 @@ def main():
3256
2599
  _log(f"client_max_size set to {client_max_size} bytes ({client_max_size / 1024 / 1024:.1f}MB)")
3257
2600
  app = web.Application(client_max_size=client_max_size)
3258
2601
 
2602
+ # Authentication middleware helper
2603
+ def check_auth(request):
2604
+ """Check if request is authenticated. Returns (is_authenticated, user_data)"""
2605
+ if not auth_enabled:
2606
+ return True, None
2607
+
2608
+ # Check for OAuth session token
2609
+ session_token = get_session_token(request)
2610
+ if session_token and session_token in g_sessions:
2611
+ return True, g_sessions[session_token]
2612
+
2613
+ # Check for API key
2614
+ auth_header = request.headers.get("Authorization", "")
2615
+ if auth_header.startswith("Bearer "):
2616
+ api_key = auth_header[7:]
2617
+ if api_key:
2618
+ return True, {"authProvider": "apikey"}
2619
+
2620
+ return False, None
2621
+
3259
2622
  async def chat_handler(request):
3260
2623
  # Check authentication if enabled
3261
- is_authenticated, user_data = g_app.check_auth(request)
2624
+ is_authenticated, user_data = check_auth(request)
3262
2625
  if not is_authenticated:
3263
- return web.json_response(g_app.error_auth_required, status=401)
2626
+ return web.json_response(
2627
+ {
2628
+ "error": {
2629
+ "message": "Authentication required",
2630
+ "type": "authentication_error",
2631
+ "code": "unauthorized",
2632
+ }
2633
+ },
2634
+ status=401,
2635
+ )
3264
2636
 
3265
2637
  try:
3266
2638
  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)
2639
+
2640
+ # Apply pre-chat filters
2641
+ context = {"request": request}
2642
+ # Apply pre-chat filters
2643
+ context = {"request": request}
2644
+ for filter_func in g_app.chat_request_filters:
2645
+ chat = await filter_func(chat, context)
2646
+
2647
+ response = await chat_completion(chat)
2648
+
2649
+ # Apply post-chat filters
2650
+ # Apply post-chat filters
2651
+ for filter_func in g_app.chat_response_filters:
2652
+ response = await filter_func(response, context)
2653
+
3272
2654
  return web.json_response(response)
3273
2655
  except Exception as e:
3274
- return web.json_response(to_error_response(e), status=500)
2656
+ return web.json_response({"error": str(e)}, status=500)
3275
2657
 
3276
2658
  app.router.add_post("/v1/chat/completions", chat_handler)
3277
2659
 
2660
+ async def extensions_handler(request):
2661
+ return web.json_response(g_app.ui_extensions)
2662
+
2663
+ app.router.add_get("/ext", extensions_handler)
2664
+
2665
+ async def models_handler(request):
2666
+ return web.json_response(get_models())
2667
+
2668
+ app.router.add_get("/models/list", models_handler)
2669
+
3278
2670
  async def active_models_handler(request):
3279
2671
  return web.json_response(get_active_models())
3280
2672
 
@@ -3304,9 +2696,8 @@ def main():
3304
2696
  if provider:
3305
2697
  if data.get("enable", False):
3306
2698
  provider_config, msg = enable_provider(provider)
3307
- _log(f"Enabled provider {provider} {msg}")
3308
- if not msg:
3309
- await load_llms()
2699
+ _log(f"Enabled provider {provider}")
2700
+ await load_llms()
3310
2701
  elif data.get("disable", False):
3311
2702
  disable_provider(provider)
3312
2703
  _log(f"Disabled provider {provider}")
@@ -3323,9 +2714,18 @@ def main():
3323
2714
 
3324
2715
  async def upload_handler(request):
3325
2716
  # Check authentication if enabled
3326
- is_authenticated, user_data = g_app.check_auth(request)
2717
+ is_authenticated, user_data = check_auth(request)
3327
2718
  if not is_authenticated:
3328
- return web.json_response(g_app.error_auth_required, status=401)
2719
+ return web.json_response(
2720
+ {
2721
+ "error": {
2722
+ "message": "Authentication required",
2723
+ "type": "authentication_error",
2724
+ "code": "unauthorized",
2725
+ }
2726
+ },
2727
+ status=401,
2728
+ )
3329
2729
 
3330
2730
  reader = await request.multipart()
3331
2731
 
@@ -3335,7 +2735,7 @@ def main():
3335
2735
  field = await reader.next()
3336
2736
 
3337
2737
  if not field:
3338
- return web.json_response(create_error_response("No file provided"), status=400)
2738
+ return web.json_response({"error": "No file provided"}, status=400)
3339
2739
 
3340
2740
  filename = field.filename or "file"
3341
2741
  content = await field.read()
@@ -3373,10 +2773,9 @@ def main():
3373
2773
  with open(full_path, "wb") as f:
3374
2774
  f.write(content)
3375
2775
 
3376
- url = f"/~cache/{relative_path}"
3377
2776
  response_data = {
3378
2777
  "date": int(time.time()),
3379
- "url": url,
2778
+ "url": f"/~cache/{relative_path}",
3380
2779
  "size": len(content),
3381
2780
  "type": mimetype,
3382
2781
  "name": filename,
@@ -3396,22 +2795,10 @@ def main():
3396
2795
  with open(info_path, "w") as f:
3397
2796
  json.dump(response_data, f)
3398
2797
 
3399
- g_app.on_cache_saved_filters({"url": url, "info": response_data})
3400
-
3401
2798
  return web.json_response(response_data)
3402
2799
 
3403
2800
  app.router.add_post("/upload", upload_handler)
3404
2801
 
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
2802
  async def cache_handler(request):
3416
2803
  path = request.match_info["tail"]
3417
2804
  full_path = get_cache_path(path)
@@ -3423,7 +2810,7 @@ def main():
3423
2810
 
3424
2811
  # Check for directory traversal for info path
3425
2812
  try:
3426
- cache_root = Path(get_cache_path())
2813
+ cache_root = Path(get_cache_path(""))
3427
2814
  requested_path = Path(info_path).resolve()
3428
2815
  if not str(requested_path).startswith(str(cache_root)):
3429
2816
  return web.Response(text="403: Forbidden", status=403)
@@ -3439,7 +2826,7 @@ def main():
3439
2826
 
3440
2827
  # Check for directory traversal
3441
2828
  try:
3442
- cache_root = Path(get_cache_path())
2829
+ cache_root = Path(get_cache_path(""))
3443
2830
  requested_path = Path(full_path).resolve()
3444
2831
  if not str(requested_path).startswith(str(cache_root)):
3445
2832
  return web.Response(text="403: Forbidden", status=403)
@@ -3458,7 +2845,7 @@ def main():
3458
2845
  async def github_auth_handler(request):
3459
2846
  """Initiate GitHub OAuth flow"""
3460
2847
  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)
2848
+ return web.json_response({"error": "GitHub OAuth not configured"}, status=500)
3462
2849
 
3463
2850
  auth_config = g_config["auth"]["github"]
3464
2851
  client_id = auth_config.get("client_id", "")
@@ -3470,11 +2857,11 @@ def main():
3470
2857
  if redirect_uri.startswith("$"):
3471
2858
  redirect_uri = redirect_uri[1:]
3472
2859
 
3473
- client_id = os.getenv(client_id, client_id)
3474
- redirect_uri = os.getenv(redirect_uri, redirect_uri)
2860
+ client_id = os.environ.get(client_id, client_id)
2861
+ redirect_uri = os.environ.get(redirect_uri, redirect_uri)
3475
2862
 
3476
2863
  if not client_id:
3477
- return web.json_response(create_error_response("GitHub client_id not configured"), status=500)
2864
+ return web.json_response({"error": "GitHub client_id not configured"}, status=500)
3478
2865
 
3479
2866
  # Generate CSRF state token
3480
2867
  state = secrets.token_urlsafe(32)
@@ -3506,7 +2893,7 @@ def main():
3506
2893
  if restrict_to.startswith("$"):
3507
2894
  restrict_to = restrict_to[1:]
3508
2895
 
3509
- restrict_to = os.getenv(restrict_to, None if restrict_to == "GITHUB_USERS" else restrict_to)
2896
+ restrict_to = os.environ.get(restrict_to, None if restrict_to == "GITHUB_USERS" else restrict_to)
3510
2897
 
3511
2898
  # If restrict_to is configured, validate the user
3512
2899
  if restrict_to:
@@ -3545,7 +2932,7 @@ def main():
3545
2932
  g_oauth_states.pop(state)
3546
2933
 
3547
2934
  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)
2935
+ return web.json_response({"error": "GitHub OAuth not configured"}, status=500)
3549
2936
 
3550
2937
  auth_config = g_config["auth"]["github"]
3551
2938
  client_id = auth_config.get("client_id", "")
@@ -3560,12 +2947,12 @@ def main():
3560
2947
  if redirect_uri.startswith("$"):
3561
2948
  redirect_uri = redirect_uri[1:]
3562
2949
 
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)
2950
+ client_id = os.environ.get(client_id, client_id)
2951
+ client_secret = os.environ.get(client_secret, client_secret)
2952
+ redirect_uri = os.environ.get(redirect_uri, redirect_uri)
3566
2953
 
3567
2954
  if not client_id or not client_secret:
3568
- return web.json_response(create_error_response("GitHub OAuth credentials not configured"), status=500)
2955
+ return web.json_response({"error": "GitHub OAuth credentials not configured"}, status=500)
3569
2956
 
3570
2957
  # Exchange code for access token
3571
2958
  async with aiohttp.ClientSession() as session:
@@ -3584,7 +2971,7 @@ def main():
3584
2971
 
3585
2972
  if not access_token:
3586
2973
  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)
2974
+ return web.Response(text=f"OAuth error: {error}", status=400)
3588
2975
 
3589
2976
  # Fetch user info
3590
2977
  user_url = "https://api.github.com/user"
@@ -3619,7 +3006,7 @@ def main():
3619
3006
  session_token = get_session_token(request)
3620
3007
 
3621
3008
  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)
3009
+ return web.json_response({"error": "Invalid or expired session"}, status=401)
3623
3010
 
3624
3011
  session_data = g_sessions[session_token]
3625
3012
 
@@ -3675,7 +3062,9 @@ def main():
3675
3062
  # })
3676
3063
 
3677
3064
  # Not authenticated - return error in expected format
3678
- return web.json_response(g_app.error_auth_required, status=401)
3065
+ return web.json_response(
3066
+ {"responseStatus": {"errorCode": "Unauthorized", "message": "Not authenticated"}}, status=401
3067
+ )
3679
3068
 
3680
3069
  app.router.add_get("/auth", auth_handler)
3681
3070
  app.router.add_get("/auth/github", github_auth_handler)
@@ -3715,18 +3104,19 @@ def main():
3715
3104
 
3716
3105
  app.router.add_get("/ui/{path:.*}", ui_static, name="ui_static")
3717
3106
 
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)
3107
+ async def ui_config_handler(request):
3108
+ with open(g_ui_path, encoding="utf-8") as f:
3109
+ ui = json.load(f)
3110
+ if "defaults" not in ui:
3111
+ ui["defaults"] = g_config["defaults"]
3112
+ enabled, disabled = provider_status()
3113
+ ui["status"] = {"all": list(g_config["providers"].keys()), "enabled": enabled, "disabled": disabled}
3114
+ # Add auth configuration
3115
+ ui["requiresAuth"] = auth_enabled
3116
+ ui["authType"] = "oauth" if auth_enabled else "apikey"
3117
+ return web.json_response(ui)
3728
3118
 
3729
- app.router.add_get("/config", config_handler)
3119
+ app.router.add_get("/config", ui_config_handler)
3730
3120
 
3731
3121
  async def not_found_handler(request):
3732
3122
  return web.Response(text="404: Not Found", status=404)
@@ -3735,81 +3125,15 @@ def main():
3735
3125
 
3736
3126
  # go through and register all g_app extensions
3737
3127
  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])
3128
+ app.router.add_get(handler[0], handler[1], **handler[2])
3747
3129
  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])
3130
+ app.router.add_post(handler[0], handler[1], **handler[2])
3787
3131
 
3788
3132
  # Serve index.html from root
3789
3133
  async def index_handler(request):
3790
3134
  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
-
3135
+ if index_content is None:
3136
+ raise web.HTTPNotFound
3813
3137
  return web.Response(body=index_content, content_type="text/html")
3814
3138
 
3815
3139
  app.router.add_get("/", index_handler)
@@ -3821,7 +3145,7 @@ def main():
3821
3145
  async def start_background_tasks(app):
3822
3146
  """Start background tasks when the app starts"""
3823
3147
  # Start watching config files in the background
3824
- asyncio.create_task(watch_config_files(g_config_path, home_providers_path))
3148
+ asyncio.create_task(watch_config_files(g_config_path, g_ui_path))
3825
3149
 
3826
3150
  app.on_startup.append(start_background_tasks)
3827
3151
 
@@ -3829,7 +3153,7 @@ def main():
3829
3153
 
3830
3154
  print(f"Starting server on port {port}...")
3831
3155
  web.run_app(app, host="0.0.0.0", port=port, print=_log)
3832
- g_app.exit(0)
3156
+ exit(0)
3833
3157
 
3834
3158
  if cli_args.enable is not None:
3835
3159
  if cli_args.enable.endswith(","):
@@ -3846,7 +3170,7 @@ def main():
3846
3170
 
3847
3171
  for provider in enable_providers:
3848
3172
  if provider not in g_config["providers"]:
3849
- print(f"Provider '{provider}' not found")
3173
+ print(f"Provider {provider} not found")
3850
3174
  print(f"Available providers: {', '.join(g_config['providers'].keys())}")
3851
3175
  exit(1)
3852
3176
  if provider in g_config["providers"]:
@@ -3859,7 +3183,7 @@ def main():
3859
3183
  print_status()
3860
3184
  if len(msgs) > 0:
3861
3185
  print("\n" + "\n".join(msgs))
3862
- g_app.exit(0)
3186
+ exit(0)
3863
3187
 
3864
3188
  if cli_args.disable is not None:
3865
3189
  if cli_args.disable.endswith(","):
@@ -3882,7 +3206,7 @@ def main():
3882
3206
  print(f"\nDisabled provider {provider}")
3883
3207
 
3884
3208
  print_status()
3885
- g_app.exit(0)
3209
+ exit(0)
3886
3210
 
3887
3211
  if cli_args.default is not None:
3888
3212
  default_model = cli_args.default
@@ -3894,14 +3218,13 @@ def main():
3894
3218
  default_text["model"] = default_model
3895
3219
  save_config(g_config)
3896
3220
  print(f"\nDefault model set to: {default_model}")
3897
- g_app.exit(0)
3221
+ exit(0)
3898
3222
 
3899
3223
  if (
3900
3224
  cli_args.chat is not None
3901
3225
  or cli_args.image is not None
3902
3226
  or cli_args.audio is not None
3903
3227
  or cli_args.file is not None
3904
- or cli_args.out is not None
3905
3228
  or len(extra_args) > 0
3906
3229
  ):
3907
3230
  try:
@@ -3912,12 +3235,6 @@ def main():
3912
3235
  chat = g_config["defaults"]["audio"]
3913
3236
  elif cli_args.file is not None:
3914
3237
  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
3238
  if cli_args.chat is not None:
3922
3239
  chat_path = os.path.join(os.path.dirname(__file__), cli_args.chat)
3923
3240
  if not os.path.exists(chat_path):
@@ -3934,9 +3251,6 @@ def main():
3934
3251
 
3935
3252
  if len(extra_args) > 0:
3936
3253
  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
3254
  # replace content of last message if exists, else add
3941
3255
  last_msg = chat["messages"][-1] if "messages" in chat else None
3942
3256
  if last_msg and last_msg["role"] == "user":
@@ -3954,31 +3268,22 @@ def main():
3954
3268
 
3955
3269
  asyncio.run(
3956
3270
  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,
3271
+ chat, image=cli_args.image, audio=cli_args.audio, file=cli_args.file, args=args, raw=cli_args.raw
3964
3272
  )
3965
3273
  )
3966
- g_app.exit(0)
3274
+ exit(0)
3967
3275
  except Exception as e:
3968
3276
  print(f"{cli_args.logprefix}Error: {e}")
3969
3277
  if cli_args.verbose:
3970
3278
  traceback.print_exc()
3971
- g_app.exit(1)
3279
+ exit(1)
3972
3280
 
3973
3281
  handled = run_extension_cli()
3974
3282
 
3975
3283
  if not handled:
3976
3284
  # show usage from ArgumentParser
3977
3285
  parser.print_help()
3978
- g_app.exit(0)
3979
3286
 
3980
3287
 
3981
3288
  if __name__ == "__main__":
3982
- if MOCK or DEBUG:
3983
- print(f"MOCK={MOCK} or DEBUG={DEBUG}")
3984
3289
  main()