llms-py 2.0.26__py3-none-any.whl → 2.0.28__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
llms/index.html CHANGED
@@ -1,8 +1,8 @@
1
1
  <html>
2
2
  <head>
3
3
  <title>llms.py</title>
4
- <link rel="stylesheet" href="/ui/typography.css">
5
4
  <link rel="stylesheet" href="/ui/app.css">
5
+ <link rel="stylesheet" href="/ui/typography.css">
6
6
  <link rel="icon" type="image/svg" href="/ui/fav.svg">
7
7
  <style>
8
8
  [type='button'],button[type='submit']{cursor:pointer}
@@ -38,6 +38,22 @@
38
38
  <body>
39
39
  <div id="app"></div>
40
40
  </body>
41
+ <script>
42
+ let colorScheme = location.search === "?dark"
43
+ ? "dark"
44
+ : location.search === "?light"
45
+ ? "light"
46
+ : localStorage.getItem('color-scheme')
47
+ let darkMode = colorScheme != null
48
+ ? colorScheme === 'dark'
49
+ : window.matchMedia('(prefers-color-scheme: dark)').matches
50
+ let html = document.documentElement
51
+ html.classList.toggle('dark', darkMode)
52
+ html.style.setProperty('color-scheme', darkMode ? 'dark' : null)
53
+ if (localStorage.getItem('color-scheme') === null) {
54
+ localStorage.setItem('color-scheme', darkMode ? 'dark' : 'light')
55
+ }
56
+ </script>
41
57
  <script type="module">
42
58
  import { createApp, defineAsyncComponent } from 'vue'
43
59
  import { createWebHistory, createRouter } from "vue-router"
llms/llms.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "auth": {
3
- "enabled": true,
3
+ "enabled": false,
4
4
  "github": {
5
5
  "client_id": "$GITHUB_CLIENT_ID",
6
6
  "client_secret": "$GITHUB_CLIENT_SECRET",
7
- "redirect_uri": "http://localhost:8000/auth/github/callback"
7
+ "redirect_uri": "http://localhost:8000/auth/github/callback",
8
+ "restrict_to": "$GITHUB_USERS"
8
9
  }
9
10
  },
10
11
  "defaults": {
@@ -104,6 +105,15 @@
104
105
  "stream": false
105
106
  }
106
107
  },
108
+ "limits": {
109
+ "client_max_size": 20971520
110
+ },
111
+ "convert": {
112
+ "image": {
113
+ "max_size": "1536x1024",
114
+ "max_length": 1572864
115
+ }
116
+ },
107
117
  "providers": {
108
118
  "openrouter_free": {
109
119
  "enabled": true,
llms/main.py CHANGED
@@ -15,6 +15,8 @@ import traceback
15
15
  import sys
16
16
  import site
17
17
  import secrets
18
+ import re
19
+ from io import BytesIO
18
20
  from urllib.parse import parse_qs, urlencode
19
21
 
20
22
  import aiohttp
@@ -23,7 +25,13 @@ from aiohttp import web
23
25
  from pathlib import Path
24
26
  from importlib import resources # Py≥3.9 (pip install importlib_resources for 3.7/3.8)
25
27
 
26
- VERSION = "2.0.26"
28
+ try:
29
+ from PIL import Image
30
+ HAS_PIL = True
31
+ except ImportError:
32
+ HAS_PIL = False
33
+
34
+ VERSION = "2.0.28"
27
35
  _ROOT = None
28
36
  g_config_path = None
29
37
  g_ui_path = None
@@ -200,6 +208,77 @@ def price_to_string(price: float | int | str | None) -> str | None:
200
208
  except (ValueError, TypeError):
201
209
  return None
202
210
 
211
+ def convert_image_if_needed(image_bytes, mimetype='image/png'):
212
+ """
213
+ Convert and resize image to WebP if it exceeds configured limits.
214
+
215
+ Args:
216
+ image_bytes: Raw image bytes
217
+ mimetype: Original image MIME type
218
+
219
+ Returns:
220
+ tuple: (converted_bytes, new_mimetype) or (original_bytes, original_mimetype) if no conversion needed
221
+ """
222
+ if not HAS_PIL:
223
+ return image_bytes, mimetype
224
+
225
+ # Get conversion config
226
+ convert_config = g_config.get('convert', {}).get('image', {}) if g_config else {}
227
+ if not convert_config:
228
+ return image_bytes, mimetype
229
+
230
+ max_size_str = convert_config.get('max_size', '1536x1024')
231
+ max_length = convert_config.get('max_length', 1.5*1024*1024) # 1.5MB
232
+
233
+ try:
234
+ # Parse max_size (e.g., "1536x1024")
235
+ max_width, max_height = map(int, max_size_str.split('x'))
236
+
237
+ # Open image
238
+ with Image.open(BytesIO(image_bytes)) as img:
239
+ original_width, original_height = img.size
240
+
241
+ # Check if image exceeds limits
242
+ needs_resize = original_width > max_width or original_height > max_height
243
+
244
+ # Check if base64 length would exceed max_length (in KB)
245
+ # Base64 encoding increases size by ~33%, so check raw bytes * 1.33 / 1024
246
+ estimated_kb = (len(image_bytes) * 1.33) / 1024
247
+ needs_conversion = estimated_kb > max_length
248
+
249
+ if not needs_resize and not needs_conversion:
250
+ return image_bytes, mimetype
251
+
252
+ # Convert RGBA to RGB if necessary (WebP doesn't support transparency in RGB mode)
253
+ if img.mode in ('RGBA', 'LA', 'P'):
254
+ # Create a white background
255
+ background = Image.new('RGB', img.size, (255, 255, 255))
256
+ if img.mode == 'P':
257
+ img = img.convert('RGBA')
258
+ background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
259
+ img = background
260
+ elif img.mode != 'RGB':
261
+ img = img.convert('RGB')
262
+
263
+ # Resize if needed (preserve aspect ratio)
264
+ if needs_resize:
265
+ img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
266
+ _log(f"Resized image from {original_width}x{original_height} to {img.size[0]}x{img.size[1]}")
267
+
268
+ # Convert to WebP
269
+ output = BytesIO()
270
+ img.save(output, format='WEBP', quality=85, method=6)
271
+ converted_bytes = output.getvalue()
272
+
273
+ _log(f"Converted image to WebP: {len(image_bytes)} bytes -> {len(converted_bytes)} bytes ({len(converted_bytes)*100//len(image_bytes)}%)")
274
+
275
+ return converted_bytes, 'image/webp'
276
+
277
+ except Exception as e:
278
+ _log(f"Error converting image: {e}")
279
+ # Return original if conversion fails
280
+ return image_bytes, mimetype
281
+
203
282
  async def process_chat(chat):
204
283
  if not chat:
205
284
  raise Exception("No chat provided")
@@ -230,19 +309,31 @@ async def process_chat(chat):
230
309
  mimetype = get_file_mime_type(get_filename(url))
231
310
  if 'Content-Type' in response.headers:
232
311
  mimetype = response.headers['Content-Type']
312
+ # convert/resize image if needed
313
+ content, mimetype = convert_image_if_needed(content, mimetype)
233
314
  # convert to data uri
234
315
  image_url['url'] = f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}"
235
316
  elif is_file_path(url):
236
317
  _log(f"Reading image: {url}")
237
318
  with open(url, "rb") as f:
238
319
  content = f.read()
239
- ext = os.path.splitext(url)[1].lower().lstrip('.') if '.' in url else 'png'
240
320
  # get mimetype from file extension
241
321
  mimetype = get_file_mime_type(get_filename(url))
322
+ # convert/resize image if needed
323
+ content, mimetype = convert_image_if_needed(content, mimetype)
242
324
  # convert to data uri
243
325
  image_url['url'] = f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}"
244
326
  elif url.startswith('data:'):
245
- pass
327
+ # Extract existing data URI and process it
328
+ if ';base64,' in url:
329
+ prefix = url.split(';base64,')[0]
330
+ mimetype = prefix.split(':')[1] if ':' in prefix else 'image/png'
331
+ base64_data = url.split(';base64,')[1]
332
+ content = base64.b64decode(base64_data)
333
+ # convert/resize image if needed
334
+ content, mimetype = convert_image_if_needed(content, mimetype)
335
+ # update data uri with potentially converted image
336
+ image_url['url'] = f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}"
246
337
  else:
247
338
  raise Exception(f"Invalid image: {url}")
248
339
  elif item['type'] == 'input_audio' and 'input_audio' in item:
@@ -1480,7 +1571,9 @@ def main():
1480
1571
 
1481
1572
  _log("Authentication enabled - GitHub OAuth configured")
1482
1573
 
1483
- app = web.Application()
1574
+ client_max_size = g_config.get('limits', {}).get('client_max_size', 20*1024*1024) # 20MB max request size (to handle base64 encoding overhead)
1575
+ _log(f"client_max_size set to {client_max_size} bytes ({client_max_size/1024/1024:.1f}MB)")
1576
+ app = web.Application(client_max_size=client_max_size)
1484
1577
 
1485
1578
  # Authentication middleware helper
1486
1579
  def check_auth(request):
@@ -1601,6 +1694,29 @@ def main():
1601
1694
  auth_url = f"https://github.com/login/oauth/authorize?{urlencode(params)}"
1602
1695
 
1603
1696
  return web.HTTPFound(auth_url)
1697
+
1698
+ def validate_user(github_username):
1699
+ auth_config = g_config['auth']['github']
1700
+ # Check if user is restricted
1701
+ restrict_to = auth_config.get('restrict_to', '')
1702
+
1703
+ # Expand environment variables
1704
+ if restrict_to.startswith('$'):
1705
+ restrict_to = os.environ.get(restrict_to[1:], '')
1706
+
1707
+ # If restrict_to is configured, validate the user
1708
+ if restrict_to:
1709
+ # Parse allowed users (comma or space delimited)
1710
+ allowed_users = [u.strip() for u in re.split(r'[,\s]+', restrict_to) if u.strip()]
1711
+
1712
+ # Check if user is in the allowed list
1713
+ if not github_username or github_username not in allowed_users:
1714
+ _log(f"Access denied for user: {github_username}. Not in allowed list: {allowed_users}")
1715
+ return web.Response(
1716
+ text=f"Access denied. User '{github_username}' is not authorized to access this application.",
1717
+ status=403
1718
+ )
1719
+ return None
1604
1720
 
1605
1721
  async def github_callback_handler(request):
1606
1722
  """Handle GitHub OAuth callback"""
@@ -1664,6 +1780,11 @@ def main():
1664
1780
  async with session.get(user_url, headers=headers) as resp:
1665
1781
  user_data = await resp.json()
1666
1782
 
1783
+ # Validate user
1784
+ error_response = validate_user(user_data.get('login', ''))
1785
+ if error_response:
1786
+ return error_response
1787
+
1667
1788
  # Create session
1668
1789
  session_token = secrets.token_urlsafe(32)
1669
1790
  g_sessions[session_token] = {