llms-py 2.0.18__py3-none-any.whl → 2.0.33__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 (41) hide show
  1. llms/index.html +17 -1
  2. llms/llms.json +1132 -1075
  3. llms/main.py +561 -103
  4. llms/ui/Analytics.mjs +115 -104
  5. llms/ui/App.mjs +81 -4
  6. llms/ui/Avatar.mjs +61 -4
  7. llms/ui/Brand.mjs +29 -11
  8. llms/ui/ChatPrompt.mjs +163 -16
  9. llms/ui/Main.mjs +177 -94
  10. llms/ui/ModelSelector.mjs +28 -10
  11. llms/ui/OAuthSignIn.mjs +92 -0
  12. llms/ui/ProviderStatus.mjs +12 -12
  13. llms/ui/Recents.mjs +13 -13
  14. llms/ui/SettingsDialog.mjs +65 -65
  15. llms/ui/Sidebar.mjs +24 -19
  16. llms/ui/SystemPromptEditor.mjs +5 -5
  17. llms/ui/SystemPromptSelector.mjs +26 -6
  18. llms/ui/Welcome.mjs +2 -2
  19. llms/ui/ai.mjs +69 -5
  20. llms/ui/app.css +548 -34
  21. llms/ui/lib/servicestack-vue.mjs +9 -9
  22. llms/ui/markdown.mjs +8 -8
  23. llms/ui/tailwind.input.css +2 -0
  24. llms/ui/threadStore.mjs +39 -0
  25. llms/ui/typography.css +54 -36
  26. {llms_py-2.0.18.dist-info → llms_py-2.0.33.dist-info}/METADATA +403 -47
  27. llms_py-2.0.33.dist-info/RECORD +48 -0
  28. {llms_py-2.0.18.dist-info → llms_py-2.0.33.dist-info}/licenses/LICENSE +1 -2
  29. llms/__pycache__/__init__.cpython-312.pyc +0 -0
  30. llms/__pycache__/__init__.cpython-313.pyc +0 -0
  31. llms/__pycache__/__init__.cpython-314.pyc +0 -0
  32. llms/__pycache__/__main__.cpython-312.pyc +0 -0
  33. llms/__pycache__/__main__.cpython-314.pyc +0 -0
  34. llms/__pycache__/llms.cpython-312.pyc +0 -0
  35. llms/__pycache__/main.cpython-312.pyc +0 -0
  36. llms/__pycache__/main.cpython-313.pyc +0 -0
  37. llms/__pycache__/main.cpython-314.pyc +0 -0
  38. llms_py-2.0.18.dist-info/RECORD +0 -56
  39. {llms_py-2.0.18.dist-info → llms_py-2.0.33.dist-info}/WHEEL +0 -0
  40. {llms_py-2.0.18.dist-info → llms_py-2.0.33.dist-info}/entry_points.txt +0 -0
  41. {llms_py-2.0.18.dist-info → llms_py-2.0.33.dist-info}/top_level.txt +0 -0
llms/main.py CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env python
2
2
 
3
+ # Copyright (c) Demis Bellot, ServiceStack <https://servicestack.net>
4
+ # License: https://github.com/ServiceStack/llms/blob/main/LICENSE
5
+
3
6
  # A lightweight CLI tool and OpenAI-compatible server for querying multiple Large Language Model (LLM) providers.
4
7
  # Docs: https://github.com/ServiceStack/llms
5
8
 
@@ -14,7 +17,10 @@ import mimetypes
14
17
  import traceback
15
18
  import sys
16
19
  import site
17
- from urllib.parse import parse_qs
20
+ import secrets
21
+ import re
22
+ from io import BytesIO
23
+ from urllib.parse import parse_qs, urlencode
18
24
 
19
25
  import aiohttp
20
26
  from aiohttp import web
@@ -22,7 +28,13 @@ from aiohttp import web
22
28
  from pathlib import Path
23
29
  from importlib import resources # Py≥3.9 (pip install importlib_resources for 3.7/3.8)
24
30
 
25
- VERSION = "2.0.18"
31
+ try:
32
+ from PIL import Image
33
+ HAS_PIL = True
34
+ except ImportError:
35
+ HAS_PIL = False
36
+
37
+ VERSION = "2.0.33"
26
38
  _ROOT = None
27
39
  g_config_path = None
28
40
  g_ui_path = None
@@ -31,6 +43,8 @@ g_handlers = {}
31
43
  g_verbose = False
32
44
  g_logprefix=""
33
45
  g_default_model=""
46
+ g_sessions = {} # OAuth session storage: {session_token: {userId, userName, displayName, profileUrl, email, created}}
47
+ g_oauth_states = {} # CSRF protection: {state: {created, redirect_uri}}
34
48
 
35
49
  def _log(message):
36
50
  """Helper method for logging from the global polling task."""
@@ -197,6 +211,77 @@ def price_to_string(price: float | int | str | None) -> str | None:
197
211
  except (ValueError, TypeError):
198
212
  return None
199
213
 
214
+ def convert_image_if_needed(image_bytes, mimetype='image/png'):
215
+ """
216
+ Convert and resize image to WebP if it exceeds configured limits.
217
+
218
+ Args:
219
+ image_bytes: Raw image bytes
220
+ mimetype: Original image MIME type
221
+
222
+ Returns:
223
+ tuple: (converted_bytes, new_mimetype) or (original_bytes, original_mimetype) if no conversion needed
224
+ """
225
+ if not HAS_PIL:
226
+ return image_bytes, mimetype
227
+
228
+ # Get conversion config
229
+ convert_config = g_config.get('convert', {}).get('image', {}) if g_config else {}
230
+ if not convert_config:
231
+ return image_bytes, mimetype
232
+
233
+ max_size_str = convert_config.get('max_size', '1536x1024')
234
+ max_length = convert_config.get('max_length', 1.5*1024*1024) # 1.5MB
235
+
236
+ try:
237
+ # Parse max_size (e.g., "1536x1024")
238
+ max_width, max_height = map(int, max_size_str.split('x'))
239
+
240
+ # Open image
241
+ with Image.open(BytesIO(image_bytes)) as img:
242
+ original_width, original_height = img.size
243
+
244
+ # Check if image exceeds limits
245
+ needs_resize = original_width > max_width or original_height > max_height
246
+
247
+ # Check if base64 length would exceed max_length (in KB)
248
+ # Base64 encoding increases size by ~33%, so check raw bytes * 1.33 / 1024
249
+ estimated_kb = (len(image_bytes) * 1.33) / 1024
250
+ needs_conversion = estimated_kb > max_length
251
+
252
+ if not needs_resize and not needs_conversion:
253
+ return image_bytes, mimetype
254
+
255
+ # Convert RGBA to RGB if necessary (WebP doesn't support transparency in RGB mode)
256
+ if img.mode in ('RGBA', 'LA', 'P'):
257
+ # Create a white background
258
+ background = Image.new('RGB', img.size, (255, 255, 255))
259
+ if img.mode == 'P':
260
+ img = img.convert('RGBA')
261
+ background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
262
+ img = background
263
+ elif img.mode != 'RGB':
264
+ img = img.convert('RGB')
265
+
266
+ # Resize if needed (preserve aspect ratio)
267
+ if needs_resize:
268
+ img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
269
+ _log(f"Resized image from {original_width}x{original_height} to {img.size[0]}x{img.size[1]}")
270
+
271
+ # Convert to WebP
272
+ output = BytesIO()
273
+ img.save(output, format='WEBP', quality=85, method=6)
274
+ converted_bytes = output.getvalue()
275
+
276
+ _log(f"Converted image to WebP: {len(image_bytes)} bytes -> {len(converted_bytes)} bytes ({len(converted_bytes)*100//len(image_bytes)}%)")
277
+
278
+ return converted_bytes, 'image/webp'
279
+
280
+ except Exception as e:
281
+ _log(f"Error converting image: {e}")
282
+ # Return original if conversion fails
283
+ return image_bytes, mimetype
284
+
200
285
  async def process_chat(chat):
201
286
  if not chat:
202
287
  raise Exception("No chat provided")
@@ -227,19 +312,31 @@ async def process_chat(chat):
227
312
  mimetype = get_file_mime_type(get_filename(url))
228
313
  if 'Content-Type' in response.headers:
229
314
  mimetype = response.headers['Content-Type']
315
+ # convert/resize image if needed
316
+ content, mimetype = convert_image_if_needed(content, mimetype)
230
317
  # convert to data uri
231
318
  image_url['url'] = f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}"
232
319
  elif is_file_path(url):
233
320
  _log(f"Reading image: {url}")
234
321
  with open(url, "rb") as f:
235
322
  content = f.read()
236
- ext = os.path.splitext(url)[1].lower().lstrip('.') if '.' in url else 'png'
237
323
  # get mimetype from file extension
238
324
  mimetype = get_file_mime_type(get_filename(url))
325
+ # convert/resize image if needed
326
+ content, mimetype = convert_image_if_needed(content, mimetype)
239
327
  # convert to data uri
240
328
  image_url['url'] = f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}"
241
329
  elif url.startswith('data:'):
242
- pass
330
+ # Extract existing data URI and process it
331
+ if ';base64,' in url:
332
+ prefix = url.split(';base64,')[0]
333
+ mimetype = prefix.split(':')[1] if ':' in prefix else 'image/png'
334
+ base64_data = url.split(';base64,')[1]
335
+ content = base64.b64decode(base64_data)
336
+ # convert/resize image if needed
337
+ content, mimetype = convert_image_if_needed(content, mimetype)
338
+ # update data uri with potentially converted image
339
+ image_url['url'] = f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}"
243
340
  else:
244
341
  raise Exception(f"Invalid image: {url}")
245
342
  elif item['type'] == 'input_audio' and 'input_audio' in item:
@@ -354,7 +451,7 @@ class OpenAiProvider:
354
451
 
355
452
  @classmethod
356
453
  def test(cls, base_url=None, api_key=None, models={}, **kwargs):
357
- return base_url is not None and api_key is not None and len(models) > 0
454
+ return base_url and api_key and len(models) > 0
358
455
 
359
456
  async def load(self):
360
457
  pass
@@ -425,6 +522,8 @@ class OpenAiProvider:
425
522
  chat = await process_chat(chat)
426
523
  _log(f"POST {self.chat_url}")
427
524
  _log(chat_summary(chat))
525
+ # remove metadata if any (conflicts with some providers, e.g. Z.ai)
526
+ chat.pop('metadata', None)
428
527
 
429
528
  async with aiohttp.ClientSession() as session:
430
529
  started_at = time.time()
@@ -467,7 +566,7 @@ class OllamaProvider(OpenAiProvider):
467
566
 
468
567
  @classmethod
469
568
  def test(cls, base_url=None, models={}, all_models=False, **kwargs):
470
- return base_url is not None and (len(models) > 0 or all_models)
569
+ return base_url and (len(models) > 0 or all_models)
471
570
 
472
571
  class GoogleOpenAiProvider(OpenAiProvider):
473
572
  def __init__(self, api_key, models, **kwargs):
@@ -476,7 +575,7 @@ class GoogleOpenAiProvider(OpenAiProvider):
476
575
 
477
576
  @classmethod
478
577
  def test(cls, api_key=None, models={}, **kwargs):
479
- return api_key is not None and len(models) > 0
578
+ return api_key and len(models) > 0
480
579
 
481
580
  class GoogleProvider(OpenAiProvider):
482
581
  def __init__(self, models, api_key, safety_settings=None, thinking_config=None, curl=False, **kwargs):
@@ -912,7 +1011,7 @@ async def load_llms():
912
1011
  await provider.load()
913
1012
 
914
1013
  def save_config(config):
915
- global g_config
1014
+ global g_config, g_config_path
916
1015
  g_config = config
917
1016
  with open(g_config_path, "w") as f:
918
1017
  json.dump(g_config, f, indent=4)
@@ -921,29 +1020,27 @@ def save_config(config):
921
1020
  def github_url(filename):
922
1021
  return f"https://raw.githubusercontent.com/ServiceStack/llms/refs/heads/main/llms/{filename}"
923
1022
 
924
- async def save_text(url, save_path):
1023
+ async def get_text(url):
925
1024
  async with aiohttp.ClientSession() as session:
926
1025
  _log(f"GET {url}")
927
1026
  async with session.get(url) as resp:
928
1027
  text = await resp.text()
929
1028
  if resp.status >= 400:
930
1029
  raise HTTPError(resp.status, reason=resp.reason, body=text, headers=dict(resp.headers))
931
- os.makedirs(os.path.dirname(save_path), exist_ok=True)
932
- with open(save_path, "w") as f:
933
- f.write(text)
934
1030
  return text
935
1031
 
1032
+ async def save_text_url(url, save_path):
1033
+ text = await get_text(url)
1034
+ os.makedirs(os.path.dirname(save_path), exist_ok=True)
1035
+ with open(save_path, "w") as f:
1036
+ f.write(text)
1037
+ return text
1038
+
936
1039
  async def save_default_config(config_path):
937
1040
  global g_config
938
- config_json = await save_text(github_url("llms.json"), config_path)
1041
+ config_json = await save_text_url(github_url("llms.json"), config_path)
939
1042
  g_config = json.loads(config_json)
940
1043
 
941
- async def update_llms():
942
- """
943
- Update llms.py from GitHub
944
- """
945
- await save_text(github_url("llms.py"), __file__)
946
-
947
1044
  def provider_status():
948
1045
  enabled = list(g_handlers.keys())
949
1046
  disabled = [provider for provider in g_config['providers'].keys() if provider not in enabled]
@@ -1262,8 +1359,119 @@ async def check_models(provider_name, model_names=None):
1262
1359
 
1263
1360
  print()
1264
1361
 
1362
+ def text_from_resource(filename):
1363
+ global _ROOT
1364
+ resource_path = _ROOT / filename
1365
+ if resource_exists(resource_path):
1366
+ try:
1367
+ return read_resource_text(resource_path)
1368
+ except (OSError, AttributeError) as e:
1369
+ _log(f"Error reading resource config {filename}: {e}")
1370
+ return None
1371
+
1372
+ def text_from_file(filename):
1373
+ if os.path.exists(filename):
1374
+ with open(filename, "r") as f:
1375
+ return f.read()
1376
+ return None
1377
+
1378
+ async def text_from_resource_or_url(filename):
1379
+ text = text_from_resource(filename)
1380
+ if not text:
1381
+ try:
1382
+ resource_url = github_url(filename)
1383
+ text = await get_text(resource_url)
1384
+ except Exception as e:
1385
+ _log(f"Error downloading JSON from {resource_url}: {e}")
1386
+ raise e
1387
+ return text
1388
+
1389
+ async def save_home_configs():
1390
+ home_config_path = home_llms_path("llms.json")
1391
+ home_ui_path = home_llms_path("ui.json")
1392
+ if os.path.exists(home_config_path) and os.path.exists(home_ui_path):
1393
+ return
1394
+
1395
+ llms_home = os.path.dirname(home_config_path)
1396
+ os.makedirs(llms_home, exist_ok=True)
1397
+ try:
1398
+ if not os.path.exists(home_config_path):
1399
+ config_json = await text_from_resource_or_url("llms.json")
1400
+ with open(home_config_path, "w") as f:
1401
+ f.write(config_json)
1402
+ _log(f"Created default config at {home_config_path}")
1403
+
1404
+ if not os.path.exists(home_ui_path):
1405
+ ui_json = await text_from_resource_or_url("ui.json")
1406
+ with open(home_ui_path, "w") as f:
1407
+ f.write(ui_json)
1408
+ _log(f"Created default ui config at {home_ui_path}")
1409
+ except Exception as e:
1410
+ print("Could not create llms.json. Create one with --init or use --config <path>")
1411
+ exit(1)
1412
+
1413
+ async def reload_providers():
1414
+ global g_config, g_handlers
1415
+ g_handlers = init_llms(g_config)
1416
+ await load_llms()
1417
+ _log(f"{len(g_handlers)} providers loaded")
1418
+ return g_handlers
1419
+
1420
+ async def watch_config_files(config_path, ui_path, interval=1):
1421
+ """Watch config files and reload providers when they change"""
1422
+ global g_config
1423
+
1424
+ config_path = Path(config_path)
1425
+ ui_path = Path(ui_path) if ui_path else None
1426
+
1427
+ file_mtimes = {}
1428
+
1429
+ _log(f"Watching config files: {config_path}" + (f", {ui_path}" if ui_path else ""))
1430
+
1431
+ while True:
1432
+ await asyncio.sleep(interval)
1433
+
1434
+ # Check llms.json
1435
+ try:
1436
+ if config_path.is_file():
1437
+ mtime = config_path.stat().st_mtime
1438
+
1439
+ if str(config_path) not in file_mtimes:
1440
+ file_mtimes[str(config_path)] = mtime
1441
+ elif file_mtimes[str(config_path)] != mtime:
1442
+ _log(f"Config file changed: {config_path.name}")
1443
+ file_mtimes[str(config_path)] = mtime
1444
+
1445
+ try:
1446
+ # Reload llms.json
1447
+ with open(config_path, "r") as f:
1448
+ g_config = json.load(f)
1449
+
1450
+ # Reload providers
1451
+ await reload_providers()
1452
+ _log("Providers reloaded successfully")
1453
+ except Exception as e:
1454
+ _log(f"Error reloading config: {e}")
1455
+ except FileNotFoundError:
1456
+ pass
1457
+
1458
+ # Check ui.json
1459
+ if ui_path:
1460
+ try:
1461
+ if ui_path.is_file():
1462
+ mtime = ui_path.stat().st_mtime
1463
+
1464
+ if str(ui_path) not in file_mtimes:
1465
+ file_mtimes[str(ui_path)] = mtime
1466
+ elif file_mtimes[str(ui_path)] != mtime:
1467
+ _log(f"Config file changed: {ui_path.name}")
1468
+ file_mtimes[str(ui_path)] = mtime
1469
+ _log("ui.json reloaded - reload page to update")
1470
+ except FileNotFoundError:
1471
+ pass
1472
+
1265
1473
  def main():
1266
- global _ROOT, g_verbose, g_default_model, g_logprefix, g_config_path, g_ui_path
1474
+ global _ROOT, g_verbose, g_default_model, g_logprefix, g_config, g_config_path, g_ui_path
1267
1475
 
1268
1476
  parser = argparse.ArgumentParser(description=f"llms v{VERSION}")
1269
1477
  parser.add_argument('--config', default=None, help='Path to config file', metavar='FILE')
@@ -1291,10 +1499,12 @@ def main():
1291
1499
  parser.add_argument('--root', default=None, help='Change root directory for UI files', metavar='PATH')
1292
1500
  parser.add_argument('--logprefix', default="", help='Prefix used in log messages', metavar='PREFIX')
1293
1501
  parser.add_argument('--verbose', action='store_true', help='Verbose output')
1294
- parser.add_argument('--update', action='store_true', help='Update to latest version')
1295
1502
 
1296
1503
  cli_args, extra_args = parser.parse_known_args()
1297
- if cli_args.verbose:
1504
+
1505
+ # Check for verbose mode from CLI argument or environment variables
1506
+ verbose_env = os.environ.get('VERBOSE', '').lower()
1507
+ if cli_args.verbose or verbose_env in ('1', 'true'):
1298
1508
  g_verbose = True
1299
1509
  # printdump(cli_args)
1300
1510
  if cli_args.model:
@@ -1302,24 +1512,13 @@ def main():
1302
1512
  if cli_args.logprefix:
1303
1513
  g_logprefix = cli_args.logprefix
1304
1514
 
1305
- if cli_args.config is not None:
1306
- g_config_path = os.path.join(os.path.dirname(__file__), cli_args.config)
1307
-
1308
- _ROOT = resolve_root()
1309
- if cli_args.root:
1310
- _ROOT = Path(cli_args.root)
1311
-
1515
+ _ROOT = Path(cli_args.root) if cli_args.root else resolve_root()
1312
1516
  if not _ROOT:
1313
1517
  print("Resource root not found")
1314
1518
  exit(1)
1315
1519
 
1316
- g_config_path = os.path.join(os.path.dirname(__file__), cli_args.config) if cli_args.config else get_config_path()
1317
- g_ui_path = get_ui_path()
1318
-
1319
1520
  home_config_path = home_llms_path("llms.json")
1320
- resource_config_path = _ROOT / "llms.json"
1321
1521
  home_ui_path = home_llms_path("ui.json")
1322
- resource_ui_path = _ROOT / "ui.json"
1323
1522
 
1324
1523
  if cli_args.init:
1325
1524
  if os.path.exists(home_config_path):
@@ -1331,74 +1530,37 @@ def main():
1331
1530
  if os.path.exists(home_ui_path):
1332
1531
  print(f"ui.json already exists at {home_ui_path}")
1333
1532
  else:
1334
- asyncio.run(save_text(github_url("ui.json"), home_ui_path))
1533
+ asyncio.run(save_text_url(github_url("ui.json"), home_ui_path))
1335
1534
  print(f"Created default ui config at {home_ui_path}")
1336
1535
  exit(0)
1337
1536
 
1338
- if not g_config_path or not os.path.exists(g_config_path):
1339
- # copy llms.json and ui.json to llms_home
1340
-
1341
- if not os.path.exists(home_config_path):
1342
- llms_home = os.path.dirname(home_config_path)
1343
- os.makedirs(llms_home, exist_ok=True)
1344
-
1345
- if resource_exists(resource_config_path):
1346
- try:
1347
- # Read config from resource (handle both Path and Traversable objects)
1348
- config_json = read_resource_text(resource_config_path)
1349
- except (OSError, AttributeError) as e:
1350
- _log(f"Error reading resource config: {e}")
1351
- if not config_json:
1352
- try:
1353
- config_json = asyncio.run(save_text(github_url("llms.json"), home_config_path))
1354
- except Exception as e:
1355
- _log(f"Error downloading llms.json: {e}")
1356
- print("Could not create llms.json. Create one with --init or use --config <path>")
1357
- exit(1)
1358
-
1359
- with open(home_config_path, "w") as f:
1360
- f.write(config_json)
1361
- _log(f"Created default config at {home_config_path}")
1362
- # Update g_config_path to point to the copied file
1363
- g_config_path = home_config_path
1364
- if not g_config_path or not os.path.exists(g_config_path):
1365
- print("llms.json not found. Create one with --init or use --config <path>")
1366
- exit(1)
1537
+ if cli_args.config:
1538
+ # read contents
1539
+ g_config_path = os.path.join(os.path.dirname(__file__), cli_args.config)
1540
+ with open(g_config_path, "r") as f:
1541
+ config_json = f.read()
1542
+ g_config = json.loads(config_json)
1367
1543
 
1368
- if not g_ui_path or not os.path.exists(g_ui_path):
1369
- # Read UI config from resource
1370
- if not os.path.exists(home_ui_path):
1371
- llms_home = os.path.dirname(home_ui_path)
1372
- os.makedirs(llms_home, exist_ok=True)
1373
- if resource_exists(resource_ui_path):
1374
- try:
1375
- # Read config from resource (handle both Path and Traversable objects)
1376
- ui_json = read_resource_text(resource_ui_path)
1377
- except (OSError, AttributeError) as e:
1378
- _log(f"Error reading resource ui config: {e}")
1379
- if not ui_json:
1380
- try:
1381
- ui_json = asyncio.run(save_text(github_url("ui.json"), home_ui_path))
1382
- except Exception as e:
1383
- _log(f"Error downloading ui.json: {e}")
1384
- print("Could not create ui.json. Create one with --init or use --config <path>")
1385
- exit(1)
1386
-
1387
- with open(home_ui_path, "w") as f:
1388
- f.write(ui_json)
1544
+ config_dir = os.path.dirname(g_config_path)
1545
+ # look for ui.json in same directory as config
1546
+ ui_path = os.path.join(config_dir, "ui.json")
1547
+ if os.path.exists(ui_path):
1548
+ g_ui_path = ui_path
1549
+ else:
1550
+ if not os.path.exists(home_ui_path):
1551
+ ui_json = text_from_resource("ui.json")
1552
+ with open(home_ui_path, "w") as f:
1553
+ f.write(ui_json)
1389
1554
  _log(f"Created default ui config at {home_ui_path}")
1390
-
1391
- # Update g_config_path to point to the copied file
1555
+ g_ui_path = home_ui_path
1556
+ else:
1557
+ # ensure llms.json and ui.json exist in home directory
1558
+ asyncio.run(save_home_configs())
1559
+ g_config_path = home_config_path
1392
1560
  g_ui_path = home_ui_path
1393
- if not g_ui_path or not os.path.exists(g_ui_path):
1394
- print("ui.json not found. Create one with --init or use --config <path>")
1395
- exit(1)
1561
+ g_config = json.loads(text_from_file(g_config_path))
1396
1562
 
1397
- # read contents
1398
- with open(g_config_path, "r") as f:
1399
- config_json = f.read()
1400
- init_llms(json.loads(config_json))
1401
- asyncio.run(load_llms())
1563
+ asyncio.run(reload_providers())
1402
1564
 
1403
1565
  # print names
1404
1566
  _log(f"enabled providers: {', '.join(g_handlers.keys())}")
@@ -1433,15 +1595,85 @@ def main():
1433
1595
  exit(0)
1434
1596
 
1435
1597
  if cli_args.serve is not None:
1598
+ # Disable inactive providers and save to config before starting server
1599
+ all_providers = g_config['providers'].keys()
1600
+ enabled_providers = list(g_handlers.keys())
1601
+ disable_providers = []
1602
+ for provider in all_providers:
1603
+ provider_config = g_config['providers'][provider]
1604
+ if provider not in enabled_providers:
1605
+ if 'enabled' in provider_config and provider_config['enabled']:
1606
+ provider_config['enabled'] = False
1607
+ disable_providers.append(provider)
1608
+
1609
+ if len(disable_providers) > 0:
1610
+ _log(f"Disabled unavailable providers: {', '.join(disable_providers)}")
1611
+ save_config(g_config)
1612
+
1613
+ # Start server
1436
1614
  port = int(cli_args.serve)
1437
1615
 
1438
1616
  if not os.path.exists(g_ui_path):
1439
1617
  print(f"UI not found at {g_ui_path}")
1440
1618
  exit(1)
1441
1619
 
1442
- app = web.Application()
1620
+ # Validate auth configuration if enabled
1621
+ auth_enabled = g_config.get('auth', {}).get('enabled', False)
1622
+ if auth_enabled:
1623
+ github_config = g_config.get('auth', {}).get('github', {})
1624
+ client_id = github_config.get('client_id', '')
1625
+ client_secret = github_config.get('client_secret', '')
1626
+
1627
+ # Expand environment variables
1628
+ if client_id.startswith('$'):
1629
+ client_id = os.environ.get(client_id[1:], '')
1630
+ if client_secret.startswith('$'):
1631
+ client_secret = os.environ.get(client_secret[1:], '')
1632
+
1633
+ if not client_id or not client_secret:
1634
+ print("ERROR: Authentication is enabled but GitHub OAuth is not properly configured.")
1635
+ print("Please set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET environment variables,")
1636
+ print("or disable authentication by setting 'auth.enabled' to false in llms.json")
1637
+ exit(1)
1638
+
1639
+ _log("Authentication enabled - GitHub OAuth configured")
1640
+
1641
+ client_max_size = g_config.get('limits', {}).get('client_max_size', 20*1024*1024) # 20MB max request size (to handle base64 encoding overhead)
1642
+ _log(f"client_max_size set to {client_max_size} bytes ({client_max_size/1024/1024:.1f}MB)")
1643
+ app = web.Application(client_max_size=client_max_size)
1644
+
1645
+ # Authentication middleware helper
1646
+ def check_auth(request):
1647
+ """Check if request is authenticated. Returns (is_authenticated, user_data)"""
1648
+ if not auth_enabled:
1649
+ return True, None
1650
+
1651
+ # Check for OAuth session token
1652
+ session_token = request.query.get('session') or request.headers.get('X-Session-Token')
1653
+ if session_token and session_token in g_sessions:
1654
+ return True, g_sessions[session_token]
1655
+
1656
+ # Check for API key
1657
+ auth_header = request.headers.get('Authorization', '')
1658
+ if auth_header.startswith('Bearer '):
1659
+ api_key = auth_header[7:]
1660
+ if api_key:
1661
+ return True, {"authProvider": "apikey"}
1662
+
1663
+ return False, None
1443
1664
 
1444
1665
  async def chat_handler(request):
1666
+ # Check authentication if enabled
1667
+ is_authenticated, user_data = check_auth(request)
1668
+ if not is_authenticated:
1669
+ return web.json_response({
1670
+ "error": {
1671
+ "message": "Authentication required",
1672
+ "type": "authentication_error",
1673
+ "code": "unauthorized"
1674
+ }
1675
+ }, status=401)
1676
+
1445
1677
  try:
1446
1678
  chat = await request.json()
1447
1679
  response = await chat_completion(chat)
@@ -1487,6 +1719,226 @@ def main():
1487
1719
  })
1488
1720
  app.router.add_post('/providers/{provider}', provider_handler)
1489
1721
 
1722
+ # OAuth handlers
1723
+ async def github_auth_handler(request):
1724
+ """Initiate GitHub OAuth flow"""
1725
+ if 'auth' not in g_config or 'github' not in g_config['auth']:
1726
+ return web.json_response({"error": "GitHub OAuth not configured"}, status=500)
1727
+
1728
+ auth_config = g_config['auth']['github']
1729
+ client_id = auth_config.get('client_id', '')
1730
+ redirect_uri = auth_config.get('redirect_uri', '')
1731
+
1732
+ # Expand environment variables
1733
+ if client_id.startswith('$'):
1734
+ client_id = os.environ.get(client_id[1:], '')
1735
+ if redirect_uri.startswith('$'):
1736
+ redirect_uri = os.environ.get(redirect_uri[1:], '')
1737
+
1738
+ if not client_id:
1739
+ return web.json_response({"error": "GitHub client_id not configured"}, status=500)
1740
+
1741
+ # Generate CSRF state token
1742
+ state = secrets.token_urlsafe(32)
1743
+ g_oauth_states[state] = {
1744
+ 'created': time.time(),
1745
+ 'redirect_uri': redirect_uri
1746
+ }
1747
+
1748
+ # Clean up old states (older than 10 minutes)
1749
+ current_time = time.time()
1750
+ expired_states = [s for s, data in g_oauth_states.items() if current_time - data['created'] > 600]
1751
+ for s in expired_states:
1752
+ del g_oauth_states[s]
1753
+
1754
+ # Build GitHub authorization URL
1755
+ params = {
1756
+ 'client_id': client_id,
1757
+ 'redirect_uri': redirect_uri,
1758
+ 'state': state,
1759
+ 'scope': 'read:user user:email'
1760
+ }
1761
+ auth_url = f"https://github.com/login/oauth/authorize?{urlencode(params)}"
1762
+
1763
+ return web.HTTPFound(auth_url)
1764
+
1765
+ def validate_user(github_username):
1766
+ auth_config = g_config['auth']['github']
1767
+ # Check if user is restricted
1768
+ restrict_to = auth_config.get('restrict_to', '')
1769
+
1770
+ # Expand environment variables
1771
+ if restrict_to.startswith('$'):
1772
+ restrict_to = os.environ.get(restrict_to[1:], '')
1773
+
1774
+ # If restrict_to is configured, validate the user
1775
+ if restrict_to:
1776
+ # Parse allowed users (comma or space delimited)
1777
+ allowed_users = [u.strip() for u in re.split(r'[,\s]+', restrict_to) if u.strip()]
1778
+
1779
+ # Check if user is in the allowed list
1780
+ if not github_username or github_username not in allowed_users:
1781
+ _log(f"Access denied for user: {github_username}. Not in allowed list: {allowed_users}")
1782
+ return web.Response(
1783
+ text=f"Access denied. User '{github_username}' is not authorized to access this application.",
1784
+ status=403
1785
+ )
1786
+ return None
1787
+
1788
+ async def github_callback_handler(request):
1789
+ """Handle GitHub OAuth callback"""
1790
+ code = request.query.get('code')
1791
+ state = request.query.get('state')
1792
+
1793
+ if not code or not state:
1794
+ return web.Response(text="Missing code or state parameter", status=400)
1795
+
1796
+ # Verify state token (CSRF protection)
1797
+ if state not in g_oauth_states:
1798
+ return web.Response(text="Invalid state parameter", status=400)
1799
+
1800
+ state_data = g_oauth_states.pop(state)
1801
+
1802
+ if 'auth' not in g_config or 'github' not in g_config['auth']:
1803
+ return web.json_response({"error": "GitHub OAuth not configured"}, status=500)
1804
+
1805
+ auth_config = g_config['auth']['github']
1806
+ client_id = auth_config.get('client_id', '')
1807
+ client_secret = auth_config.get('client_secret', '')
1808
+ redirect_uri = auth_config.get('redirect_uri', '')
1809
+
1810
+ # Expand environment variables
1811
+ if client_id.startswith('$'):
1812
+ client_id = os.environ.get(client_id[1:], '')
1813
+ if client_secret.startswith('$'):
1814
+ client_secret = os.environ.get(client_secret[1:], '')
1815
+ if redirect_uri.startswith('$'):
1816
+ redirect_uri = os.environ.get(redirect_uri[1:], '')
1817
+
1818
+ if not client_id or not client_secret:
1819
+ return web.json_response({"error": "GitHub OAuth credentials not configured"}, status=500)
1820
+
1821
+ # Exchange code for access token
1822
+ async with aiohttp.ClientSession() as session:
1823
+ token_url = "https://github.com/login/oauth/access_token"
1824
+ token_data = {
1825
+ 'client_id': client_id,
1826
+ 'client_secret': client_secret,
1827
+ 'code': code,
1828
+ 'redirect_uri': redirect_uri
1829
+ }
1830
+ headers = {'Accept': 'application/json'}
1831
+
1832
+ async with session.post(token_url, data=token_data, headers=headers) as resp:
1833
+ token_response = await resp.json()
1834
+ access_token = token_response.get('access_token')
1835
+
1836
+ if not access_token:
1837
+ error = token_response.get('error_description', 'Failed to get access token')
1838
+ return web.Response(text=f"OAuth error: {error}", status=400)
1839
+
1840
+ # Fetch user info
1841
+ user_url = "https://api.github.com/user"
1842
+ headers = {
1843
+ "Authorization": f"Bearer {access_token}",
1844
+ "Accept": "application/json"
1845
+ }
1846
+
1847
+ async with session.get(user_url, headers=headers) as resp:
1848
+ user_data = await resp.json()
1849
+
1850
+ # Validate user
1851
+ error_response = validate_user(user_data.get('login', ''))
1852
+ if error_response:
1853
+ return error_response
1854
+
1855
+ # Create session
1856
+ session_token = secrets.token_urlsafe(32)
1857
+ g_sessions[session_token] = {
1858
+ "userId": str(user_data.get('id', '')),
1859
+ "userName": user_data.get('login', ''),
1860
+ "displayName": user_data.get('name', ''),
1861
+ "profileUrl": user_data.get('avatar_url', ''),
1862
+ "email": user_data.get('email', ''),
1863
+ "created": time.time()
1864
+ }
1865
+
1866
+ # Redirect to UI with session token
1867
+ return web.HTTPFound(f"/?session={session_token}")
1868
+
1869
+ async def session_handler(request):
1870
+ """Validate and return session info"""
1871
+ session_token = request.query.get('session') or request.headers.get('X-Session-Token')
1872
+
1873
+ if not session_token or session_token not in g_sessions:
1874
+ return web.json_response({"error": "Invalid or expired session"}, status=401)
1875
+
1876
+ session_data = g_sessions[session_token]
1877
+
1878
+ # Clean up old sessions (older than 24 hours)
1879
+ current_time = time.time()
1880
+ expired_sessions = [token for token, data in g_sessions.items() if current_time - data['created'] > 86400]
1881
+ for token in expired_sessions:
1882
+ del g_sessions[token]
1883
+
1884
+ return web.json_response({
1885
+ **session_data,
1886
+ "sessionToken": session_token
1887
+ })
1888
+
1889
+ async def logout_handler(request):
1890
+ """End OAuth session"""
1891
+ session_token = request.query.get('session') or request.headers.get('X-Session-Token')
1892
+
1893
+ if session_token and session_token in g_sessions:
1894
+ del g_sessions[session_token]
1895
+
1896
+ return web.json_response({"success": True})
1897
+
1898
+ async def auth_handler(request):
1899
+ """Check authentication status and return user info"""
1900
+ # Check for OAuth session token
1901
+ session_token = request.query.get('session') or request.headers.get('X-Session-Token')
1902
+
1903
+ if session_token and session_token in g_sessions:
1904
+ session_data = g_sessions[session_token]
1905
+ return web.json_response({
1906
+ "userId": session_data.get("userId", ""),
1907
+ "userName": session_data.get("userName", ""),
1908
+ "displayName": session_data.get("displayName", ""),
1909
+ "profileUrl": session_data.get("profileUrl", ""),
1910
+ "authProvider": "github"
1911
+ })
1912
+
1913
+ # Check for API key in Authorization header
1914
+ # auth_header = request.headers.get('Authorization', '')
1915
+ # if auth_header.startswith('Bearer '):
1916
+ # # For API key auth, return a basic response
1917
+ # # You can customize this based on your API key validation logic
1918
+ # api_key = auth_header[7:]
1919
+ # if api_key: # Add your API key validation logic here
1920
+ # return web.json_response({
1921
+ # "userId": "1",
1922
+ # "userName": "apiuser",
1923
+ # "displayName": "API User",
1924
+ # "profileUrl": "",
1925
+ # "authProvider": "apikey"
1926
+ # })
1927
+
1928
+ # Not authenticated - return error in expected format
1929
+ return web.json_response({
1930
+ "responseStatus": {
1931
+ "errorCode": "Unauthorized",
1932
+ "message": "Not authenticated"
1933
+ }
1934
+ }, status=401)
1935
+
1936
+ app.router.add_get('/auth', auth_handler)
1937
+ app.router.add_get('/auth/github', github_auth_handler)
1938
+ app.router.add_get('/auth/github/callback', github_callback_handler)
1939
+ app.router.add_get('/auth/session', session_handler)
1940
+ app.router.add_post('/auth/logout', logout_handler)
1941
+
1490
1942
  async def ui_static(request: web.Request) -> web.Response:
1491
1943
  path = Path(request.match_info["path"])
1492
1944
 
@@ -1526,9 +1978,12 @@ def main():
1526
1978
  enabled, disabled = provider_status()
1527
1979
  ui['status'] = {
1528
1980
  "all": list(g_config['providers'].keys()),
1529
- "enabled": enabled,
1530
- "disabled": disabled
1981
+ "enabled": enabled,
1982
+ "disabled": disabled
1531
1983
  }
1984
+ # Add auth configuration
1985
+ ui['requiresAuth'] = auth_enabled
1986
+ ui['authType'] = 'oauth' if auth_enabled else 'apikey'
1532
1987
  return web.json_response(ui)
1533
1988
  app.router.add_get('/config', ui_config_handler)
1534
1989
 
@@ -1547,6 +2002,14 @@ def main():
1547
2002
  # Serve index.html as fallback route (SPA routing)
1548
2003
  app.router.add_route('*', '/{tail:.*}', index_handler)
1549
2004
 
2005
+ # Setup file watcher for config files
2006
+ async def start_background_tasks(app):
2007
+ """Start background tasks when the app starts"""
2008
+ # Start watching config files in the background
2009
+ asyncio.create_task(watch_config_files(g_config_path, g_ui_path))
2010
+
2011
+ app.on_startup.append(start_background_tasks)
2012
+
1550
2013
  print(f"Starting server on port {port}...")
1551
2014
  web.run_app(app, host='0.0.0.0', port=port, print=_log)
1552
2015
  exit(0)
@@ -1617,11 +2080,6 @@ def main():
1617
2080
  print(f"\nDefault model set to: {default_model}")
1618
2081
  exit(0)
1619
2082
 
1620
- if cli_args.update:
1621
- asyncio.run(update_llms())
1622
- print(f"{__file__} updated")
1623
- exit(0)
1624
-
1625
2083
  if cli_args.chat is not None or cli_args.image is not None or cli_args.audio is not None or cli_args.file is not None or len(extra_args) > 0:
1626
2084
  try:
1627
2085
  chat = g_config['defaults']['text']