llms-py 2.0.25__py3-none-any.whl → 2.0.26__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
@@ -1,4 +1,12 @@
1
1
  {
2
+ "auth": {
3
+ "enabled": true,
4
+ "github": {
5
+ "client_id": "$GITHUB_CLIENT_ID",
6
+ "client_secret": "$GITHUB_CLIENT_SECRET",
7
+ "redirect_uri": "http://localhost:8000/auth/github/callback"
8
+ }
9
+ },
2
10
  "defaults": {
3
11
  "headers": {
4
12
  "Content-Type": "application/json",
@@ -113,6 +121,7 @@
113
121
  "mai-ds-r1": "microsoft/mai-ds-r1:free",
114
122
  "llama3.3:70b": "meta-llama/llama-3.3-70b-instruct:free",
115
123
  "nemotron-nano:9b": "nvidia/nemotron-nano-9b-v2:free",
124
+ "nemotron-nano:12b":"nvidia/nemotron-nano-12b-v2-vl:free",
116
125
  "deepseek-r1-distill-llama:70b": "deepseek/deepseek-r1-distill-llama-70b:free",
117
126
  "gpt-oss:20b": "openai/gpt-oss-20b:free",
118
127
  "mistral-small3.2:24b": "mistralai/mistral-small-3.2-24b-instruct:free",
llms/main.py CHANGED
@@ -14,7 +14,8 @@ import mimetypes
14
14
  import traceback
15
15
  import sys
16
16
  import site
17
- from urllib.parse import parse_qs
17
+ import secrets
18
+ from urllib.parse import parse_qs, urlencode
18
19
 
19
20
  import aiohttp
20
21
  from aiohttp import web
@@ -22,7 +23,7 @@ from aiohttp import web
22
23
  from pathlib import Path
23
24
  from importlib import resources # Py≥3.9 (pip install importlib_resources for 3.7/3.8)
24
25
 
25
- VERSION = "2.0.25"
26
+ VERSION = "2.0.26"
26
27
  _ROOT = None
27
28
  g_config_path = None
28
29
  g_ui_path = None
@@ -31,6 +32,8 @@ g_handlers = {}
31
32
  g_verbose = False
32
33
  g_logprefix=""
33
34
  g_default_model=""
35
+ g_sessions = {} # OAuth session storage: {session_token: {userId, userName, displayName, profileUrl, email, created}}
36
+ g_oauth_states = {} # CSRF protection: {state: {created, redirect_uri}}
34
37
 
35
38
  def _log(message):
36
39
  """Helper method for logging from the global polling task."""
@@ -1456,9 +1459,61 @@ def main():
1456
1459
  print(f"UI not found at {g_ui_path}")
1457
1460
  exit(1)
1458
1461
 
1462
+ # Validate auth configuration if enabled
1463
+ auth_enabled = g_config.get('auth', {}).get('enabled', False)
1464
+ if auth_enabled:
1465
+ github_config = g_config.get('auth', {}).get('github', {})
1466
+ client_id = github_config.get('client_id', '')
1467
+ client_secret = github_config.get('client_secret', '')
1468
+
1469
+ # Expand environment variables
1470
+ if client_id.startswith('$'):
1471
+ client_id = os.environ.get(client_id[1:], '')
1472
+ if client_secret.startswith('$'):
1473
+ client_secret = os.environ.get(client_secret[1:], '')
1474
+
1475
+ if not client_id or not client_secret:
1476
+ print("ERROR: Authentication is enabled but GitHub OAuth is not properly configured.")
1477
+ print("Please set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET environment variables,")
1478
+ print("or disable authentication by setting 'auth.enabled' to false in llms.json")
1479
+ exit(1)
1480
+
1481
+ _log("Authentication enabled - GitHub OAuth configured")
1482
+
1459
1483
  app = web.Application()
1460
1484
 
1485
+ # Authentication middleware helper
1486
+ def check_auth(request):
1487
+ """Check if request is authenticated. Returns (is_authenticated, user_data)"""
1488
+ if not auth_enabled:
1489
+ return True, None
1490
+
1491
+ # Check for OAuth session token
1492
+ session_token = request.query.get('session') or request.headers.get('X-Session-Token')
1493
+ if session_token and session_token in g_sessions:
1494
+ return True, g_sessions[session_token]
1495
+
1496
+ # Check for API key
1497
+ auth_header = request.headers.get('Authorization', '')
1498
+ if auth_header.startswith('Bearer '):
1499
+ api_key = auth_header[7:]
1500
+ if api_key:
1501
+ return True, {"authProvider": "apikey"}
1502
+
1503
+ return False, None
1504
+
1461
1505
  async def chat_handler(request):
1506
+ # Check authentication if enabled
1507
+ is_authenticated, user_data = check_auth(request)
1508
+ if not is_authenticated:
1509
+ return web.json_response({
1510
+ "error": {
1511
+ "message": "Authentication required",
1512
+ "type": "authentication_error",
1513
+ "code": "unauthorized"
1514
+ }
1515
+ }, status=401)
1516
+
1462
1517
  try:
1463
1518
  chat = await request.json()
1464
1519
  response = await chat_completion(chat)
@@ -1504,6 +1559,198 @@ def main():
1504
1559
  })
1505
1560
  app.router.add_post('/providers/{provider}', provider_handler)
1506
1561
 
1562
+ # OAuth handlers
1563
+ async def github_auth_handler(request):
1564
+ """Initiate GitHub OAuth flow"""
1565
+ if 'auth' not in g_config or 'github' not in g_config['auth']:
1566
+ return web.json_response({"error": "GitHub OAuth not configured"}, status=500)
1567
+
1568
+ auth_config = g_config['auth']['github']
1569
+ client_id = auth_config.get('client_id', '')
1570
+ redirect_uri = auth_config.get('redirect_uri', '')
1571
+
1572
+ # Expand environment variables
1573
+ if client_id.startswith('$'):
1574
+ client_id = os.environ.get(client_id[1:], '')
1575
+ if redirect_uri.startswith('$'):
1576
+ redirect_uri = os.environ.get(redirect_uri[1:], '')
1577
+
1578
+ if not client_id:
1579
+ return web.json_response({"error": "GitHub client_id not configured"}, status=500)
1580
+
1581
+ # Generate CSRF state token
1582
+ state = secrets.token_urlsafe(32)
1583
+ g_oauth_states[state] = {
1584
+ 'created': time.time(),
1585
+ 'redirect_uri': redirect_uri
1586
+ }
1587
+
1588
+ # Clean up old states (older than 10 minutes)
1589
+ current_time = time.time()
1590
+ expired_states = [s for s, data in g_oauth_states.items() if current_time - data['created'] > 600]
1591
+ for s in expired_states:
1592
+ del g_oauth_states[s]
1593
+
1594
+ # Build GitHub authorization URL
1595
+ params = {
1596
+ 'client_id': client_id,
1597
+ 'redirect_uri': redirect_uri,
1598
+ 'state': state,
1599
+ 'scope': 'read:user user:email'
1600
+ }
1601
+ auth_url = f"https://github.com/login/oauth/authorize?{urlencode(params)}"
1602
+
1603
+ return web.HTTPFound(auth_url)
1604
+
1605
+ async def github_callback_handler(request):
1606
+ """Handle GitHub OAuth callback"""
1607
+ code = request.query.get('code')
1608
+ state = request.query.get('state')
1609
+
1610
+ if not code or not state:
1611
+ return web.Response(text="Missing code or state parameter", status=400)
1612
+
1613
+ # Verify state token (CSRF protection)
1614
+ if state not in g_oauth_states:
1615
+ return web.Response(text="Invalid state parameter", status=400)
1616
+
1617
+ state_data = g_oauth_states.pop(state)
1618
+
1619
+ if 'auth' not in g_config or 'github' not in g_config['auth']:
1620
+ return web.json_response({"error": "GitHub OAuth not configured"}, status=500)
1621
+
1622
+ auth_config = g_config['auth']['github']
1623
+ client_id = auth_config.get('client_id', '')
1624
+ client_secret = auth_config.get('client_secret', '')
1625
+ redirect_uri = auth_config.get('redirect_uri', '')
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
+ if redirect_uri.startswith('$'):
1633
+ redirect_uri = os.environ.get(redirect_uri[1:], '')
1634
+
1635
+ if not client_id or not client_secret:
1636
+ return web.json_response({"error": "GitHub OAuth credentials not configured"}, status=500)
1637
+
1638
+ # Exchange code for access token
1639
+ async with aiohttp.ClientSession() as session:
1640
+ token_url = "https://github.com/login/oauth/access_token"
1641
+ token_data = {
1642
+ 'client_id': client_id,
1643
+ 'client_secret': client_secret,
1644
+ 'code': code,
1645
+ 'redirect_uri': redirect_uri
1646
+ }
1647
+ headers = {'Accept': 'application/json'}
1648
+
1649
+ async with session.post(token_url, data=token_data, headers=headers) as resp:
1650
+ token_response = await resp.json()
1651
+ access_token = token_response.get('access_token')
1652
+
1653
+ if not access_token:
1654
+ error = token_response.get('error_description', 'Failed to get access token')
1655
+ return web.Response(text=f"OAuth error: {error}", status=400)
1656
+
1657
+ # Fetch user info
1658
+ user_url = "https://api.github.com/user"
1659
+ headers = {
1660
+ "Authorization": f"Bearer {access_token}",
1661
+ "Accept": "application/json"
1662
+ }
1663
+
1664
+ async with session.get(user_url, headers=headers) as resp:
1665
+ user_data = await resp.json()
1666
+
1667
+ # Create session
1668
+ session_token = secrets.token_urlsafe(32)
1669
+ g_sessions[session_token] = {
1670
+ "userId": str(user_data.get('id', '')),
1671
+ "userName": user_data.get('login', ''),
1672
+ "displayName": user_data.get('name', ''),
1673
+ "profileUrl": user_data.get('avatar_url', ''),
1674
+ "email": user_data.get('email', ''),
1675
+ "created": time.time()
1676
+ }
1677
+
1678
+ # Redirect to UI with session token
1679
+ return web.HTTPFound(f"/?session={session_token}")
1680
+
1681
+ async def session_handler(request):
1682
+ """Validate and return session info"""
1683
+ session_token = request.query.get('session') or request.headers.get('X-Session-Token')
1684
+
1685
+ if not session_token or session_token not in g_sessions:
1686
+ return web.json_response({"error": "Invalid or expired session"}, status=401)
1687
+
1688
+ session_data = g_sessions[session_token]
1689
+
1690
+ # Clean up old sessions (older than 24 hours)
1691
+ current_time = time.time()
1692
+ expired_sessions = [token for token, data in g_sessions.items() if current_time - data['created'] > 86400]
1693
+ for token in expired_sessions:
1694
+ del g_sessions[token]
1695
+
1696
+ return web.json_response({
1697
+ **session_data,
1698
+ "sessionToken": session_token
1699
+ })
1700
+
1701
+ async def logout_handler(request):
1702
+ """End OAuth session"""
1703
+ session_token = request.query.get('session') or request.headers.get('X-Session-Token')
1704
+
1705
+ if session_token and session_token in g_sessions:
1706
+ del g_sessions[session_token]
1707
+
1708
+ return web.json_response({"success": True})
1709
+
1710
+ async def auth_handler(request):
1711
+ """Check authentication status and return user info"""
1712
+ # Check for OAuth session token
1713
+ session_token = request.query.get('session') or request.headers.get('X-Session-Token')
1714
+
1715
+ if session_token and session_token in g_sessions:
1716
+ session_data = g_sessions[session_token]
1717
+ return web.json_response({
1718
+ "userId": session_data.get("userId", ""),
1719
+ "userName": session_data.get("userName", ""),
1720
+ "displayName": session_data.get("displayName", ""),
1721
+ "profileUrl": session_data.get("profileUrl", ""),
1722
+ "authProvider": "github"
1723
+ })
1724
+
1725
+ # Check for API key in Authorization header
1726
+ # auth_header = request.headers.get('Authorization', '')
1727
+ # if auth_header.startswith('Bearer '):
1728
+ # # For API key auth, return a basic response
1729
+ # # You can customize this based on your API key validation logic
1730
+ # api_key = auth_header[7:]
1731
+ # if api_key: # Add your API key validation logic here
1732
+ # return web.json_response({
1733
+ # "userId": "1",
1734
+ # "userName": "apiuser",
1735
+ # "displayName": "API User",
1736
+ # "profileUrl": "",
1737
+ # "authProvider": "apikey"
1738
+ # })
1739
+
1740
+ # Not authenticated - return error in expected format
1741
+ return web.json_response({
1742
+ "responseStatus": {
1743
+ "errorCode": "Unauthorized",
1744
+ "message": "Not authenticated"
1745
+ }
1746
+ }, status=401)
1747
+
1748
+ app.router.add_get('/auth', auth_handler)
1749
+ app.router.add_get('/auth/github', github_auth_handler)
1750
+ app.router.add_get('/auth/github/callback', github_callback_handler)
1751
+ app.router.add_get('/auth/session', session_handler)
1752
+ app.router.add_post('/auth/logout', logout_handler)
1753
+
1507
1754
  async def ui_static(request: web.Request) -> web.Response:
1508
1755
  path = Path(request.match_info["path"])
1509
1756
 
@@ -1543,9 +1790,12 @@ def main():
1543
1790
  enabled, disabled = provider_status()
1544
1791
  ui['status'] = {
1545
1792
  "all": list(g_config['providers'].keys()),
1546
- "enabled": enabled,
1547
- "disabled": disabled
1793
+ "enabled": enabled,
1794
+ "disabled": disabled
1548
1795
  }
1796
+ # Add auth configuration
1797
+ ui['requiresAuth'] = auth_enabled
1798
+ ui['authType'] = 'oauth' if auth_enabled else 'apikey'
1549
1799
  return web.json_response(ui)
1550
1800
  app.router.add_get('/config', ui_config_handler)
1551
1801
 
llms/ui/App.mjs CHANGED
@@ -1,13 +1,18 @@
1
+ import { inject } from "vue"
1
2
  import Sidebar from "./Sidebar.mjs"
2
3
 
3
4
  export default {
4
5
  components: {
5
6
  Sidebar,
6
7
  },
8
+ setup() {
9
+ const ai = inject('ai')
10
+ return { ai }
11
+ },
7
12
  template: `
8
13
  <div class="flex h-screen bg-white">
9
- <!-- Sidebar -->
10
- <div class="w-72 xl:w-80 flex-shrink-0">
14
+ <!-- Sidebar (hidden when auth required and not authenticated) -->
15
+ <div v-if="!(ai.requiresAuth && !ai.auth)" class="w-72 xl:w-80 flex-shrink-0">
11
16
  <Sidebar />
12
17
  </div>
13
18
 
llms/ui/Avatar.mjs CHANGED
@@ -1,13 +1,40 @@
1
- import { computed, inject } from "vue"
1
+ import { computed, inject, ref, onMounted, onUnmounted } from "vue"
2
2
 
3
3
  export default {
4
4
  template:`
5
- <div v-if="$ai.auth?.profileUrl" :title="authTitle">
6
- <img :src="$ai.auth.profileUrl" class="size-8 rounded-full" />
5
+ <div v-if="$ai.auth?.profileUrl" class="relative" ref="avatarContainer">
6
+ <img
7
+ @click.stop="toggleMenu"
8
+ :src="$ai.auth.profileUrl"
9
+ :title="authTitle"
10
+ class="size-8 rounded-full cursor-pointer hover:ring-2 hover:ring-gray-300"
11
+ />
12
+ <div
13
+ v-if="showMenu"
14
+ @click.stop
15
+ class="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg py-1 z-50 border border-gray-200 dark:border-gray-700"
16
+ >
17
+ <div class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
18
+ <div class="font-medium whitespace-nowrap overflow-hidden text-ellipsis">{{ $ai.auth.displayName || $ai.auth.userName }}</div>
19
+ <div class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap overflow-hidden text-ellipsis">{{ $ai.auth.email }}</div>
20
+ </div>
21
+ <button type="button"
22
+ @click="handleLogout"
23
+ class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center whitespace-nowrap"
24
+ >
25
+ <svg class="w-4 h-4 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
26
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
27
+ </svg>
28
+ Sign Out
29
+ </button>
30
+ </div>
7
31
  </div>
8
32
  `,
9
33
  setup() {
10
34
  const ai = inject('ai')
35
+ const showMenu = ref(false)
36
+ const avatarContainer = ref(null)
37
+
11
38
  const authTitle = computed(() => {
12
39
  if (!ai.auth) return ''
13
40
  const { userId, userName, displayName, bearerToken, roles } = ai.auth
@@ -20,9 +47,39 @@ export default {
20
47
  ]
21
48
  return sb.filter(x => x).join('\n')
22
49
  })
23
-
50
+
51
+ function toggleMenu() {
52
+ showMenu.value = !showMenu.value
53
+ }
54
+
55
+ async function handleLogout() {
56
+ showMenu.value = false
57
+ await ai.signOut()
58
+ // Reload the page to show sign-in screen
59
+ window.location.reload()
60
+ }
61
+
62
+ // Close menu when clicking outside
63
+ const handleClickOutside = (event) => {
64
+ if (showMenu.value && avatarContainer.value && !avatarContainer.value.contains(event.target)) {
65
+ showMenu.value = false
66
+ }
67
+ }
68
+
69
+ onMounted(() => {
70
+ document.addEventListener('click', handleClickOutside)
71
+ })
72
+
73
+ onUnmounted(() => {
74
+ document.removeEventListener('click', handleClickOutside)
75
+ })
76
+
24
77
  return {
25
78
  authTitle,
79
+ handleLogout,
80
+ showMenu,
81
+ toggleMenu,
82
+ avatarContainer,
26
83
  }
27
84
  }
28
85
  }
llms/ui/Main.mjs CHANGED
@@ -6,6 +6,7 @@ import { storageObject, addCopyButtons, formatCost, statsTitle } from './utils.m
6
6
  import { renderMarkdown } from './markdown.mjs'
7
7
  import ChatPrompt, { useChatPrompt } from './ChatPrompt.mjs'
8
8
  import SignIn from './SignIn.mjs'
9
+ import OAuthSignIn from './OAuthSignIn.mjs'
9
10
  import Avatar from './Avatar.mjs'
10
11
  import ModelSelector from './ModelSelector.mjs'
11
12
  import SystemPromptSelector from './SystemPromptSelector.mjs'
@@ -22,32 +23,34 @@ export default {
22
23
  SystemPromptEditor,
23
24
  ChatPrompt,
24
25
  SignIn,
26
+ OAuthSignIn,
25
27
  Avatar,
26
28
  Welcome,
27
29
  },
28
30
  template: `
29
31
  <div class="flex flex-col h-full w-full">
30
- <!-- Header with model and prompt selectors -->
31
- <div class="border-b border-gray-200 bg-white px-2 py-2 w-full min-h-16">
32
+ <!-- Header with model and prompt selectors (hidden when auth required and not authenticated) -->
33
+ <div v-if="!($ai.requiresAuth && !$ai.auth)" class="border-b border-gray-200 bg-white px-2 py-2 w-full min-h-16">
32
34
  <div class="flex items-center justify-between w-full">
33
35
  <ModelSelector :models="models" v-model="selectedModel" @updated="configUpdated" />
34
36
 
35
37
  <div class="flex items-center space-x-2">
36
- <SystemPromptSelector :prompts="prompts" v-model="selectedPrompt"
38
+ <SystemPromptSelector :prompts="prompts" v-model="selectedPrompt"
37
39
  :show="showSystemPrompt" @toggle="showSystemPrompt = !showSystemPrompt" />
38
40
  <Avatar />
39
41
  </div>
40
42
  </div>
41
43
  </div>
42
44
 
43
- <SystemPromptEditor v-if="showSystemPrompt"
45
+ <SystemPromptEditor v-if="showSystemPrompt && !($ai.requiresAuth && !$ai.auth)"
44
46
  v-model="currentSystemPrompt" :prompts="prompts" :selected="selectedPrompt" />
45
47
 
46
48
  <!-- Messages Area -->
47
49
  <div class="flex-1 overflow-y-auto" ref="messagesContainer">
48
50
  <div class="mx-auto max-w-6xl px-4 py-6">
49
51
  <div v-if="$ai.requiresAuth && !$ai.auth">
50
- <SignIn @done="$ai.signIn($event)" />
52
+ <OAuthSignIn v-if="$ai.authType === 'oauth'" @done="$ai.signIn($event)" />
53
+ <SignIn v-else @done="$ai.signIn($event)" />
51
54
  </div>
52
55
  <!-- Welcome message when no thread is selected -->
53
56
  <div v-else-if="!currentThread" class="text-center py-12">
@@ -0,0 +1,92 @@
1
+ import { inject, ref, onMounted } from "vue"
2
+ import Welcome from './Welcome.mjs'
3
+
4
+ export default {
5
+ components: {
6
+ Welcome,
7
+ },
8
+ template: `
9
+ <div class="min-h-full -mt-36 flex flex-col justify-center sm:px-6 lg:px-8">
10
+ <div class="sm:mx-auto sm:w-full sm:max-w-md text-center">
11
+ <Welcome />
12
+ </div>
13
+ <div class="sm:mx-auto sm:w-full sm:max-w-md">
14
+ <div v-if="errorMessage" class="mb-3 bg-red-50 border border-red-200 text-red-800 rounded-lg px-4 py-3">
15
+ <div class="flex items-start space-x-2">
16
+ <div class="flex-1">
17
+ <div class="text-base font-medium">{{ errorMessage }}</div>
18
+ </div>
19
+ <button type="button"
20
+ @click="errorMessage = null"
21
+ class="text-red-400 hover:text-red-600 flex-shrink-0"
22
+ >
23
+ <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
24
+ <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
25
+ </svg>
26
+ </button>
27
+ </div>
28
+ </div>
29
+ <div class="py-8 px-4 sm:px-10">
30
+ <div class="space-y-4">
31
+ <button
32
+ type="button"
33
+ @click="signInWithGitHub"
34
+ class="w-full inline-flex items-center justify-center px-4 py-3 border border-gray-300 rounded-md shadow-sm text-base font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-colors"
35
+ >
36
+ <svg class="w-6 h-6 mr-3" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
37
+ <path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd" />
38
+ </svg>
39
+ Sign in with GitHub
40
+ </button>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ </div>
45
+ `,
46
+ emits: ['done'],
47
+ setup(props, { emit }) {
48
+ const ai = inject('ai')
49
+ const errorMessage = ref(null)
50
+
51
+ function signInWithGitHub() {
52
+ // Redirect to GitHub OAuth endpoint
53
+ window.location.href = '/auth/github'
54
+ }
55
+
56
+ // Check for session token in URL (after OAuth callback redirect)
57
+ onMounted(async () => {
58
+ const urlParams = new URLSearchParams(window.location.search)
59
+ const sessionToken = urlParams.get('session')
60
+
61
+ if (sessionToken) {
62
+ try {
63
+ // Validate session with server
64
+ const response = await ai.get(`/auth/session?session=${sessionToken}`)
65
+
66
+ if (response.ok) {
67
+ const sessionData = await response.json()
68
+
69
+ // Clean up URL
70
+ const url = new URL(window.location.href)
71
+ url.searchParams.delete('session')
72
+ window.history.replaceState({}, '', url.toString())
73
+
74
+ // Emit done event with session data
75
+ emit('done', sessionData)
76
+ } else {
77
+ errorMessage.value = 'Failed to validate session'
78
+ }
79
+ } catch (error) {
80
+ console.error('Session validation error:', error)
81
+ errorMessage.value = 'Failed to validate session'
82
+ }
83
+ }
84
+ })
85
+
86
+ return {
87
+ signInWithGitHub,
88
+ errorMessage,
89
+ }
90
+ }
91
+ }
92
+
llms/ui/ai.mjs CHANGED
@@ -6,12 +6,13 @@ const headers = { 'Accept': 'application/json' }
6
6
  const prefsKey = 'llms.prefs'
7
7
 
8
8
  export const o = {
9
- version: '2.0.25',
9
+ version: '2.0.26',
10
10
  base,
11
11
  prefsKey,
12
12
  welcome: 'Welcome to llms.py',
13
13
  auth: null,
14
14
  requiresAuth: false,
15
+ authType: 'apikey', // 'oauth' or 'apikey' - controls which SignIn component to use
15
16
  headers,
16
17
 
17
18
  resolveUrl(url){
@@ -50,26 +51,88 @@ export const o = {
50
51
  this.auth = auth
51
52
  if (auth?.apiKey) {
52
53
  this.headers.Authorization = `Bearer ${auth.apiKey}`
53
- } else if (this.headers.Authorization) {
54
+ //localStorage.setItem('llms:auth', JSON.stringify({ apiKey: auth.apiKey }))
55
+ } else if (auth?.sessionToken) {
56
+ this.headers['X-Session-Token'] = auth.sessionToken
57
+ localStorage.setItem('llms:auth', JSON.stringify({ sessionToken: auth.sessionToken }))
58
+ } else {
59
+ if (this.headers.Authorization) {
60
+ delete this.headers.Authorization
61
+ }
62
+ if (this.headers['X-Session-Token']) {
63
+ delete this.headers['X-Session-Token']
64
+ }
65
+ }
66
+ },
67
+ async signOut() {
68
+ if (this.auth?.sessionToken) {
69
+ // Call logout endpoint for OAuth sessions
70
+ try {
71
+ await this.post('/auth/logout', {
72
+ headers: {
73
+ 'X-Session-Token': this.auth.sessionToken
74
+ }
75
+ })
76
+ } catch (error) {
77
+ console.error('Logout error:', error)
78
+ }
79
+ }
80
+ this.auth = null
81
+ if (this.headers.Authorization) {
54
82
  delete this.headers.Authorization
55
83
  }
84
+ if (this.headers['X-Session-Token']) {
85
+ delete this.headers['X-Session-Token']
86
+ }
87
+ localStorage.removeItem('llms:auth')
56
88
  },
57
89
  async init() {
58
90
  // Load models and prompts
59
91
  const { initDB } = useThreadStore()
60
- const [_, configRes, modelsRes, authRes] = await Promise.all([
92
+ const [_, configRes, modelsRes] = await Promise.all([
61
93
  initDB(),
62
94
  this.getConfig(),
63
95
  this.getModels(),
64
- this.getAuth(),
65
96
  ])
66
97
  const config = await configRes.json()
67
98
  const models = await modelsRes.json()
68
- const auth = this.requiresAuth
99
+
100
+ // Update auth settings from server config
101
+ if (config.requiresAuth != null) {
102
+ this.requiresAuth = config.requiresAuth
103
+ }
104
+ if (config.authType != null) {
105
+ this.authType = config.authType
106
+ }
107
+
108
+ // Try to restore session from localStorage
109
+ if (this.requiresAuth) {
110
+ const storedAuth = localStorage.getItem('llms:auth')
111
+ if (storedAuth) {
112
+ try {
113
+ const authData = JSON.parse(storedAuth)
114
+ if (authData.sessionToken) {
115
+ this.headers['X-Session-Token'] = authData.sessionToken
116
+ }
117
+ // else if (authData.apiKey) {
118
+ // this.headers.Authorization = `Bearer ${authData.apiKey}`
119
+ // }
120
+ } catch (e) {
121
+ console.error('Failed to restore auth from localStorage:', e)
122
+ localStorage.removeItem('llms:auth')
123
+ }
124
+ }
125
+ }
126
+
127
+ // Get auth status
128
+ const authRes = await this.getAuth()
129
+ const auth = this.requiresAuth
69
130
  ? await authRes.json()
70
131
  : null
71
132
  if (auth?.responseStatus?.errorCode) {
72
133
  console.error(auth.responseStatus.errorCode, auth.responseStatus.message)
134
+ // Clear invalid session from localStorage
135
+ localStorage.removeItem('llms:auth')
73
136
  } else {
74
137
  this.signIn(auth)
75
138
  }
llms/ui/app.css CHANGED
@@ -426,6 +426,9 @@
426
426
  .-mt-12 {
427
427
  margin-top: calc(var(--spacing) * -12);
428
428
  }
429
+ .-mt-36 {
430
+ margin-top: calc(var(--spacing) * -36);
431
+ }
429
432
  .mt-1 {
430
433
  margin-top: calc(var(--spacing) * 1);
431
434
  }
@@ -662,6 +665,9 @@
662
665
  .w-32 {
663
666
  width: calc(var(--spacing) * 32);
664
667
  }
668
+ .w-48 {
669
+ width: calc(var(--spacing) * 48);
670
+ }
665
671
  .w-72 {
666
672
  width: calc(var(--spacing) * 72);
667
673
  }
@@ -852,6 +858,13 @@
852
858
  margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));
853
859
  }
854
860
  }
861
+ .space-y-4 {
862
+ :where(& > :not(:last-child)) {
863
+ --tw-space-y-reverse: 0;
864
+ margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));
865
+ margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)));
866
+ }
867
+ }
855
868
  .space-y-6 {
856
869
  :where(& > :not(:last-child)) {
857
870
  --tw-space-y-reverse: 0;
@@ -1431,6 +1444,9 @@
1431
1444
  .break-words {
1432
1445
  overflow-wrap: break-word;
1433
1446
  }
1447
+ .text-ellipsis {
1448
+ text-overflow: ellipsis;
1449
+ }
1434
1450
  .whitespace-nowrap {
1435
1451
  white-space: nowrap;
1436
1452
  }
@@ -2214,6 +2230,21 @@
2214
2230
  }
2215
2231
  }
2216
2232
  }
2233
+ .hover\:ring-2 {
2234
+ &:hover {
2235
+ @media (hover: hover) {
2236
+ --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, hsl(var(--ring)));
2237
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
2238
+ }
2239
+ }
2240
+ }
2241
+ .hover\:ring-gray-300 {
2242
+ &:hover {
2243
+ @media (hover: hover) {
2244
+ --tw-ring-color: var(--color-gray-300);
2245
+ }
2246
+ }
2247
+ }
2217
2248
  .hover\:file\:bg-violet-100 {
2218
2249
  &:hover {
2219
2250
  @media (hover: hover) {
@@ -2292,6 +2323,11 @@
2292
2323
  --tw-ring-color: var(--color-cyan-500);
2293
2324
  }
2294
2325
  }
2326
+ .focus\:ring-gray-500 {
2327
+ &:focus {
2328
+ --tw-ring-color: var(--color-gray-500);
2329
+ }
2330
+ }
2295
2331
  .focus\:ring-green-500 {
2296
2332
  &:focus {
2297
2333
  --tw-ring-color: var(--color-green-500);
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: llms-py
3
- Version: 2.0.25
3
+ Version: 2.0.26
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
@@ -111,56 +111,7 @@ test the response times for all configured providers and models, the results of
111
111
  pip install llms-py
112
112
  ```
113
113
 
114
- ### Using Docker
115
-
116
- **a) Simple - Run in a Docker container:**
117
-
118
- Run the server on port `8000`:
119
-
120
- ```bash
121
- docker run -p 8000:8000 -e GROQ_API_KEY=$GROQ_API_KEY ghcr.io/servicestack/llms:latest
122
- ```
123
-
124
- Get the latest version:
125
-
126
- ```bash
127
- docker pull ghcr.io/servicestack/llms:latest
128
- ```
129
-
130
- Use custom `llms.json` and `ui.json` config files outside of the container (auto created if they don't exist):
131
-
132
- ```bash
133
- docker run -p 8000:8000 -e GROQ_API_KEY=$GROQ_API_KEY \
134
- -v ~/.llms:/home/llms/.llms \
135
- ghcr.io/servicestack/llms:latest
136
- ```
137
-
138
- **b) Recommended - Use Docker Compose:**
139
-
140
- Download and use [docker-compose.yml](https://raw.githubusercontent.com/ServiceStack/llms/refs/heads/main/docker-compose.yml):
141
-
142
- ```bash
143
- curl -O https://raw.githubusercontent.com/ServiceStack/llms/refs/heads/main/docker-compose.yml
144
- ```
145
-
146
- Update API Keys in `docker-compose.yml` then start the server:
147
-
148
- ```bash
149
- docker-compose up -d
150
- ```
151
-
152
- **c) Build and run local Docker image from source:**
153
-
154
- ```bash
155
- git clone https://github.com/ServiceStack/llms
156
-
157
- docker-compose -f docker-compose.local.yml up -d --build
158
- ```
159
-
160
- After the container starts, you can access the UI and API at `http://localhost:8000`.
161
-
162
-
163
- See [DOCKER.md](DOCKER.md) for detailed instructions on customizing configuration files.
114
+ - [Using Docker](#using-docker)
164
115
 
165
116
  ## Quick Start
166
117
 
@@ -224,6 +175,63 @@ llms --disable openrouter_free codestral google_free groq
224
175
  llms --enable openrouter anthropic google openai grok z.ai qwen mistral
225
176
  ```
226
177
 
178
+ ## Using Docker
179
+
180
+ #### a) Simple - Run in a Docker container:
181
+
182
+ Run the server on port `8000`:
183
+
184
+ ```bash
185
+ docker run -p 8000:8000 -e GROQ_API_KEY=$GROQ_API_KEY ghcr.io/servicestack/llms:latest
186
+ ```
187
+
188
+ Get the latest version:
189
+
190
+ ```bash
191
+ docker pull ghcr.io/servicestack/llms:latest
192
+ ```
193
+
194
+ Use custom `llms.json` and `ui.json` config files outside of the container (auto created if they don't exist):
195
+
196
+ ```bash
197
+ docker run -p 8000:8000 -e GROQ_API_KEY=$GROQ_API_KEY \
198
+ -v ~/.llms:/home/llms/.llms \
199
+ ghcr.io/servicestack/llms:latest
200
+ ```
201
+
202
+ #### b) Recommended - Use Docker Compose:
203
+
204
+ Download and use [docker-compose.yml](https://raw.githubusercontent.com/ServiceStack/llms/refs/heads/main/docker-compose.yml):
205
+
206
+ ```bash
207
+ curl -O https://raw.githubusercontent.com/ServiceStack/llms/refs/heads/main/docker-compose.yml
208
+ ```
209
+
210
+ Update API Keys in `docker-compose.yml` then start the server:
211
+
212
+ ```bash
213
+ docker-compose up -d
214
+ ```
215
+
216
+ #### c) Build and run local Docker image from source:
217
+
218
+ ```bash
219
+ git clone https://github.com/ServiceStack/llms
220
+
221
+ docker-compose -f docker-compose.local.yml up -d --build
222
+ ```
223
+
224
+ After the container starts, you can access the UI and API at `http://localhost:8000`.
225
+
226
+
227
+ See [DOCKER.md](DOCKER.md) for detailed instructions on customizing configuration files.
228
+
229
+ ## GitHub OAuth Authentication
230
+
231
+ 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
+
233
+ See [GITHUB_OAUTH_SETUP.md](GITHUB_OAUTH_SETUP.md) for detailed setup instructions.
234
+
227
235
  ## Configuration
228
236
 
229
237
  The configuration file [llms.json](llms/llms.json) is saved to `~/.llms/llms.json` and defines available providers, models, and default settings. Key sections:
@@ -1,16 +1,17 @@
1
1
  llms/__init__.py,sha256=Mk6eHi13yoUxLlzhwfZ6A1IjsfSQt9ShhOdbLXTvffU,53
2
2
  llms/__main__.py,sha256=hrBulHIt3lmPm1BCyAEVtB6DQ0Hvc3gnIddhHCmJasg,151
3
3
  llms/index.html,sha256=OA9mRmgh-dQrPqb0Z2Jv-cwEZ3YLPRxcWUN7ASjxO8s,2658
4
- llms/llms.json,sha256=6ZXd08HsxN9KX_gplD2UsxNKqZ8JxOl5TWDR2lIH07g,40779
5
- llms/main.py,sha256=49ayMoH5IlOOeV4lEOqE3sGU9_ooMJLhadDmDhT4gnE,71372
4
+ llms/llms.json,sha256=AFGc5-6gWH_czvz0dvkIRPFxkiZluaOwZBBScCVd5HU,41103
5
+ llms/main.py,sha256=yPY7xeVPhvPz3nHd34svZrqD5aOWEDRepotXTapwoAE,82441
6
6
  llms/ui.json,sha256=iBOmpNeD5-o8AgUa51ymS-KemovJ7bm9J1fnL0nf8jk,134025
7
7
  llms/ui/Analytics.mjs,sha256=mAS5AUQjpnEIMyzGzOGE6fZxwxoVyq5QCitYQSSCEpQ,69151
8
- llms/ui/App.mjs,sha256=hXtUjaL3GrcIHieEK3BzIG72OVzrorBBS4RkE1DOGc4,439
9
- llms/ui/Avatar.mjs,sha256=3rHpxe_LuCDiNP895F3FOjWx4j377JA9rD1FLluvtgA,851
8
+ llms/ui/App.mjs,sha256=fcDx0psdr4NFgkxotPVwC_uYbUHy1BoKxQWcOi3SMbM,631
9
+ llms/ui/Avatar.mjs,sha256=TgouwV9bN-Ou1Tf2zCDtVaRiUB21TXZZPFCTlFL-xxQ,3387
10
10
  llms/ui/Brand.mjs,sha256=0NN2JBLUC0OWERuLz9myrimlcA7v7D5B_EMd0sQQVDo,1905
11
11
  llms/ui/ChatPrompt.mjs,sha256=85O_kLVKWbbUDOUlvkuAineam_jrd6lzrj4O00p1XOg,21172
12
- llms/ui/Main.mjs,sha256=3Q6oR-F907EbDn-pv_SDPnErDMJcpOFgnze323lyhIs,40450
12
+ llms/ui/Main.mjs,sha256=8-LcEhAbB-HWDHtn0z1DFjgOsWvkcfEQqSUlhWozAVk,40745
13
13
  llms/ui/ModelSelector.mjs,sha256=ASLTUaqig3cDMiGup01rpubC2RrrZvPd8IFrYcK8GyQ,2565
14
+ llms/ui/OAuthSignIn.mjs,sha256=4_j4IYzpw9P1ppzxn2QZJQksh9VB6Rfzg6Nf-TfXWSA,4701
14
15
  llms/ui/ProviderIcon.mjs,sha256=HTjlgtXEpekn8iNN_S0uswbbvL0iGb20N15-_lXdojk,9054
15
16
  llms/ui/ProviderStatus.mjs,sha256=qF_rPdhyt9GffKdPCJdU0yanrDJ3cw1HLPygFP_KjEs,5744
16
17
  llms/ui/Recents.mjs,sha256=hmj7V-RXVw-DqMXjUr3OhFHTYQTkvkEhuNEDTGBf3Qw,8448
@@ -20,8 +21,8 @@ llms/ui/SignIn.mjs,sha256=df3b-7L3ZIneDGbJWUk93K9RGo40gVeuR5StzT1ZH9g,2324
20
21
  llms/ui/SystemPromptEditor.mjs,sha256=2CyIUvkIubqYPyIp5zC6_I8CMxvYINuYNjDxvMz4VRU,1265
21
22
  llms/ui/SystemPromptSelector.mjs,sha256=AuEtRwUf_RkGgene3nVA9bw8AeMb-b5_6ZLJCTWA8KQ,3051
22
23
  llms/ui/Welcome.mjs,sha256=QFAxN7sjWlhMvOIJCmHjNFCQcvpM_T-b4ze1ld9Hj7I,912
23
- llms/ui/ai.mjs,sha256=uQlTN0SdfC6n8erPfS6j6NwCpCPvfv3ewEUFtDk9UZY,2346
24
- llms/ui/app.css,sha256=e81FHQ-K7TlS7Cr2x_CCHqrvmVvg9I-m0InLQHRT_Dg,98992
24
+ llms/ui/ai.mjs,sha256=oXfMQ7kCTm8PFq2bOs7flr5tn9PJa36mAyBg2L4SDIg,4768
25
+ llms/ui/app.css,sha256=dYJ83FUYz_j31nxaKKT25xgrYFcoJ0h9ybLum9_VouA,100019
25
26
  llms/ui/fav.svg,sha256=_R6MFeXl6wBFT0lqcUxYQIDWgm246YH_3hSTW0oO8qw,734
26
27
  llms/ui/markdown.mjs,sha256=O5UspOeD8-E23rxOLWcS4eyy2YejMbPwszCYteVtuoU,6221
27
28
  llms/ui/tailwind.input.css,sha256=yo_3A50uyiVSUHUWeqAMorXMhCWpZoE5lTO6OJIFlYg,11974
@@ -39,9 +40,9 @@ llms/ui/lib/servicestack-vue.mjs,sha256=r_-khYokisXJAIPDLh8Wq6YtcLAY6HNjtJlCZJjL
39
40
  llms/ui/lib/vue-router.min.mjs,sha256=fR30GHoXI1u81zyZ26YEU105pZgbbAKSXbpnzFKIxls,30418
40
41
  llms/ui/lib/vue.min.mjs,sha256=iXh97m5hotl0eFllb3aoasQTImvp7mQoRJ_0HoxmZkw,163811
41
42
  llms/ui/lib/vue.mjs,sha256=dS8LKOG01t9CvZ04i0tbFXHqFXOO_Ha4NmM3BytjQAs,537071
42
- llms_py-2.0.25.dist-info/licenses/LICENSE,sha256=bus9cuAOWeYqBk2OuhSABVV1P4z7hgrEFISpyda_H5w,1532
43
- llms_py-2.0.25.dist-info/METADATA,sha256=QZtFwWTYyu0Swd4RbeyaPL4jc927X46lD2JEwMtf4ME,35942
44
- llms_py-2.0.25.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
45
- llms_py-2.0.25.dist-info/entry_points.txt,sha256=WswyE7PfnkZMIxboC-MS6flBD6wm-CYU7JSUnMhqMfM,40
46
- llms_py-2.0.25.dist-info/top_level.txt,sha256=gC7hk9BKSeog8gyg-EM_g2gxm1mKHwFRfK-10BxOsa4,5
47
- llms_py-2.0.25.dist-info/RECORD,,
43
+ llms_py-2.0.26.dist-info/licenses/LICENSE,sha256=bus9cuAOWeYqBk2OuhSABVV1P4z7hgrEFISpyda_H5w,1532
44
+ llms_py-2.0.26.dist-info/METADATA,sha256=gKYhj_tL1Mw6HZLDKYYrQneBXoqlQy-aHp4ctWiIQYo,36283
45
+ llms_py-2.0.26.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
46
+ llms_py-2.0.26.dist-info/entry_points.txt,sha256=WswyE7PfnkZMIxboC-MS6flBD6wm-CYU7JSUnMhqMfM,40
47
+ llms_py-2.0.26.dist-info/top_level.txt,sha256=gC7hk9BKSeog8gyg-EM_g2gxm1mKHwFRfK-10BxOsa4,5
48
+ llms_py-2.0.26.dist-info/RECORD,,