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