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.
Files changed (39) hide show
  1. llms/__pycache__/__init__.cpython-312.pyc +0 -0
  2. llms/__pycache__/__init__.cpython-313.pyc +0 -0
  3. llms/__pycache__/__init__.cpython-314.pyc +0 -0
  4. llms/__pycache__/__main__.cpython-312.pyc +0 -0
  5. llms/__pycache__/__main__.cpython-314.pyc +0 -0
  6. llms/__pycache__/llms.cpython-312.pyc +0 -0
  7. llms/__pycache__/main.cpython-312.pyc +0 -0
  8. llms/__pycache__/main.cpython-313.pyc +0 -0
  9. llms/__pycache__/main.cpython-314.pyc +0 -0
  10. llms/__pycache__/plugins.cpython-314.pyc +0 -0
  11. llms/index.html +25 -56
  12. llms/llms.json +2 -2
  13. llms/main.py +452 -93
  14. llms/providers.json +1 -1
  15. llms/ui/App.mjs +25 -4
  16. llms/ui/Avatar.mjs +3 -2
  17. llms/ui/ChatPrompt.mjs +43 -52
  18. llms/ui/Main.mjs +87 -98
  19. llms/ui/OAuthSignIn.mjs +2 -33
  20. llms/ui/ProviderStatus.mjs +7 -8
  21. llms/ui/Recents.mjs +10 -9
  22. llms/ui/Sidebar.mjs +2 -1
  23. llms/ui/SignIn.mjs +7 -6
  24. llms/ui/ai.mjs +9 -41
  25. llms/ui/app.css +137 -138
  26. llms/ui/index.mjs +213 -0
  27. llms/ui/{ModelSelector.mjs → model-selector.mjs} +193 -200
  28. llms/ui/tailwind.input.css +441 -79
  29. llms/ui/threadStore.mjs +17 -6
  30. llms/ui/utils.mjs +1 -0
  31. {llms_py-3.0.0b1.dist-info → llms_py-3.0.0b2.dist-info}/METADATA +1 -1
  32. llms_py-3.0.0b2.dist-info/RECORD +58 -0
  33. llms/ui/SystemPromptEditor.mjs +0 -31
  34. llms/ui/SystemPromptSelector.mjs +0 -56
  35. llms_py-3.0.0b1.dist-info/RECORD +0 -49
  36. {llms_py-3.0.0b1.dist-info → llms_py-3.0.0b2.dist-info}/WHEEL +0 -0
  37. {llms_py-3.0.0b1.dist-info → llms_py-3.0.0b2.dist-info}/entry_points.txt +0 -0
  38. {llms_py-3.0.0b1.dist-info → llms_py-3.0.0b2.dist-info}/licenses/LICENSE +0 -0
  39. {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.0b1"
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
- name = model["model"]
945
- if name.endswith(":latest"):
946
- name = name[:-7]
947
- model_id = name.replace(":", "-")
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 ALL_PROVIDERS:
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 = os.environ.get(client_id[1:], "")
2576
+ client_id = client_id[1:]
2278
2577
  if client_secret.startswith("$"):
2279
- client_secret = os.environ.get(client_secret[1:], "")
2578
+ client_secret = client_secret[1:]
2280
2579
 
2281
- if not client_id or not client_secret:
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 = request.query.get("session") or request.headers.get("X-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, "r") as f:
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 = os.environ.get(client_id[1:], "")
2856
+ client_id = client_id[1:]
2531
2857
  if redirect_uri.startswith("$"):
2532
- redirect_uri = os.environ.get(redirect_uri[1:], "")
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 = os.environ.get(restrict_to[1:], "")
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 = os.environ.get(client_id[1:], "")
2944
+ client_id = client_id[1:]
2606
2945
  if client_secret.startswith("$"):
2607
- client_secret = os.environ.get(client_secret[1:], "")
2946
+ client_secret = client_secret[1:]
2608
2947
  if redirect_uri.startswith("$"):
2609
- redirect_uri = os.environ.get(redirect_uri[1:], "")
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
- return web.HTTPFound(f"/?session={session_token}")
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 = request.query.get("session") or request.headers.get("X-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 = request.query.get("session") or request.headers.get("X-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
- return web.json_response({"success": True})
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 = request.query.get("session") or request.headers.get("X-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
- # show usage from ArgumentParser
2926
- parser.print_help()
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__":