llms-py 3.0.0b1__py3-none-any.whl → 3.0.0b2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/index.html +25 -56
- llms/llms.json +2 -2
- llms/main.py +452 -93
- llms/providers.json +1 -1
- llms/ui/App.mjs +25 -4
- llms/ui/Avatar.mjs +3 -2
- llms/ui/ChatPrompt.mjs +43 -52
- llms/ui/Main.mjs +87 -98
- llms/ui/OAuthSignIn.mjs +2 -33
- llms/ui/ProviderStatus.mjs +7 -8
- llms/ui/Recents.mjs +10 -9
- llms/ui/Sidebar.mjs +2 -1
- llms/ui/SignIn.mjs +7 -6
- llms/ui/ai.mjs +9 -41
- llms/ui/app.css +137 -138
- llms/ui/index.mjs +213 -0
- llms/ui/{ModelSelector.mjs → model-selector.mjs} +193 -200
- llms/ui/tailwind.input.css +441 -79
- llms/ui/threadStore.mjs +17 -6
- llms/ui/utils.mjs +1 -0
- {llms_py-3.0.0b1.dist-info → llms_py-3.0.0b2.dist-info}/METADATA +1 -1
- llms_py-3.0.0b2.dist-info/RECORD +58 -0
- llms/ui/SystemPromptEditor.mjs +0 -31
- llms/ui/SystemPromptSelector.mjs +0 -56
- llms_py-3.0.0b1.dist-info/RECORD +0 -49
- {llms_py-3.0.0b1.dist-info → llms_py-3.0.0b2.dist-info}/WHEEL +0 -0
- {llms_py-3.0.0b1.dist-info → llms_py-3.0.0b2.dist-info}/entry_points.txt +0 -0
- {llms_py-3.0.0b1.dist-info → llms_py-3.0.0b2.dist-info}/licenses/LICENSE +0 -0
- {llms_py-3.0.0b1.dist-info → llms_py-3.0.0b2.dist-info}/top_level.txt +0 -0
llms/main.py
CHANGED
|
@@ -9,18 +9,20 @@
|
|
|
9
9
|
import argparse
|
|
10
10
|
import asyncio
|
|
11
11
|
import base64
|
|
12
|
-
from datetime import datetime
|
|
13
12
|
import hashlib
|
|
13
|
+
import importlib.util
|
|
14
14
|
import json
|
|
15
15
|
import mimetypes
|
|
16
16
|
import os
|
|
17
17
|
import re
|
|
18
18
|
import secrets
|
|
19
|
+
import shutil
|
|
19
20
|
import site
|
|
20
21
|
import subprocess
|
|
21
22
|
import sys
|
|
22
23
|
import time
|
|
23
24
|
import traceback
|
|
25
|
+
from datetime import datetime
|
|
24
26
|
from importlib import resources # Py≥3.9 (pip install importlib_resources for 3.7/3.8)
|
|
25
27
|
from io import BytesIO
|
|
26
28
|
from pathlib import Path
|
|
@@ -36,8 +38,9 @@ try:
|
|
|
36
38
|
except ImportError:
|
|
37
39
|
HAS_PIL = False
|
|
38
40
|
|
|
39
|
-
VERSION = "3.0.
|
|
41
|
+
VERSION = "3.0.0b2"
|
|
40
42
|
_ROOT = None
|
|
43
|
+
DEBUG = True # os.getenv("PYPI_SERVICESTACK") is not None
|
|
41
44
|
g_config_path = None
|
|
42
45
|
g_ui_path = None
|
|
43
46
|
g_config = None
|
|
@@ -48,14 +51,25 @@ g_logprefix = ""
|
|
|
48
51
|
g_default_model = ""
|
|
49
52
|
g_sessions = {} # OAuth session storage: {session_token: {userId, userName, displayName, profileUrl, email, created}}
|
|
50
53
|
g_oauth_states = {} # CSRF protection: {state: {created, redirect_uri}}
|
|
54
|
+
g_app = None # ExtensionsContext Singleton
|
|
51
55
|
|
|
52
56
|
|
|
53
57
|
def _log(message):
|
|
54
|
-
"""Helper method for logging from the global polling task."""
|
|
55
58
|
if g_verbose:
|
|
56
59
|
print(f"{g_logprefix}{message}", flush=True)
|
|
57
60
|
|
|
58
61
|
|
|
62
|
+
def _dbg(message):
|
|
63
|
+
if DEBUG:
|
|
64
|
+
print(f"DEBUG: {message}", flush=True)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _err(message, e):
|
|
68
|
+
print(f"ERROR: {message}: {e}", flush=True)
|
|
69
|
+
if g_verbose:
|
|
70
|
+
print(traceback.format_exc(), flush=True)
|
|
71
|
+
|
|
72
|
+
|
|
59
73
|
def printdump(obj):
|
|
60
74
|
args = obj.__dict__ if hasattr(obj, "__dict__") else obj
|
|
61
75
|
print(json.dumps(args, indent=2))
|
|
@@ -581,55 +595,6 @@ class OpenAiCompatible:
|
|
|
581
595
|
return self.provider_model(last_part)
|
|
582
596
|
return None
|
|
583
597
|
|
|
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
|
-
)
|
|
632
|
-
|
|
633
598
|
def to_response(self, response, chat, started_at):
|
|
634
599
|
if "metadata" not in response:
|
|
635
600
|
response["metadata"] = {}
|
|
@@ -644,8 +609,6 @@ class OpenAiCompatible:
|
|
|
644
609
|
async def chat(self, chat):
|
|
645
610
|
chat["model"] = self.provider_model(chat["model"]) or chat["model"]
|
|
646
611
|
|
|
647
|
-
self.validate_modalities(chat)
|
|
648
|
-
|
|
649
612
|
# with open(os.path.join(os.path.dirname(__file__), 'chat.wip.json'), "w") as f:
|
|
650
613
|
# f.write(json.dumps(chat, indent=2))
|
|
651
614
|
|
|
@@ -941,11 +904,10 @@ class OllamaProvider(OpenAiCompatible):
|
|
|
941
904
|
) as response:
|
|
942
905
|
data = await response_json(response)
|
|
943
906
|
for model in data.get("models", []):
|
|
944
|
-
|
|
945
|
-
if
|
|
946
|
-
|
|
947
|
-
model_id =
|
|
948
|
-
ret[model_id] = name
|
|
907
|
+
model_id = model["model"]
|
|
908
|
+
if model_id.endswith(":latest"):
|
|
909
|
+
model_id = model_id[:-7]
|
|
910
|
+
ret[model_id] = model_id
|
|
949
911
|
_log(f"Loaded Ollama models: {ret}")
|
|
950
912
|
except Exception as e:
|
|
951
913
|
_log(f"Error getting Ollama models: {e}")
|
|
@@ -1228,20 +1190,6 @@ class GoogleProvider(OpenAiCompatible):
|
|
|
1228
1190
|
return self.to_response(response, chat, started_at)
|
|
1229
1191
|
|
|
1230
1192
|
|
|
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
|
-
|
|
1245
1193
|
def get_provider_model(model_name):
|
|
1246
1194
|
for provider in g_handlers.values():
|
|
1247
1195
|
provider_model = provider.provider_model(model_name)
|
|
@@ -1487,7 +1435,7 @@ def create_provider(provider):
|
|
|
1487
1435
|
_log(f"Provider {provider_label} is missing 'npm' sdk")
|
|
1488
1436
|
return None
|
|
1489
1437
|
|
|
1490
|
-
for provider_type in
|
|
1438
|
+
for provider_type in g_app.all_providers:
|
|
1491
1439
|
if provider_type.sdk == npm_sdk:
|
|
1492
1440
|
kwargs = create_provider_kwargs(provider)
|
|
1493
1441
|
return provider_type(**kwargs)
|
|
@@ -2059,8 +2007,240 @@ async def watch_config_files(config_path, ui_path, interval=1):
|
|
|
2059
2007
|
pass
|
|
2060
2008
|
|
|
2061
2009
|
|
|
2010
|
+
def get_session_token(request):
|
|
2011
|
+
return request.query.get("session") or request.headers.get("X-Session-Token") or request.cookies.get("llms-token")
|
|
2012
|
+
|
|
2013
|
+
|
|
2014
|
+
class AppExtensions:
|
|
2015
|
+
"""
|
|
2016
|
+
APIs extensions can use to extend the app
|
|
2017
|
+
"""
|
|
2018
|
+
|
|
2019
|
+
def __init__(self, cli_args, extra_args):
|
|
2020
|
+
self.cli_args = cli_args
|
|
2021
|
+
self.extra_args = extra_args
|
|
2022
|
+
self.ui_extensions = []
|
|
2023
|
+
self.chat_request_filters = []
|
|
2024
|
+
self.chat_response_filters = []
|
|
2025
|
+
self.server_add_get = []
|
|
2026
|
+
self.server_add_post = []
|
|
2027
|
+
self.all_providers = [
|
|
2028
|
+
OpenAiCompatible,
|
|
2029
|
+
OpenAiProvider,
|
|
2030
|
+
AnthropicProvider,
|
|
2031
|
+
MistralProvider,
|
|
2032
|
+
GroqProvider,
|
|
2033
|
+
XaiProvider,
|
|
2034
|
+
CodestralProvider,
|
|
2035
|
+
GoogleProvider,
|
|
2036
|
+
OllamaProvider,
|
|
2037
|
+
LMStudioProvider,
|
|
2038
|
+
]
|
|
2039
|
+
|
|
2040
|
+
|
|
2041
|
+
class ExtensionContext:
|
|
2042
|
+
def __init__(self, app, path):
|
|
2043
|
+
self.app = app
|
|
2044
|
+
self.path = path
|
|
2045
|
+
self.name = os.path.basename(path)
|
|
2046
|
+
self.ext_prefix = f"/ext/{self.name}"
|
|
2047
|
+
|
|
2048
|
+
def log(self, message):
|
|
2049
|
+
print(f"[{self.name}] {message}", flush=True)
|
|
2050
|
+
|
|
2051
|
+
def dbg(self, message):
|
|
2052
|
+
if DEBUG:
|
|
2053
|
+
print(f"DEBUG [{self.name}]: {message}", flush=True)
|
|
2054
|
+
|
|
2055
|
+
def err(self, message, e):
|
|
2056
|
+
print(f"ERROR [{self.name}]: {message}", e)
|
|
2057
|
+
if g_verbose:
|
|
2058
|
+
print(traceback.format_exc(), flush=True)
|
|
2059
|
+
|
|
2060
|
+
def add_provider(self, provider):
|
|
2061
|
+
self.log(f"Registered provider: {provider}")
|
|
2062
|
+
self.app.all_providers.append(provider)
|
|
2063
|
+
|
|
2064
|
+
def register_ui_extension(self, index):
|
|
2065
|
+
path = os.path.join(self.ext_prefix, index)
|
|
2066
|
+
self.log(f"Registered UI extension: {path}")
|
|
2067
|
+
self.app.ui_extensions.append({"id": self.name, "path": path})
|
|
2068
|
+
|
|
2069
|
+
def register_chat_request_filter(self, handler):
|
|
2070
|
+
self.log(f"Registered chat request filter: {handler}")
|
|
2071
|
+
self.app.chat_request_filters.append(handler)
|
|
2072
|
+
|
|
2073
|
+
def register_chat_response_filter(self, handler):
|
|
2074
|
+
self.log(f"Registered chat response filter: {handler}")
|
|
2075
|
+
self.app.chat_response_filters.append(handler)
|
|
2076
|
+
|
|
2077
|
+
def add_static_files(self, ext_dir):
|
|
2078
|
+
self.log(f"Registered static files: {ext_dir}")
|
|
2079
|
+
|
|
2080
|
+
async def serve_static(request):
|
|
2081
|
+
path = request.match_info["path"]
|
|
2082
|
+
file_path = os.path.join(ext_dir, path)
|
|
2083
|
+
if os.path.exists(file_path):
|
|
2084
|
+
return web.FileResponse(file_path)
|
|
2085
|
+
return web.Response(status=404)
|
|
2086
|
+
|
|
2087
|
+
self.app.server_add_get.append((os.path.join(self.ext_prefix, "{path:.*}"), serve_static, {}))
|
|
2088
|
+
|
|
2089
|
+
def add_get(self, path, handler, **kwargs):
|
|
2090
|
+
self.dbg(f"Registered GET: {os.path.join(self.ext_prefix, path)}")
|
|
2091
|
+
self.app.server_add_get.append((os.path.join(self.ext_prefix, path), handler, kwargs))
|
|
2092
|
+
|
|
2093
|
+
def add_post(self, path, handler, **kwargs):
|
|
2094
|
+
self.dbg(f"Registered POST: {os.path.join(self.ext_prefix, path)}")
|
|
2095
|
+
self.app.server_add_post.append((os.path.join(self.ext_prefix, path), handler, kwargs))
|
|
2096
|
+
|
|
2097
|
+
def get_config(self):
|
|
2098
|
+
return g_config
|
|
2099
|
+
|
|
2100
|
+
def chat_completion(self, chat):
|
|
2101
|
+
return chat_completion(chat)
|
|
2102
|
+
|
|
2103
|
+
def get_providers(self):
|
|
2104
|
+
return g_handlers
|
|
2105
|
+
|
|
2106
|
+
def get_provider(self, name):
|
|
2107
|
+
return g_handlers.get(name)
|
|
2108
|
+
|
|
2109
|
+
def get_session(self, request):
|
|
2110
|
+
session_token = get_session_token(request)
|
|
2111
|
+
|
|
2112
|
+
if not session_token or session_token not in g_sessions:
|
|
2113
|
+
return None
|
|
2114
|
+
|
|
2115
|
+
session_data = g_sessions[session_token]
|
|
2116
|
+
return session_data
|
|
2117
|
+
|
|
2118
|
+
def get_username(self, request):
|
|
2119
|
+
session = self.get_session(request)
|
|
2120
|
+
if session:
|
|
2121
|
+
return session.get("userName")
|
|
2122
|
+
return None
|
|
2123
|
+
|
|
2124
|
+
|
|
2125
|
+
def get_extensions_path():
|
|
2126
|
+
return os.path.join(Path.home(), ".llms", "extensions")
|
|
2127
|
+
|
|
2128
|
+
|
|
2129
|
+
def init_extensions(parser):
|
|
2130
|
+
extensions_path = get_extensions_path()
|
|
2131
|
+
os.makedirs(extensions_path, exist_ok=True)
|
|
2132
|
+
|
|
2133
|
+
for item in os.listdir(extensions_path):
|
|
2134
|
+
item_path = os.path.join(extensions_path, item)
|
|
2135
|
+
if os.path.isdir(item_path):
|
|
2136
|
+
try:
|
|
2137
|
+
# check for __parser__ function if exists in __init.__.py and call it with parser
|
|
2138
|
+
init_file = os.path.join(item_path, "__init__.py")
|
|
2139
|
+
if os.path.exists(init_file):
|
|
2140
|
+
spec = importlib.util.spec_from_file_location(item, init_file)
|
|
2141
|
+
if spec and spec.loader:
|
|
2142
|
+
module = importlib.util.module_from_spec(spec)
|
|
2143
|
+
sys.modules[item] = module
|
|
2144
|
+
spec.loader.exec_module(module)
|
|
2145
|
+
|
|
2146
|
+
parser_func = getattr(module, "__parser__", None)
|
|
2147
|
+
if callable(parser_func):
|
|
2148
|
+
parser_func(parser)
|
|
2149
|
+
_log(f"Extension {item} parser loaded")
|
|
2150
|
+
except Exception as e:
|
|
2151
|
+
_err(f"Failed to load extension {item} parser", e)
|
|
2152
|
+
|
|
2153
|
+
|
|
2154
|
+
def install_extensions():
|
|
2155
|
+
"""
|
|
2156
|
+
Scans ensure ~/.llms/extensions/ for directories with __init__.py and loads them as extensions.
|
|
2157
|
+
Calls the `__install__(ctx)` function in the extension module.
|
|
2158
|
+
"""
|
|
2159
|
+
extensions_path = get_extensions_path()
|
|
2160
|
+
os.makedirs(extensions_path, exist_ok=True)
|
|
2161
|
+
|
|
2162
|
+
ext_count = len(os.listdir(extensions_path))
|
|
2163
|
+
if ext_count == 0:
|
|
2164
|
+
_log("No extensions found")
|
|
2165
|
+
return
|
|
2166
|
+
|
|
2167
|
+
_log(f"Installing {ext_count} extension{'' if ext_count == 1 else 's'}...")
|
|
2168
|
+
|
|
2169
|
+
sys.path.append(extensions_path)
|
|
2170
|
+
|
|
2171
|
+
for item in os.listdir(extensions_path):
|
|
2172
|
+
item_path = os.path.join(extensions_path, item)
|
|
2173
|
+
if os.path.isdir(item_path):
|
|
2174
|
+
init_file = os.path.join(item_path, "__init__.py")
|
|
2175
|
+
if os.path.exists(init_file):
|
|
2176
|
+
ctx = ExtensionContext(g_app, item_path)
|
|
2177
|
+
try:
|
|
2178
|
+
spec = importlib.util.spec_from_file_location(item, init_file)
|
|
2179
|
+
if spec and spec.loader:
|
|
2180
|
+
module = importlib.util.module_from_spec(spec)
|
|
2181
|
+
sys.modules[item] = module
|
|
2182
|
+
spec.loader.exec_module(module)
|
|
2183
|
+
|
|
2184
|
+
install_func = getattr(module, "__install__", None)
|
|
2185
|
+
if callable(install_func):
|
|
2186
|
+
install_func(ctx)
|
|
2187
|
+
_log(f"Extension {item} installed")
|
|
2188
|
+
else:
|
|
2189
|
+
_dbg(f"Extension {item} has no __install__ function")
|
|
2190
|
+
else:
|
|
2191
|
+
_dbg(f"Extension {item} has no __init__.py")
|
|
2192
|
+
|
|
2193
|
+
# if ui folder exists, serve as static files at /ext/{item}/
|
|
2194
|
+
ui_path = os.path.join(item_path, "ui")
|
|
2195
|
+
if os.path.exists(ui_path):
|
|
2196
|
+
ctx.add_static_files(ui_path)
|
|
2197
|
+
|
|
2198
|
+
# Register UI extension if index.mjs exists (/ext/{item}/index.mjs)
|
|
2199
|
+
if os.path.exists(os.path.join(ui_path, "index.mjs")):
|
|
2200
|
+
ctx.register_ui_extension("index.mjs")
|
|
2201
|
+
|
|
2202
|
+
except Exception as e:
|
|
2203
|
+
_err(f"Failed to install extension {item}", e)
|
|
2204
|
+
else:
|
|
2205
|
+
_dbg(f"Extension {init_file} not found")
|
|
2206
|
+
else:
|
|
2207
|
+
_dbg(f"Extension {item} not found: {item_path} is not a directory {os.path.exists(item_path)}")
|
|
2208
|
+
|
|
2209
|
+
|
|
2210
|
+
def run_extension_cli():
|
|
2211
|
+
"""
|
|
2212
|
+
Run the CLI for an extension.
|
|
2213
|
+
"""
|
|
2214
|
+
extensions_path = get_extensions_path()
|
|
2215
|
+
os.makedirs(extensions_path, exist_ok=True)
|
|
2216
|
+
|
|
2217
|
+
for item in os.listdir(extensions_path):
|
|
2218
|
+
item_path = os.path.join(extensions_path, item)
|
|
2219
|
+
if os.path.isdir(item_path):
|
|
2220
|
+
init_file = os.path.join(item_path, "__init__.py")
|
|
2221
|
+
if os.path.exists(init_file):
|
|
2222
|
+
ctx = ExtensionContext(g_app, item_path)
|
|
2223
|
+
try:
|
|
2224
|
+
spec = importlib.util.spec_from_file_location(item, init_file)
|
|
2225
|
+
if spec and spec.loader:
|
|
2226
|
+
module = importlib.util.module_from_spec(spec)
|
|
2227
|
+
sys.modules[item] = module
|
|
2228
|
+
spec.loader.exec_module(module)
|
|
2229
|
+
|
|
2230
|
+
# Check for __run__ function if exists in __init__.py and call it with ctx
|
|
2231
|
+
run_func = getattr(module, "__run__", None)
|
|
2232
|
+
if callable(run_func):
|
|
2233
|
+
handled = run_func(ctx)
|
|
2234
|
+
_log(f"Extension {item} was run")
|
|
2235
|
+
return handled
|
|
2236
|
+
|
|
2237
|
+
except Exception as e:
|
|
2238
|
+
_err(f"Failed to run extension {item}", e)
|
|
2239
|
+
return False
|
|
2240
|
+
|
|
2241
|
+
|
|
2062
2242
|
def main():
|
|
2063
|
-
global _ROOT, g_verbose, g_default_model, g_logprefix, g_providers, g_config, g_config_path, g_ui_path
|
|
2243
|
+
global _ROOT, g_verbose, g_default_model, g_logprefix, g_providers, g_config, g_config_path, g_ui_path, g_app
|
|
2064
2244
|
|
|
2065
2245
|
parser = argparse.ArgumentParser(description=f"llms v{VERSION}")
|
|
2066
2246
|
parser.add_argument("--config", default=None, help="Path to config file", metavar="FILE")
|
|
@@ -2102,8 +2282,30 @@ def main():
|
|
|
2102
2282
|
parser.add_argument("--logprefix", default="", help="Prefix used in log messages", metavar="PREFIX")
|
|
2103
2283
|
parser.add_argument("--verbose", action="store_true", help="Verbose output")
|
|
2104
2284
|
|
|
2285
|
+
parser.add_argument(
|
|
2286
|
+
"--add",
|
|
2287
|
+
nargs="?",
|
|
2288
|
+
const="ls",
|
|
2289
|
+
default=None,
|
|
2290
|
+
help="Install an extension (lists available extensions if no name provided)",
|
|
2291
|
+
metavar="EXTENSION",
|
|
2292
|
+
)
|
|
2293
|
+
parser.add_argument(
|
|
2294
|
+
"--remove",
|
|
2295
|
+
nargs="?",
|
|
2296
|
+
const="ls",
|
|
2297
|
+
default=None,
|
|
2298
|
+
help="Remove an extension (lists installed extensions if no name provided)",
|
|
2299
|
+
metavar="EXTENSION",
|
|
2300
|
+
)
|
|
2301
|
+
|
|
2302
|
+
# Load parser extensions, go through all extensions and load their parser arguments
|
|
2303
|
+
init_extensions(parser)
|
|
2304
|
+
|
|
2105
2305
|
cli_args, extra_args = parser.parse_known_args()
|
|
2106
2306
|
|
|
2307
|
+
g_app = AppExtensions(cli_args, extra_args)
|
|
2308
|
+
|
|
2107
2309
|
# Check for verbose mode from CLI argument or environment variables
|
|
2108
2310
|
verbose_env = os.environ.get("VERBOSE", "").lower()
|
|
2109
2311
|
if cli_args.verbose or verbose_env in ("1", "true"):
|
|
@@ -2187,8 +2389,105 @@ def main():
|
|
|
2187
2389
|
print(f"Updated {home_providers_path}")
|
|
2188
2390
|
exit(0)
|
|
2189
2391
|
|
|
2392
|
+
if cli_args.add is not None:
|
|
2393
|
+
if cli_args.add == "ls":
|
|
2394
|
+
|
|
2395
|
+
async def list_extensions():
|
|
2396
|
+
print("\nAvailable extensions:")
|
|
2397
|
+
text = await get_text("https://api.github.com/orgs/llmspy/repos?per_page=100&sort=updated")
|
|
2398
|
+
repos = json.loads(text)
|
|
2399
|
+
max_name_length = 0
|
|
2400
|
+
for repo in repos:
|
|
2401
|
+
max_name_length = max(max_name_length, len(repo["name"]))
|
|
2402
|
+
|
|
2403
|
+
for repo in repos:
|
|
2404
|
+
print(f" {repo['name']:<{max_name_length + 2}} {repo['description']}")
|
|
2405
|
+
|
|
2406
|
+
print("\nUsage:")
|
|
2407
|
+
print(" llms --add <extension>")
|
|
2408
|
+
print(" llms --add <github-user>/<repo>")
|
|
2409
|
+
|
|
2410
|
+
asyncio.run(list_extensions())
|
|
2411
|
+
exit(0)
|
|
2412
|
+
|
|
2413
|
+
async def install_extension(name):
|
|
2414
|
+
# Determine git URL and target directory name
|
|
2415
|
+
if "/" in name:
|
|
2416
|
+
git_url = f"https://github.com/{name}"
|
|
2417
|
+
target_name = name.split("/")[-1]
|
|
2418
|
+
else:
|
|
2419
|
+
git_url = f"https://github.com/llmspy/{name}"
|
|
2420
|
+
target_name = name
|
|
2421
|
+
|
|
2422
|
+
# check extension is not already installed
|
|
2423
|
+
extensions_path = get_extensions_path()
|
|
2424
|
+
target_path = os.path.join(extensions_path, target_name)
|
|
2425
|
+
|
|
2426
|
+
if os.path.exists(target_path):
|
|
2427
|
+
print(f"Extension {target_name} is already installed at {target_path}")
|
|
2428
|
+
return
|
|
2429
|
+
|
|
2430
|
+
print(f"Installing extension: {name}")
|
|
2431
|
+
print(f"Cloning from {git_url} to {target_path}...")
|
|
2432
|
+
|
|
2433
|
+
try:
|
|
2434
|
+
subprocess.run(["git", "clone", git_url, target_path], check=True)
|
|
2435
|
+
|
|
2436
|
+
# Check for requirements.txt
|
|
2437
|
+
requirements_path = os.path.join(target_path, "requirements.txt")
|
|
2438
|
+
if os.path.exists(requirements_path):
|
|
2439
|
+
print(f"Installing dependencies from {requirements_path}...")
|
|
2440
|
+
subprocess.run(
|
|
2441
|
+
[sys.executable, "-m", "pip", "install", "-r", "requirements.txt"], cwd=target_path, check=True
|
|
2442
|
+
)
|
|
2443
|
+
print("Dependencies installed successfully.")
|
|
2444
|
+
|
|
2445
|
+
print(f"Extension {target_name} installed successfully.")
|
|
2446
|
+
|
|
2447
|
+
except subprocess.CalledProcessError as e:
|
|
2448
|
+
print(f"Failed to install extension: {e}")
|
|
2449
|
+
# cleanup if clone failed but directory was created (unlikely with simple git clone but good practice)
|
|
2450
|
+
if os.path.exists(target_path) and not os.listdir(target_path):
|
|
2451
|
+
os.rmdir(target_path)
|
|
2452
|
+
|
|
2453
|
+
asyncio.run(install_extension(cli_args.add))
|
|
2454
|
+
exit(0)
|
|
2455
|
+
|
|
2456
|
+
if cli_args.remove is not None:
|
|
2457
|
+
if cli_args.remove == "ls":
|
|
2458
|
+
# List installed extensions
|
|
2459
|
+
extensions_path = get_extensions_path()
|
|
2460
|
+
extensions = os.listdir(extensions_path)
|
|
2461
|
+
if len(extensions) == 0:
|
|
2462
|
+
print("No extensions installed.")
|
|
2463
|
+
exit(0)
|
|
2464
|
+
print("Installed extensions:")
|
|
2465
|
+
for extension in extensions:
|
|
2466
|
+
print(f" {extension}")
|
|
2467
|
+
exit(0)
|
|
2468
|
+
# Remove an extension
|
|
2469
|
+
extension_name = cli_args.remove
|
|
2470
|
+
extensions_path = get_extensions_path()
|
|
2471
|
+
target_path = os.path.join(extensions_path, extension_name)
|
|
2472
|
+
|
|
2473
|
+
if not os.path.exists(target_path):
|
|
2474
|
+
print(f"Extension {extension_name} not found at {target_path}")
|
|
2475
|
+
exit(1)
|
|
2476
|
+
|
|
2477
|
+
print(f"Removing extension: {extension_name}...")
|
|
2478
|
+
try:
|
|
2479
|
+
shutil.rmtree(target_path)
|
|
2480
|
+
print(f"Extension {extension_name} removed successfully.")
|
|
2481
|
+
except Exception as e:
|
|
2482
|
+
print(f"Failed to remove extension: {e}")
|
|
2483
|
+
exit(1)
|
|
2484
|
+
|
|
2485
|
+
exit(0)
|
|
2486
|
+
|
|
2190
2487
|
asyncio.run(reload_providers())
|
|
2191
2488
|
|
|
2489
|
+
install_extensions()
|
|
2490
|
+
|
|
2192
2491
|
# print names
|
|
2193
2492
|
_log(f"enabled providers: {', '.join(g_handlers.keys())}")
|
|
2194
2493
|
|
|
@@ -2274,11 +2573,19 @@ def main():
|
|
|
2274
2573
|
|
|
2275
2574
|
# Expand environment variables
|
|
2276
2575
|
if client_id.startswith("$"):
|
|
2277
|
-
client_id =
|
|
2576
|
+
client_id = client_id[1:]
|
|
2278
2577
|
if client_secret.startswith("$"):
|
|
2279
|
-
client_secret =
|
|
2578
|
+
client_secret = client_secret[1:]
|
|
2280
2579
|
|
|
2281
|
-
|
|
2580
|
+
client_id = os.environ.get(client_id, client_id)
|
|
2581
|
+
client_secret = os.environ.get(client_secret, client_secret)
|
|
2582
|
+
|
|
2583
|
+
if (
|
|
2584
|
+
not client_id
|
|
2585
|
+
or not client_secret
|
|
2586
|
+
or client_id == "GITHUB_CLIENT_ID"
|
|
2587
|
+
or client_secret == "GITHUB_CLIENT_SECRET"
|
|
2588
|
+
):
|
|
2282
2589
|
print("ERROR: Authentication is enabled but GitHub OAuth is not properly configured.")
|
|
2283
2590
|
print("Please set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET environment variables,")
|
|
2284
2591
|
print("or disable authentication by setting 'auth.enabled' to false in llms.json")
|
|
@@ -2299,7 +2606,7 @@ def main():
|
|
|
2299
2606
|
return True, None
|
|
2300
2607
|
|
|
2301
2608
|
# Check for OAuth session token
|
|
2302
|
-
session_token =
|
|
2609
|
+
session_token = get_session_token(request)
|
|
2303
2610
|
if session_token and session_token in g_sessions:
|
|
2304
2611
|
return True, g_sessions[session_token]
|
|
2305
2612
|
|
|
@@ -2329,13 +2636,32 @@ def main():
|
|
|
2329
2636
|
|
|
2330
2637
|
try:
|
|
2331
2638
|
chat = await request.json()
|
|
2639
|
+
|
|
2640
|
+
# Apply pre-chat filters
|
|
2641
|
+
context = {"request": request}
|
|
2642
|
+
# Apply pre-chat filters
|
|
2643
|
+
context = {"request": request}
|
|
2644
|
+
for filter_func in g_app.chat_request_filters:
|
|
2645
|
+
chat = await filter_func(chat, context)
|
|
2646
|
+
|
|
2332
2647
|
response = await chat_completion(chat)
|
|
2648
|
+
|
|
2649
|
+
# Apply post-chat filters
|
|
2650
|
+
# Apply post-chat filters
|
|
2651
|
+
for filter_func in g_app.chat_response_filters:
|
|
2652
|
+
response = await filter_func(response, context)
|
|
2653
|
+
|
|
2333
2654
|
return web.json_response(response)
|
|
2334
2655
|
except Exception as e:
|
|
2335
2656
|
return web.json_response({"error": str(e)}, status=500)
|
|
2336
2657
|
|
|
2337
2658
|
app.router.add_post("/v1/chat/completions", chat_handler)
|
|
2338
2659
|
|
|
2660
|
+
async def extensions_handler(request):
|
|
2661
|
+
return web.json_response(g_app.ui_extensions)
|
|
2662
|
+
|
|
2663
|
+
app.router.add_get("/ext", extensions_handler)
|
|
2664
|
+
|
|
2339
2665
|
async def models_handler(request):
|
|
2340
2666
|
return web.json_response(get_models())
|
|
2341
2667
|
|
|
@@ -2491,7 +2817,7 @@ def main():
|
|
|
2491
2817
|
except Exception:
|
|
2492
2818
|
return web.Response(text="403: Forbidden", status=403)
|
|
2493
2819
|
|
|
2494
|
-
with open(info_path
|
|
2820
|
+
with open(info_path) as f:
|
|
2495
2821
|
content = f.read()
|
|
2496
2822
|
return web.Response(text=content, content_type="application/json")
|
|
2497
2823
|
|
|
@@ -2527,9 +2853,12 @@ def main():
|
|
|
2527
2853
|
|
|
2528
2854
|
# Expand environment variables
|
|
2529
2855
|
if client_id.startswith("$"):
|
|
2530
|
-
client_id =
|
|
2856
|
+
client_id = client_id[1:]
|
|
2531
2857
|
if redirect_uri.startswith("$"):
|
|
2532
|
-
redirect_uri =
|
|
2858
|
+
redirect_uri = redirect_uri[1:]
|
|
2859
|
+
|
|
2860
|
+
client_id = os.environ.get(client_id, client_id)
|
|
2861
|
+
redirect_uri = os.environ.get(redirect_uri, redirect_uri)
|
|
2533
2862
|
|
|
2534
2863
|
if not client_id:
|
|
2535
2864
|
return web.json_response({"error": "GitHub client_id not configured"}, status=500)
|
|
@@ -2562,7 +2891,9 @@ def main():
|
|
|
2562
2891
|
|
|
2563
2892
|
# Expand environment variables
|
|
2564
2893
|
if restrict_to.startswith("$"):
|
|
2565
|
-
restrict_to =
|
|
2894
|
+
restrict_to = restrict_to[1:]
|
|
2895
|
+
|
|
2896
|
+
restrict_to = os.environ.get(restrict_to, None if restrict_to == "GITHUB_USERS" else restrict_to)
|
|
2566
2897
|
|
|
2567
2898
|
# If restrict_to is configured, validate the user
|
|
2568
2899
|
if restrict_to:
|
|
@@ -2583,6 +2914,14 @@ def main():
|
|
|
2583
2914
|
code = request.query.get("code")
|
|
2584
2915
|
state = request.query.get("state")
|
|
2585
2916
|
|
|
2917
|
+
# Handle malformed URLs where query params are appended with & instead of ?
|
|
2918
|
+
if not code and "tail" in request.match_info:
|
|
2919
|
+
tail = request.match_info["tail"]
|
|
2920
|
+
if tail.startswith("&"):
|
|
2921
|
+
params = parse_qs(tail[1:])
|
|
2922
|
+
code = params.get("code", [None])[0]
|
|
2923
|
+
state = params.get("state", [None])[0]
|
|
2924
|
+
|
|
2586
2925
|
if not code or not state:
|
|
2587
2926
|
return web.Response(text="Missing code or state parameter", status=400)
|
|
2588
2927
|
|
|
@@ -2602,11 +2941,15 @@ def main():
|
|
|
2602
2941
|
|
|
2603
2942
|
# Expand environment variables
|
|
2604
2943
|
if client_id.startswith("$"):
|
|
2605
|
-
client_id =
|
|
2944
|
+
client_id = client_id[1:]
|
|
2606
2945
|
if client_secret.startswith("$"):
|
|
2607
|
-
client_secret =
|
|
2946
|
+
client_secret = client_secret[1:]
|
|
2608
2947
|
if redirect_uri.startswith("$"):
|
|
2609
|
-
redirect_uri =
|
|
2948
|
+
redirect_uri = redirect_uri[1:]
|
|
2949
|
+
|
|
2950
|
+
client_id = os.environ.get(client_id, client_id)
|
|
2951
|
+
client_secret = os.environ.get(client_secret, client_secret)
|
|
2952
|
+
redirect_uri = os.environ.get(redirect_uri, redirect_uri)
|
|
2610
2953
|
|
|
2611
2954
|
if not client_id or not client_secret:
|
|
2612
2955
|
return web.json_response({"error": "GitHub OAuth credentials not configured"}, status=500)
|
|
@@ -2654,11 +2997,13 @@ def main():
|
|
|
2654
2997
|
}
|
|
2655
2998
|
|
|
2656
2999
|
# Redirect to UI with session token
|
|
2657
|
-
|
|
3000
|
+
response = web.HTTPFound(f"/?session={session_token}")
|
|
3001
|
+
response.set_cookie("llms-token", session_token, httponly=True, path="/", max_age=86400)
|
|
3002
|
+
return response
|
|
2658
3003
|
|
|
2659
3004
|
async def session_handler(request):
|
|
2660
3005
|
"""Validate and return session info"""
|
|
2661
|
-
session_token =
|
|
3006
|
+
session_token = get_session_token(request)
|
|
2662
3007
|
|
|
2663
3008
|
if not session_token or session_token not in g_sessions:
|
|
2664
3009
|
return web.json_response({"error": "Invalid or expired session"}, status=401)
|
|
@@ -2675,17 +3020,19 @@ def main():
|
|
|
2675
3020
|
|
|
2676
3021
|
async def logout_handler(request):
|
|
2677
3022
|
"""End OAuth session"""
|
|
2678
|
-
session_token =
|
|
3023
|
+
session_token = get_session_token(request)
|
|
2679
3024
|
|
|
2680
3025
|
if session_token and session_token in g_sessions:
|
|
2681
3026
|
del g_sessions[session_token]
|
|
2682
3027
|
|
|
2683
|
-
|
|
3028
|
+
response = web.json_response({"success": True})
|
|
3029
|
+
response.del_cookie("llms-token")
|
|
3030
|
+
return response
|
|
2684
3031
|
|
|
2685
3032
|
async def auth_handler(request):
|
|
2686
3033
|
"""Check authentication status and return user info"""
|
|
2687
3034
|
# Check for OAuth session token
|
|
2688
|
-
session_token =
|
|
3035
|
+
session_token = get_session_token(request)
|
|
2689
3036
|
|
|
2690
3037
|
if session_token and session_token in g_sessions:
|
|
2691
3038
|
session_data = g_sessions[session_token]
|
|
@@ -2722,6 +3069,7 @@ def main():
|
|
|
2722
3069
|
app.router.add_get("/auth", auth_handler)
|
|
2723
3070
|
app.router.add_get("/auth/github", github_auth_handler)
|
|
2724
3071
|
app.router.add_get("/auth/github/callback", github_callback_handler)
|
|
3072
|
+
app.router.add_get("/auth/github/callback{tail:.*}", github_callback_handler)
|
|
2725
3073
|
app.router.add_get("/auth/session", session_handler)
|
|
2726
3074
|
app.router.add_post("/auth/logout", logout_handler)
|
|
2727
3075
|
|
|
@@ -2775,6 +3123,12 @@ def main():
|
|
|
2775
3123
|
|
|
2776
3124
|
app.router.add_get("/favicon.ico", not_found_handler)
|
|
2777
3125
|
|
|
3126
|
+
# go through and register all g_app extensions
|
|
3127
|
+
for handler in g_app.server_add_get:
|
|
3128
|
+
app.router.add_get(handler[0], handler[1], **handler[2])
|
|
3129
|
+
for handler in g_app.server_add_post:
|
|
3130
|
+
app.router.add_post(handler[0], handler[1], **handler[2])
|
|
3131
|
+
|
|
2778
3132
|
# Serve index.html from root
|
|
2779
3133
|
async def index_handler(request):
|
|
2780
3134
|
index_content = read_resource_file_bytes("index.html")
|
|
@@ -2795,6 +3149,8 @@ def main():
|
|
|
2795
3149
|
|
|
2796
3150
|
app.on_startup.append(start_background_tasks)
|
|
2797
3151
|
|
|
3152
|
+
# go through and register all g_app extensions
|
|
3153
|
+
|
|
2798
3154
|
print(f"Starting server on port {port}...")
|
|
2799
3155
|
web.run_app(app, host="0.0.0.0", port=port, print=_log)
|
|
2800
3156
|
exit(0)
|
|
@@ -2922,8 +3278,11 @@ def main():
|
|
|
2922
3278
|
traceback.print_exc()
|
|
2923
3279
|
exit(1)
|
|
2924
3280
|
|
|
2925
|
-
|
|
2926
|
-
|
|
3281
|
+
handled = run_extension_cli()
|
|
3282
|
+
|
|
3283
|
+
if not handled:
|
|
3284
|
+
# show usage from ArgumentParser
|
|
3285
|
+
parser.print_help()
|
|
2927
3286
|
|
|
2928
3287
|
|
|
2929
3288
|
if __name__ == "__main__":
|