llms-py 2.0.27__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/llms.json CHANGED
@@ -4,7 +4,8 @@
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.27"
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] = {
llms/ui/Analytics.mjs CHANGED
@@ -1021,7 +1021,9 @@ export default {
1021
1021
 
1022
1022
  // Only display label if percentage > 1%
1023
1023
  if (parseFloat(percentage) > 1) {
1024
- chartCtx.fillStyle = '#000'
1024
+ // Use white color in dark mode, black in light mode
1025
+ const isDarkMode = document.documentElement.classList.contains('dark')
1026
+ chartCtx.fillStyle = isDarkMode ? '#fff' : '#000'
1025
1027
  chartCtx.font = 'bold 12px Arial'
1026
1028
  chartCtx.textAlign = 'center'
1027
1029
  chartCtx.textBaseline = 'middle'
@@ -1078,7 +1080,9 @@ export default {
1078
1080
 
1079
1081
  // Only display label if percentage > 1%
1080
1082
  if (parseFloat(percentage) > 1) {
1081
- chartCtx.fillStyle = '#000'
1083
+ // Use white color in dark mode, black in light mode
1084
+ const isDarkMode = document.documentElement.classList.contains('dark')
1085
+ chartCtx.fillStyle = isDarkMode ? '#fff' : '#000'
1082
1086
  chartCtx.font = 'bold 12px Arial'
1083
1087
  chartCtx.textAlign = 'center'
1084
1088
  chartCtx.textBaseline = 'middle'
@@ -1135,7 +1139,9 @@ export default {
1135
1139
 
1136
1140
  // Only display label if percentage > 1%
1137
1141
  if (parseFloat(percentage) > 1) {
1138
- chartCtx.fillStyle = '#000'
1142
+ // Use white color in dark mode, black in light mode
1143
+ const isDarkMode = document.documentElement.classList.contains('dark')
1144
+ chartCtx.fillStyle = isDarkMode ? '#fff' : '#000'
1139
1145
  chartCtx.font = 'bold 12px Arial'
1140
1146
  chartCtx.textAlign = 'center'
1141
1147
  chartCtx.textBaseline = 'middle'
@@ -1192,7 +1198,9 @@ export default {
1192
1198
 
1193
1199
  // Only display label if percentage > 1%
1194
1200
  if (parseFloat(percentage) > 1) {
1195
- chartCtx.fillStyle = '#000'
1201
+ // Use white color in dark mode, black in light mode
1202
+ const isDarkMode = document.documentElement.classList.contains('dark')
1203
+ chartCtx.fillStyle = isDarkMode ? '#fff' : '#000'
1196
1204
  chartCtx.font = 'bold 12px Arial'
1197
1205
  chartCtx.textAlign = 'center'
1198
1206
  chartCtx.textBaseline = 'middle'
llms/ui/Main.mjs CHANGED
@@ -38,7 +38,6 @@ export default {
38
38
  <SystemPromptSelector :prompts="prompts" v-model="selectedPrompt"
39
39
  :show="showSystemPrompt" @toggle="showSystemPrompt = !showSystemPrompt" />
40
40
  <Avatar />
41
- <DarkModeToggle />
42
41
  </div>
43
42
  </div>
44
43
  </div>
@@ -63,7 +62,7 @@ export default {
63
62
  </div>
64
63
 
65
64
  <!-- Export/Import buttons -->
66
- <div class="mt-2 flex space-x-3 justify-center">
65
+ <div class="mt-2 flex space-x-3 justify-center items-center">
67
66
  <button type="button"
68
67
  @click="(e) => e.altKey ? exportRequests() : exportThreads()"
69
68
  :disabled="isExporting"
@@ -104,6 +103,9 @@ export default {
104
103
  @change="handleFileImport"
105
104
  class="hidden"
106
105
  />
106
+
107
+ <DarkModeToggle />
108
+
107
109
  </div>
108
110
 
109
111
  </div>
llms/ui/ai.mjs CHANGED
@@ -6,7 +6,7 @@ const headers = { 'Accept': 'application/json' }
6
6
  const prefsKey = 'llms.prefs'
7
7
 
8
8
  export const o = {
9
- version: '2.0.27',
9
+ version: '2.0.28',
10
10
  base,
11
11
  prefsKey,
12
12
  welcome: 'Welcome to llms.py',
llms/ui/app.css CHANGED
@@ -82,7 +82,6 @@
82
82
  --color-purple-900: oklch(38.1% 0.176 304.987);
83
83
  --color-slate-50: oklch(98.4% 0.003 247.858);
84
84
  --color-slate-200: oklch(92.9% 0.013 255.508);
85
- --color-slate-300: oklch(86.9% 0.022 252.894);
86
85
  --color-slate-400: oklch(70.4% 0.04 256.788);
87
86
  --color-slate-500: oklch(55.4% 0.046 257.417);
88
87
  --color-slate-700: oklch(37.2% 0.044 257.287);
@@ -399,15 +398,9 @@
399
398
  max-width: 96rem;
400
399
  }
401
400
  }
402
- .-m-2 {
403
- margin: calc(var(--spacing) * -2);
404
- }
405
401
  .-m-2\.5 {
406
402
  margin: calc(var(--spacing) * -2.5);
407
403
  }
408
- .-mx-1 {
409
- margin-inline: calc(var(--spacing) * -1);
410
- }
411
404
  .-mx-1\.5 {
412
405
  margin-inline: calc(var(--spacing) * -1.5);
413
406
  }
@@ -420,9 +413,6 @@
420
413
  .mx-auto {
421
414
  margin-inline: auto;
422
415
  }
423
- .-my-1 {
424
- margin-block: calc(var(--spacing) * -1);
425
- }
426
416
  .-my-1\.5 {
427
417
  margin-block: calc(var(--spacing) * -1.5);
428
418
  }
@@ -498,9 +488,6 @@
498
488
  .-ml-px {
499
489
  margin-left: -1px;
500
490
  }
501
- .ml-0 {
502
- margin-left: calc(var(--spacing) * 0);
503
- }
504
491
  .ml-0\.5 {
505
492
  margin-left: calc(var(--spacing) * 0.5);
506
493
  }
@@ -1091,9 +1078,6 @@
1091
1078
  .border-yellow-400 {
1092
1079
  border-color: var(--color-yellow-400);
1093
1080
  }
1094
- .bg-black {
1095
- background-color: var(--color-black);
1096
- }
1097
1081
  .bg-black\/40 {
1098
1082
  background-color: color-mix(in srgb, #000 40%, transparent);
1099
1083
  @supports (color: color-mix(in lab, red, red)) {
@@ -1134,9 +1118,6 @@
1134
1118
  .bg-gray-400 {
1135
1119
  background-color: var(--color-gray-400);
1136
1120
  }
1137
- .bg-gray-500 {
1138
- background-color: var(--color-gray-500);
1139
- }
1140
1121
  .bg-gray-500\/75 {
1141
1122
  background-color: color-mix(in srgb, oklch(55.1% 0.027 264.364) 75%, transparent);
1142
1123
  @supports (color: color-mix(in lab, red, red)) {
@@ -1151,9 +1132,6 @@
1151
1132
  .bg-gray-700 {
1152
1133
  background-color: var(--color-gray-700);
1153
1134
  }
1154
- .bg-gray-900 {
1155
- background-color: var(--color-gray-900);
1156
- }
1157
1135
  .bg-gray-900\/80 {
1158
1136
  background-color: color-mix(in srgb, oklch(21% 0.034 264.665) 80%, transparent);
1159
1137
  @supports (color: color-mix(in lab, red, red)) {
@@ -1204,9 +1182,6 @@
1204
1182
  .bg-sky-600 {
1205
1183
  background-color: var(--color-sky-600);
1206
1184
  }
1207
- .bg-slate-400 {
1208
- background-color: var(--color-slate-400);
1209
- }
1210
1185
  .bg-slate-400\/10 {
1211
1186
  background-color: color-mix(in srgb, oklch(70.4% 0.04 256.788) 10%, transparent);
1212
1187
  @supports (color: color-mix(in lab, red, red)) {
@@ -1269,9 +1244,6 @@
1269
1244
  .px-6 {
1270
1245
  padding-inline: calc(var(--spacing) * 6);
1271
1246
  }
1272
- .py-0 {
1273
- padding-block: calc(var(--spacing) * 0);
1274
- }
1275
1247
  .py-0\.5 {
1276
1248
  padding-block: calc(var(--spacing) * 0.5);
1277
1249
  }
@@ -1302,9 +1274,6 @@
1302
1274
  .py-12 {
1303
1275
  padding-block: calc(var(--spacing) * 12);
1304
1276
  }
1305
- .pt-0 {
1306
- padding-top: calc(var(--spacing) * 0);
1307
- }
1308
1277
  .pt-0\.5 {
1309
1278
  padding-top: calc(var(--spacing) * 0.5);
1310
1279
  }
@@ -1596,23 +1565,12 @@
1596
1565
  .text-sky-600 {
1597
1566
  color: var(--color-sky-600);
1598
1567
  }
1599
- .text-slate-300 {
1600
- color: var(--color-slate-300);
1601
- }
1602
1568
  .text-slate-500 {
1603
1569
  color: var(--color-slate-500);
1604
1570
  }
1605
1571
  .text-white {
1606
1572
  color: var(--color-white);
1607
1573
  }
1608
- .text-white\/70 {
1609
- color: color-mix(in srgb, #fff 70%, transparent);
1610
- @supports (color: color-mix(in lab, red, red)) {
1611
- & {
1612
- color: color-mix(in oklab, var(--color-white) 70%, transparent);
1613
- }
1614
- }
1615
- }
1616
1574
  .text-yellow-400 {
1617
1575
  color: var(--color-yellow-400);
1618
1576
  }
@@ -1631,9 +1589,6 @@
1631
1589
  .uppercase {
1632
1590
  text-transform: uppercase;
1633
1591
  }
1634
- .underline {
1635
- text-decoration-line: underline;
1636
- }
1637
1592
  .placeholder-gray-500 {
1638
1593
  &::placeholder {
1639
1594
  color: var(--color-gray-500);
@@ -1695,9 +1650,6 @@
1695
1650
  --tw-inset-ring-shadow: inset 0 0 0 1px var(--tw-inset-ring-color, currentcolor);
1696
1651
  box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
1697
1652
  }
1698
- .ring-black {
1699
- --tw-ring-color: var(--color-black);
1700
- }
1701
1653
  .ring-black\/5 {
1702
1654
  --tw-ring-color: color-mix(in srgb, #000 5%, transparent);
1703
1655
  @supports (color: color-mix(in lab, red, red)) {
@@ -1712,9 +1664,6 @@
1712
1664
  .ring-indigo-500 {
1713
1665
  --tw-ring-color: var(--color-indigo-500);
1714
1666
  }
1715
- .inset-ring-gray-900 {
1716
- --tw-inset-ring-color: var(--color-gray-900);
1717
- }
1718
1667
  .inset-ring-gray-900\/5 {
1719
1668
  --tw-inset-ring-color: color-mix(in srgb, oklch(21% 0.034 264.665) 5%, transparent);
1720
1669
  @supports (color: color-mix(in lab, red, red)) {
@@ -2103,18 +2052,6 @@
2103
2052
  }
2104
2053
  }
2105
2054
  }
2106
- .hover\:bg-white\/20 {
2107
- &:hover {
2108
- @media (hover: hover) {
2109
- background-color: color-mix(in srgb, #fff 20%, transparent);
2110
- @supports (color: color-mix(in lab, red, red)) {
2111
- & {
2112
- background-color: color-mix(in oklab, var(--color-white) 20%, transparent);
2113
- }
2114
- }
2115
- }
2116
- }
2117
- }
2118
2055
  .hover\:bg-yellow-50 {
2119
2056
  &:hover {
2120
2057
  @media (hover: hover) {
@@ -2269,13 +2206,6 @@
2269
2206
  }
2270
2207
  }
2271
2208
  }
2272
- .hover\:text-white {
2273
- &:hover {
2274
- @media (hover: hover) {
2275
- color: var(--color-white);
2276
- }
2277
- }
2278
- }
2279
2209
  .hover\:shadow {
2280
2210
  &:hover {
2281
2211
  @media (hover: hover) {
llms/ui/typography.css CHANGED
@@ -94,16 +94,18 @@
94
94
  border-left-style: solid;
95
95
  }
96
96
 
97
- .dark .prose :not(:where([class~="not-prose"] *)), .dark .prose :where(td):not(:where([class~="not-prose"] *)) {
97
+ .dark .prose :where(td):not(:where([class~="not-prose"] *)) {
98
98
  color: rgb(209 213 219); /*text-gray-300*/
99
99
  }
100
100
  .dark .prose :where(h1,h2,h3,h4,h5,h6,th):not(:where([class~="not-prose"] *)) {
101
101
  color: rgb(243 244 246); /*text-gray-100*/
102
102
  }
103
- .dark .prose :where(code):not(:where([class~="not-prose"] *)) {
103
+ .dark .prose :where(code):not(:where([class~="not-prose"] *)),
104
+ .dark .message em {
104
105
  background-color: rgb(30 58 138); /*text-blue-900*/
105
106
  color: rgb(243 244 246); /*text-gray-100*/
106
107
  }
108
+
107
109
  .dark .prose :where(pre code):not(:where([class~="not-prose"] *)) {
108
110
  background-color: unset;
109
111
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: llms-py
3
- Version: 2.0.27
3
+ Version: 2.0.28
4
4
  Summary: A lightweight CLI tool and OpenAI-compatible server for querying multiple Large Language Model (LLM) providers
5
5
  Home-page: https://github.com/ServiceStack/llms
6
6
  Author: ServiceStack
@@ -50,7 +50,7 @@ Configure additional providers and models in [llms.json](llms/llms.json)
50
50
 
51
51
  ## Features
52
52
 
53
- - **Lightweight**: Single [llms.py](https://github.com/ServiceStack/llms/blob/main/llms/main.py) Python file with single `aiohttp` dependency
53
+ - **Lightweight**: Single [llms.py](https://github.com/ServiceStack/llms/blob/main/llms/main.py) Python file with single `aiohttp` dependency (Pillow optional)
54
54
  - **Multi-Provider Support**: OpenRouter, Ollama, Anthropic, Google, OpenAI, Grok, Groq, Qwen, Z.ai, Mistral
55
55
  - **OpenAI-Compatible API**: Works with any client that supports OpenAI's chat completion API
56
56
  - **Built-in Analytics**: Built-in analytics UI to visualize costs, requests, and token usage
@@ -58,6 +58,7 @@ Configure additional providers and models in [llms.json](llms/llms.json)
58
58
  - **CLI Interface**: Simple command-line interface for quick interactions
59
59
  - **Server Mode**: Run an OpenAI-compatible HTTP server at `http://localhost:{PORT}/v1/chat/completions`
60
60
  - **Image Support**: Process images through vision-capable models
61
+ - Auto resizes and converts to webp if exceeds configured limits
61
62
  - **Audio Support**: Process audio through audio-capable models
62
63
  - **Custom Chat Templates**: Configurable chat completion request templates for different modalities
63
64
  - **Auto-Discovery**: Automatically discover available Ollama models
@@ -68,23 +69,27 @@ Configure additional providers and models in [llms.json](llms/llms.json)
68
69
 
69
70
  Access all your local all remote LLMs with a single ChatGPT-like UI:
70
71
 
71
- [![](https://servicestack.net/img/posts/llms-py-ui/bg.webp?)](https://servicestack.net/posts/llms-py-ui)
72
+ [![](https://servicestack.net/img/posts/llms-py-ui/bg.webp)](https://servicestack.net/posts/llms-py-ui)
72
73
 
73
- **Monthly Costs Analysis**
74
+ #### Dark Mode Support
75
+
76
+ [![](https://servicestack.net/img/posts/llms-py-ui/dark-attach-image.webp)](https://servicestack.net/posts/llms-py-ui)
77
+
78
+ #### Monthly Costs Analysis
74
79
 
75
80
  [![](https://servicestack.net/img/posts/llms-py-ui/analytics-costs.webp)](https://servicestack.net/posts/llms-py-ui)
76
81
 
77
- **Monthly Token Usage**
82
+ #### Monthly Token Usage (Dark Mode)
78
83
 
79
- [![](https://servicestack.net/img/posts/llms-py-ui/analytics-tokens.webp)](https://servicestack.net/posts/llms-py-ui)
84
+ [![](https://servicestack.net/img/posts/llms-py-ui/dark-analytics-tokens.webp)](https://servicestack.net/posts/llms-py-ui)
80
85
 
81
- **Monthly Activity Log**
86
+ #### Monthly Activity Log
82
87
 
83
88
  [![](https://servicestack.net/img/posts/llms-py-ui/analytics-activity.webp)](https://servicestack.net/posts/llms-py-ui)
84
89
 
85
90
  [More Features and Screenshots](https://servicestack.net/posts/llms-py-ui).
86
91
 
87
- **Check Provider Reliability and Response Times**
92
+ #### Check Provider Reliability and Response Times
88
93
 
89
94
  Check the status of configured providers to test if they're configured correctly, reachable and what their response times is for the simplest `1+1=` request:
90
95
 
@@ -230,6 +235,22 @@ See [DOCKER.md](DOCKER.md) for detailed instructions on customizing configuratio
230
235
 
231
236
  llms.py supports optional GitHub OAuth authentication to secure your web UI and API endpoints. When enabled, users must sign in with their GitHub account before accessing the application.
232
237
 
238
+ ```json
239
+ {
240
+ "auth": {
241
+ "enabled": true,
242
+ "github": {
243
+ "client_id": "$GITHUB_CLIENT_ID",
244
+ "client_secret": "$GITHUB_CLIENT_SECRET",
245
+ "redirect_uri": "http://localhost:8000/auth/github/callback",
246
+ "restrict_to": "$GITHUB_USERS"
247
+ }
248
+ }
249
+ }
250
+ ```
251
+
252
+ `GITHUB_USERS` is optional but if set will only allow access to the specified users.
253
+
233
254
  See [GITHUB_OAUTH_SETUP.md](GITHUB_OAUTH_SETUP.md) for detailed setup instructions.
234
255
 
235
256
  ## Configuration
@@ -243,6 +264,8 @@ The configuration file [llms.json](llms/llms.json) is saved to `~/.llms/llms.jso
243
264
  - `audio`: Default chat completion request template for audio prompts
244
265
  - `file`: Default chat completion request template for file prompts
245
266
  - `check`: Check request template for testing provider connectivity
267
+ - `limits`: Override Request size limits
268
+ - `convert`: Max image size and length limits and auto conversion settings
246
269
 
247
270
  ### Providers
248
271
 
@@ -1211,7 +1234,7 @@ This shows:
1211
1234
  - `llms/main.py` - Main script with CLI and server functionality
1212
1235
  - `llms/llms.json` - Default configuration file
1213
1236
  - `llms/ui.json` - UI configuration file
1214
- - `requirements.txt` - Python dependencies (aiohttp)
1237
+ - `requirements.txt` - Python dependencies, required: `aiohttp`, optional: `Pillow`
1215
1238
 
1216
1239
  ### Provider Classes
1217
1240
 
@@ -1,15 +1,15 @@
1
1
  llms/__init__.py,sha256=Mk6eHi13yoUxLlzhwfZ6A1IjsfSQt9ShhOdbLXTvffU,53
2
2
  llms/__main__.py,sha256=hrBulHIt3lmPm1BCyAEVtB6DQ0Hvc3gnIddhHCmJasg,151
3
3
  llms/index.html,sha256=_pkjdzCX95HTf19LgE4gMh6tLittcnf7M_jL2hSEbbM,3250
4
- llms/llms.json,sha256=z9JmihUrqMuup7T4lT47xxLyL2SFC-UNnrLDrHv6nsU,41104
5
- llms/main.py,sha256=v6N4_EWeGlgQSgpm0BynD5k34ke-LZnokDnrvjajQYQ,82441
4
+ llms/llms.json,sha256=oTMlVM3nYeooQgsPbIGN2LQ-1aq0u1v38sjmxHPiGAc,41331
5
+ llms/main.py,sha256=yAnDzq4qPQ9QqMdK3jnj_2l5GaU6SdofQmfhX6zHRys,88018
6
6
  llms/ui.json,sha256=iBOmpNeD5-o8AgUa51ymS-KemovJ7bm9J1fnL0nf8jk,134025
7
- llms/ui/Analytics.mjs,sha256=2XPuhqm3hEyVR_XFSJFF2uYx7odk4LKswq_7Ri76WCI,71247
7
+ llms/ui/Analytics.mjs,sha256=r5il9Yvh2lje23e4zbGVUqDu2CG75mtyx84MeMVmHpU,72055
8
8
  llms/ui/App.mjs,sha256=95pBXexTt3tgaX-Toh5oS0PdFxrVt3ZU8wu8Ywoz6FI,648
9
9
  llms/ui/Avatar.mjs,sha256=TgouwV9bN-Ou1Tf2zCDtVaRiUB21TXZZPFCTlFL-xxQ,3387
10
10
  llms/ui/Brand.mjs,sha256=kJn7O4Oo3tbZi87Ifbbcq7AZvhfUqbn_ZcXcyv_WI1A,2075
11
11
  llms/ui/ChatPrompt.mjs,sha256=gVKBbxMmvIZWq70LWYBWUgWGemY_pnyMUelI9jRQbsI,25417
12
- llms/ui/Main.mjs,sha256=MTYgkH1lfpzToTvqhmw4zHfZwY9_uCB44kFzoWcu7XE,42230
12
+ llms/ui/Main.mjs,sha256=pgJOi7Snllf4nHk95HxwFB38cce5jostwwSJyAvynHQ,42249
13
13
  llms/ui/ModelSelector.mjs,sha256=iAFp6ZW2pFfS9L1fn07x8a0rJaqqh8w8Tkxej-10pPw,2520
14
14
  llms/ui/OAuthSignIn.mjs,sha256=IdA9Tbswlh74a_-9e9YulOpqLfRpodRLGfCZ9sTZ5jU,4879
15
15
  llms/ui/ProviderIcon.mjs,sha256=HTjlgtXEpekn8iNN_S0uswbbvL0iGb20N15-_lXdojk,9054
@@ -21,13 +21,13 @@ llms/ui/SignIn.mjs,sha256=df3b-7L3ZIneDGbJWUk93K9RGo40gVeuR5StzT1ZH9g,2324
21
21
  llms/ui/SystemPromptEditor.mjs,sha256=PffkNPV6hGbm1QZBKPI7yvWPZSBL7qla0d-JEJ4mxYo,1466
22
22
  llms/ui/SystemPromptSelector.mjs,sha256=bxbJc_Ekqr2d7fO_4fOvMyjL3r60T5B-jQDEuwHBVVo,3151
23
23
  llms/ui/Welcome.mjs,sha256=r9j7unF9CF3k7gEQBMRMVsa2oSjgHGNn46Oa5l5BwlY,950
24
- llms/ui/ai.mjs,sha256=KhIXL9aOHgynjB4udbRCKAtb1iqISVPlt831Ys_lCoE,4768
25
- llms/ui/app.css,sha256=V3wFB01yyC4t6q7e4NIiASjIytNwMBH8m3SVTJaGv30,109606
24
+ llms/ui/ai.mjs,sha256=Gb72n4mBGdXWKac-thKsVo4gj71CYgmcCZCWWCppxgE,4768
25
+ llms/ui/app.css,sha256=agY_1rQHTaph-9YxlfC6zpQaC-HOI984URQH64hg6Ag,108008
26
26
  llms/ui/fav.svg,sha256=_R6MFeXl6wBFT0lqcUxYQIDWgm246YH_3hSTW0oO8qw,734
27
27
  llms/ui/markdown.mjs,sha256=uWSyBZZ8a76Dkt53q6CJzxg7Gkx7uayX089td3Srv8w,6388
28
28
  llms/ui/tailwind.input.css,sha256=QInTVDpCR89OTzRo9AePdAa-MX3i66RkhNOfa4_7UAg,12086
29
29
  llms/ui/threadStore.mjs,sha256=VeGXAuUlA9-Ie9ZzOsay6InKBK_ewWFK6aTRmLTporg,16543
30
- llms/ui/typography.css,sha256=hy0aE8XRz6ZbGbzjynpHeKDOahVoZlGqDFYSdZnqAJ0,19618
30
+ llms/ui/typography.css,sha256=6o7pbMIamRVlm2GfzSStpcOG4T5eFCK_WcQ3RIHKAsU,19587
31
31
  llms/ui/utils.mjs,sha256=cYrP17JwpQk7lLqTWNgVTOD_ZZAovbWnx2QSvKzeB24,5333
32
32
  llms/ui/lib/chart.js,sha256=dx8FdDX0Rv6OZtZjr9FQh5h-twFsKjfnb-FvFlQ--cU,196176
33
33
  llms/ui/lib/charts.mjs,sha256=MNym9qE_2eoH6M7_8Gj9i6e6-Y3b7zw9UQWCUHRF6x0,1088
@@ -40,9 +40,9 @@ llms/ui/lib/servicestack-vue.mjs,sha256=r_-khYokisXJAIPDLh8Wq6YtcLAY6HNjtJlCZJjL
40
40
  llms/ui/lib/vue-router.min.mjs,sha256=fR30GHoXI1u81zyZ26YEU105pZgbbAKSXbpnzFKIxls,30418
41
41
  llms/ui/lib/vue.min.mjs,sha256=iXh97m5hotl0eFllb3aoasQTImvp7mQoRJ_0HoxmZkw,163811
42
42
  llms/ui/lib/vue.mjs,sha256=dS8LKOG01t9CvZ04i0tbFXHqFXOO_Ha4NmM3BytjQAs,537071
43
- llms_py-2.0.27.dist-info/licenses/LICENSE,sha256=bus9cuAOWeYqBk2OuhSABVV1P4z7hgrEFISpyda_H5w,1532
44
- llms_py-2.0.27.dist-info/METADATA,sha256=qfd5X3D5bEdJ20PSLIhlApH9sghWeeqD_W0sFgnZv9g,36283
45
- llms_py-2.0.27.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
46
- llms_py-2.0.27.dist-info/entry_points.txt,sha256=WswyE7PfnkZMIxboC-MS6flBD6wm-CYU7JSUnMhqMfM,40
47
- llms_py-2.0.27.dist-info/top_level.txt,sha256=gC7hk9BKSeog8gyg-EM_g2gxm1mKHwFRfK-10BxOsa4,5
48
- llms_py-2.0.27.dist-info/RECORD,,
43
+ llms_py-2.0.28.dist-info/licenses/LICENSE,sha256=bus9cuAOWeYqBk2OuhSABVV1P4z7hgrEFISpyda_H5w,1532
44
+ llms_py-2.0.28.dist-info/METADATA,sha256=kfED8zRRxV4mDAddg20-0rShqkz4wz5vpsTMmyfvlik,37074
45
+ llms_py-2.0.28.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
46
+ llms_py-2.0.28.dist-info/entry_points.txt,sha256=WswyE7PfnkZMIxboC-MS6flBD6wm-CYU7JSUnMhqMfM,40
47
+ llms_py-2.0.28.dist-info/top_level.txt,sha256=gC7hk9BKSeog8gyg-EM_g2gxm1mKHwFRfK-10BxOsa4,5
48
+ llms_py-2.0.28.dist-info/RECORD,,