llms-py 2.0.9__py3-none-any.whl → 3.0.10__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 (194) hide show
  1. llms/__init__.py +4 -0
  2. llms/__main__.py +9 -0
  3. llms/db.py +359 -0
  4. llms/extensions/analytics/ui/index.mjs +1444 -0
  5. llms/extensions/app/README.md +20 -0
  6. llms/extensions/app/__init__.py +589 -0
  7. llms/extensions/app/db.py +536 -0
  8. {llms_py-2.0.9.data/data → llms/extensions/app}/ui/Recents.mjs +100 -73
  9. llms_py-2.0.9.data/data/ui/Sidebar.mjs → llms/extensions/app/ui/index.mjs +150 -79
  10. llms/extensions/app/ui/threadStore.mjs +433 -0
  11. llms/extensions/core_tools/CALCULATOR.md +32 -0
  12. llms/extensions/core_tools/__init__.py +637 -0
  13. llms/extensions/core_tools/ui/codemirror/addon/edit/closebrackets.js +201 -0
  14. llms/extensions/core_tools/ui/codemirror/addon/edit/closetag.js +185 -0
  15. llms/extensions/core_tools/ui/codemirror/addon/edit/continuelist.js +101 -0
  16. llms/extensions/core_tools/ui/codemirror/addon/edit/matchbrackets.js +160 -0
  17. llms/extensions/core_tools/ui/codemirror/addon/edit/matchtags.js +66 -0
  18. llms/extensions/core_tools/ui/codemirror/addon/edit/trailingspace.js +27 -0
  19. llms/extensions/core_tools/ui/codemirror/addon/selection/active-line.js +72 -0
  20. llms/extensions/core_tools/ui/codemirror/addon/selection/mark-selection.js +119 -0
  21. llms/extensions/core_tools/ui/codemirror/addon/selection/selection-pointer.js +98 -0
  22. llms/extensions/core_tools/ui/codemirror/codemirror.css +344 -0
  23. llms/extensions/core_tools/ui/codemirror/codemirror.js +9884 -0
  24. llms/extensions/core_tools/ui/codemirror/doc/docs.css +225 -0
  25. llms/extensions/core_tools/ui/codemirror/doc/source_sans.woff +0 -0
  26. llms/extensions/core_tools/ui/codemirror/mode/clike/clike.js +942 -0
  27. llms/extensions/core_tools/ui/codemirror/mode/javascript/index.html +118 -0
  28. llms/extensions/core_tools/ui/codemirror/mode/javascript/javascript.js +962 -0
  29. llms/extensions/core_tools/ui/codemirror/mode/javascript/typescript.html +62 -0
  30. llms/extensions/core_tools/ui/codemirror/mode/python/python.js +402 -0
  31. llms/extensions/core_tools/ui/codemirror/theme/dracula.css +40 -0
  32. llms/extensions/core_tools/ui/codemirror/theme/mocha.css +135 -0
  33. llms/extensions/core_tools/ui/index.mjs +650 -0
  34. llms/extensions/gallery/README.md +61 -0
  35. llms/extensions/gallery/__init__.py +63 -0
  36. llms/extensions/gallery/db.py +243 -0
  37. llms/extensions/gallery/ui/index.mjs +482 -0
  38. llms/extensions/katex/README.md +39 -0
  39. llms/extensions/katex/__init__.py +6 -0
  40. llms/extensions/katex/ui/README.md +125 -0
  41. llms/extensions/katex/ui/contrib/auto-render.js +338 -0
  42. llms/extensions/katex/ui/contrib/auto-render.min.js +1 -0
  43. llms/extensions/katex/ui/contrib/auto-render.mjs +244 -0
  44. llms/extensions/katex/ui/contrib/copy-tex.js +127 -0
  45. llms/extensions/katex/ui/contrib/copy-tex.min.js +1 -0
  46. llms/extensions/katex/ui/contrib/copy-tex.mjs +105 -0
  47. llms/extensions/katex/ui/contrib/mathtex-script-type.js +109 -0
  48. llms/extensions/katex/ui/contrib/mathtex-script-type.min.js +1 -0
  49. llms/extensions/katex/ui/contrib/mathtex-script-type.mjs +24 -0
  50. llms/extensions/katex/ui/contrib/mhchem.js +3213 -0
  51. llms/extensions/katex/ui/contrib/mhchem.min.js +1 -0
  52. llms/extensions/katex/ui/contrib/mhchem.mjs +3109 -0
  53. llms/extensions/katex/ui/contrib/render-a11y-string.js +887 -0
  54. llms/extensions/katex/ui/contrib/render-a11y-string.min.js +1 -0
  55. llms/extensions/katex/ui/contrib/render-a11y-string.mjs +800 -0
  56. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.ttf +0 -0
  57. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.woff +0 -0
  58. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  59. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  60. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  61. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  62. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  63. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  64. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  65. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  66. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  67. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  68. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  69. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  70. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  71. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.ttf +0 -0
  72. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.woff +0 -0
  73. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.woff2 +0 -0
  74. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  75. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  76. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  77. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.ttf +0 -0
  78. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.woff +0 -0
  79. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.woff2 +0 -0
  80. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.ttf +0 -0
  81. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.woff +0 -0
  82. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.woff2 +0 -0
  83. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  84. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  85. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  86. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.ttf +0 -0
  87. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.woff +0 -0
  88. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.woff2 +0 -0
  89. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  90. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  91. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  92. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  93. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  94. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  95. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  96. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  97. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  98. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.ttf +0 -0
  99. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.woff +0 -0
  100. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.woff2 +0 -0
  101. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.ttf +0 -0
  102. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.woff +0 -0
  103. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  104. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.ttf +0 -0
  105. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.woff +0 -0
  106. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  107. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.ttf +0 -0
  108. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.woff +0 -0
  109. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  110. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.ttf +0 -0
  111. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.woff +0 -0
  112. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  113. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  114. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  115. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  116. llms/extensions/katex/ui/index.mjs +92 -0
  117. llms/extensions/katex/ui/katex-swap.css +1230 -0
  118. llms/extensions/katex/ui/katex-swap.min.css +1 -0
  119. llms/extensions/katex/ui/katex.css +1230 -0
  120. llms/extensions/katex/ui/katex.js +19080 -0
  121. llms/extensions/katex/ui/katex.min.css +1 -0
  122. llms/extensions/katex/ui/katex.min.js +1 -0
  123. llms/extensions/katex/ui/katex.min.mjs +1 -0
  124. llms/extensions/katex/ui/katex.mjs +18547 -0
  125. llms/extensions/providers/__init__.py +22 -0
  126. llms/extensions/providers/anthropic.py +233 -0
  127. llms/extensions/providers/cerebras.py +37 -0
  128. llms/extensions/providers/chutes.py +153 -0
  129. llms/extensions/providers/google.py +481 -0
  130. llms/extensions/providers/nvidia.py +103 -0
  131. llms/extensions/providers/openai.py +154 -0
  132. llms/extensions/providers/openrouter.py +74 -0
  133. llms/extensions/providers/zai.py +182 -0
  134. llms/extensions/system_prompts/README.md +22 -0
  135. llms/extensions/system_prompts/__init__.py +45 -0
  136. llms/extensions/system_prompts/ui/index.mjs +280 -0
  137. llms/extensions/system_prompts/ui/prompts.json +1067 -0
  138. llms/extensions/tools/__init__.py +144 -0
  139. llms/extensions/tools/ui/index.mjs +706 -0
  140. llms/index.html +58 -0
  141. llms/llms.json +400 -0
  142. llms/main.py +4407 -0
  143. llms/providers-extra.json +394 -0
  144. llms/providers.json +1 -0
  145. llms/ui/App.mjs +188 -0
  146. llms/ui/ai.mjs +217 -0
  147. llms/ui/app.css +7081 -0
  148. llms/ui/ctx.mjs +412 -0
  149. llms/ui/index.mjs +131 -0
  150. llms/ui/lib/chart.js +14 -0
  151. llms/ui/lib/charts.mjs +16 -0
  152. llms/ui/lib/color.js +14 -0
  153. llms/ui/lib/servicestack-vue.mjs +37 -0
  154. llms/ui/lib/vue.min.mjs +13 -0
  155. llms/ui/lib/vue.mjs +18530 -0
  156. {llms_py-2.0.9.data/data → llms}/ui/markdown.mjs +33 -15
  157. llms/ui/modules/chat/ChatBody.mjs +976 -0
  158. llms/ui/modules/chat/SettingsDialog.mjs +374 -0
  159. llms/ui/modules/chat/index.mjs +991 -0
  160. llms/ui/modules/icons.mjs +46 -0
  161. llms/ui/modules/layout.mjs +271 -0
  162. llms/ui/modules/model-selector.mjs +811 -0
  163. llms/ui/tailwind.input.css +742 -0
  164. {llms_py-2.0.9.data/data → llms}/ui/typography.css +133 -7
  165. llms/ui/utils.mjs +261 -0
  166. llms_py-3.0.10.dist-info/METADATA +49 -0
  167. llms_py-3.0.10.dist-info/RECORD +177 -0
  168. llms_py-3.0.10.dist-info/entry_points.txt +2 -0
  169. {llms_py-2.0.9.dist-info → llms_py-3.0.10.dist-info}/licenses/LICENSE +1 -2
  170. llms.py +0 -1402
  171. llms_py-2.0.9.data/data/index.html +0 -64
  172. llms_py-2.0.9.data/data/llms.json +0 -447
  173. llms_py-2.0.9.data/data/requirements.txt +0 -1
  174. llms_py-2.0.9.data/data/ui/App.mjs +0 -20
  175. llms_py-2.0.9.data/data/ui/ChatPrompt.mjs +0 -389
  176. llms_py-2.0.9.data/data/ui/Main.mjs +0 -680
  177. llms_py-2.0.9.data/data/ui/app.css +0 -3951
  178. llms_py-2.0.9.data/data/ui/lib/servicestack-vue.min.mjs +0 -37
  179. llms_py-2.0.9.data/data/ui/lib/vue.min.mjs +0 -12
  180. llms_py-2.0.9.data/data/ui/tailwind.input.css +0 -261
  181. llms_py-2.0.9.data/data/ui/threadStore.mjs +0 -273
  182. llms_py-2.0.9.data/data/ui/utils.mjs +0 -114
  183. llms_py-2.0.9.data/data/ui.json +0 -1069
  184. llms_py-2.0.9.dist-info/METADATA +0 -941
  185. llms_py-2.0.9.dist-info/RECORD +0 -30
  186. llms_py-2.0.9.dist-info/entry_points.txt +0 -2
  187. {llms_py-2.0.9.data/data → llms}/ui/fav.svg +0 -0
  188. {llms_py-2.0.9.data/data → llms}/ui/lib/highlight.min.mjs +0 -0
  189. {llms_py-2.0.9.data/data → llms}/ui/lib/idb.min.mjs +0 -0
  190. {llms_py-2.0.9.data/data → llms}/ui/lib/marked.min.mjs +0 -0
  191. /llms_py-2.0.9.data/data/ui/lib/servicestack-client.min.mjs → /llms/ui/lib/servicestack-client.mjs +0 -0
  192. {llms_py-2.0.9.data/data → llms}/ui/lib/vue-router.min.mjs +0 -0
  193. {llms_py-2.0.9.dist-info → llms_py-3.0.10.dist-info}/WHEEL +0 -0
  194. {llms_py-2.0.9.dist-info → llms_py-3.0.10.dist-info}/top_level.txt +0 -0
llms.py DELETED
@@ -1,1402 +0,0 @@
1
- #!/usr/bin/env python
2
-
3
- # A lightweight CLI tool and OpenAI-compatible server for querying multiple Large Language Model (LLM) providers.
4
- # Docs: https://github.com/ServiceStack/llms
5
-
6
- import os
7
- import time
8
- import json
9
- import argparse
10
- import asyncio
11
- import subprocess
12
- import base64
13
- import mimetypes
14
- import traceback
15
- import sys
16
- import site
17
- from urllib.parse import parse_qs
18
-
19
- import aiohttp
20
- from aiohttp import web
21
-
22
- from pathlib import Path
23
- from importlib import resources # Py≥3.9 (pip install importlib_resources for 3.7/3.8)
24
-
25
- VERSION = "2.0.9"
26
- _ROOT = None
27
- g_config_path = None
28
- g_ui_path = None
29
- g_config = None
30
- g_handlers = {}
31
- g_verbose = False
32
- g_logprefix=""
33
- g_default_model=""
34
-
35
- def _log(message):
36
- """Helper method for logging from the global polling task."""
37
- if g_verbose:
38
- print(f"{g_logprefix}{message}", flush=True)
39
-
40
- def printdump(obj):
41
- args = obj.__dict__ if hasattr(obj, '__dict__') else obj
42
- print(json.dumps(args, indent=2))
43
-
44
- def print_chat(chat):
45
- _log(f"Chat: {chat_summary(chat)}")
46
-
47
- def chat_summary(chat):
48
- """Summarize chat completion request for logging."""
49
- # replace image_url.url with <image>
50
- clone = json.loads(json.dumps(chat))
51
- for message in clone['messages']:
52
- if 'content' in message:
53
- if isinstance(message['content'], list):
54
- for item in message['content']:
55
- if 'image_url' in item:
56
- if 'url' in item['image_url']:
57
- url = item['image_url']['url']
58
- prefix = url.split(',', 1)[0]
59
- item['image_url']['url'] = prefix + f",({len(url) - len(prefix)})"
60
- elif 'input_audio' in item:
61
- if 'data' in item['input_audio']:
62
- data = item['input_audio']['data']
63
- item['input_audio']['data'] = f"({len(data)})"
64
- elif 'file' in item:
65
- if 'file_data' in item['file']:
66
- data = item['file']['file_data']
67
- prefix = url.split(',', 1)[0]
68
- item['file']['file_data'] = prefix + f",({len(url) - len(prefix)})"
69
- return json.dumps(clone, indent=2)
70
-
71
- def gemini_chat_summary(gemini_chat):
72
- """Summarize Gemini chat completion request for logging. Replace inline_data with size of content only"""
73
- clone = json.loads(json.dumps(gemini_chat))
74
- for content in clone['contents']:
75
- for part in content['parts']:
76
- if 'inline_data' in part:
77
- data = part['inline_data']['data']
78
- part['inline_data']['data'] = f"({len(data)})"
79
- return json.dumps(clone, indent=2)
80
-
81
- image_exts = 'png,webp,jpg,jpeg,gif,bmp,svg,tiff,ico'.split(',')
82
- audio_exts = 'mp3,wav,ogg,flac,m4a,opus,webm'.split(',')
83
-
84
- def is_file_path(path):
85
- # macOs max path is 1023
86
- return path and len(path) < 1024 and os.path.exists(path)
87
-
88
- def is_url(url):
89
- return url and (url.startswith('http://') or url.startswith('https://'))
90
-
91
- def get_filename(file):
92
- return file.rsplit('/',1)[1] if '/' in file else 'file'
93
-
94
- def parse_args_params(args_str):
95
- """Parse URL-encoded parameters and return a dictionary."""
96
- if not args_str:
97
- return {}
98
-
99
- # Parse the URL-encoded string
100
- parsed = parse_qs(args_str, keep_blank_values=True)
101
-
102
- # Convert to simple dict with single values (not lists)
103
- result = {}
104
- for key, values in parsed.items():
105
- if len(values) == 1:
106
- value = values[0]
107
- # Try to convert to appropriate types
108
- if value.lower() == 'true':
109
- result[key] = True
110
- elif value.lower() == 'false':
111
- result[key] = False
112
- elif value.isdigit():
113
- result[key] = int(value)
114
- else:
115
- try:
116
- # Try to parse as float
117
- result[key] = float(value)
118
- except ValueError:
119
- # Keep as string
120
- result[key] = value
121
- else:
122
- # Multiple values, keep as list
123
- result[key] = values
124
-
125
- return result
126
-
127
- def apply_args_to_chat(chat, args_params):
128
- """Apply parsed arguments to the chat request."""
129
- if not args_params:
130
- return chat
131
-
132
- # Apply each parameter to the chat request
133
- for key, value in args_params.items():
134
- if isinstance(value, str):
135
- if key == 'stop':
136
- if ',' in value:
137
- value = value.split(',')
138
- elif key == 'max_completion_tokens' or key == 'max_tokens' or key == 'n' or key == 'seed' or key == 'top_logprobs':
139
- value = int(value)
140
- elif key == 'temperature' or key == 'top_p' or key == 'frequency_penalty' or key == 'presence_penalty':
141
- value = float(value)
142
- elif key == 'store' or key == 'logprobs' or key == 'enable_thinking' or key == 'parallel_tool_calls' or key == 'stream':
143
- value = bool(value)
144
- chat[key] = value
145
-
146
- return chat
147
-
148
- def is_base_64(data):
149
- try:
150
- base64.b64decode(data)
151
- return True
152
- except Exception:
153
- return False
154
-
155
- def get_file_mime_type(filename):
156
- mime_type, _ = mimetypes.guess_type(filename)
157
- return mime_type or "application/octet-stream"
158
-
159
- async def process_chat(chat):
160
- if not chat:
161
- raise Exception("No chat provided")
162
- if 'stream' not in chat:
163
- chat['stream'] = False
164
- if 'messages' not in chat:
165
- return chat
166
-
167
- async with aiohttp.ClientSession() as session:
168
- for message in chat['messages']:
169
- if 'content' not in message:
170
- continue
171
-
172
- if isinstance(message['content'], list):
173
- for item in message['content']:
174
- if 'type' not in item:
175
- continue
176
- if item['type'] == 'image_url' and 'image_url' in item:
177
- image_url = item['image_url']
178
- if 'url' in image_url:
179
- url = image_url['url']
180
- if is_url(url):
181
- _log(f"Downloading image: {url}")
182
- async with session.get(url, timeout=aiohttp.ClientTimeout(total=120)) as response:
183
- response.raise_for_status()
184
- content = await response.read()
185
- # get mimetype from response headers
186
- mimetype = get_file_mime_type(get_filename(url))
187
- if 'Content-Type' in response.headers:
188
- mimetype = response.headers['Content-Type']
189
- # convert to data uri
190
- image_url['url'] = f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}"
191
- elif is_file_path(url):
192
- _log(f"Reading image: {url}")
193
- with open(url, "rb") as f:
194
- content = f.read()
195
- ext = os.path.splitext(url)[1].lower().lstrip('.') if '.' in url else 'png'
196
- # get mimetype from file extension
197
- mimetype = get_file_mime_type(get_filename(url))
198
- # convert to data uri
199
- image_url['url'] = f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}"
200
- elif url.startswith('data:'):
201
- pass
202
- else:
203
- raise Exception(f"Invalid image: {url}")
204
- elif item['type'] == 'input_audio' and 'input_audio' in item:
205
- input_audio = item['input_audio']
206
- if 'data' in input_audio:
207
- url = input_audio['data']
208
- mimetype = get_file_mime_type(get_filename(url))
209
- if is_url(url):
210
- _log(f"Downloading audio: {url}")
211
- async with session.get(url, timeout=aiohttp.ClientTimeout(total=120)) as response:
212
- response.raise_for_status()
213
- content = await response.read()
214
- # get mimetype from response headers
215
- if 'Content-Type' in response.headers:
216
- mimetype = response.headers['Content-Type']
217
- # convert to base64
218
- input_audio['data'] = base64.b64encode(content).decode('utf-8')
219
- input_audio['format'] = mimetype.rsplit('/',1)[1]
220
- elif is_file_path(url):
221
- _log(f"Reading audio: {url}")
222
- with open(url, "rb") as f:
223
- content = f.read()
224
- # convert to base64
225
- input_audio['data'] = base64.b64encode(content).decode('utf-8')
226
- input_audio['format'] = mimetype.rsplit('/',1)[1]
227
- elif is_base_64(url):
228
- pass # use base64 data as-is
229
- else:
230
- raise Exception(f"Invalid audio: {url}")
231
- elif item['type'] == 'file' and 'file' in item:
232
- file = item['file']
233
- if 'file_data' in file:
234
- url = file['file_data']
235
- mimetype = get_file_mime_type(get_filename(url))
236
- if is_url(url):
237
- _log(f"Downloading file: {url}")
238
- async with session.get(url, timeout=aiohttp.ClientTimeout(total=120)) as response:
239
- response.raise_for_status()
240
- content = await response.read()
241
- file['filename'] = get_filename(url)
242
- file['file_data'] = f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}"
243
- elif is_file_path(url):
244
- _log(f"Reading file: {url}")
245
- with open(url, "rb") as f:
246
- content = f.read()
247
- file['filename'] = get_filename(url)
248
- file['file_data'] = f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}"
249
- elif url.startswith('data:'):
250
- if 'filename' not in file:
251
- file['filename'] = 'file'
252
- pass # use base64 data as-is
253
- else:
254
- raise Exception(f"Invalid file: {url}")
255
- return chat
256
-
257
- class HTTPError(Exception):
258
- def __init__(self, status, reason, body, headers=None):
259
- self.status = status
260
- self.reason = reason
261
- self.body = body
262
- self.headers = headers
263
- super().__init__(f"HTTP {status} {reason}")
264
-
265
- async def response_json(response):
266
- text = await response.text()
267
- if response.status >= 400:
268
- raise HTTPError(response.status, reason=response.reason, body=text, headers=dict(response.headers))
269
- response.raise_for_status()
270
- body = json.loads(text)
271
- return body
272
-
273
- class OpenAiProvider:
274
- def __init__(self, base_url, api_key=None, models={}, **kwargs):
275
- self.base_url = base_url.strip("/")
276
- self.api_key = api_key
277
- self.models = models
278
-
279
- # check if base_url ends with /v{\d} to handle providers with different versions (e.g. z.ai uses /v4)
280
- last_segment = base_url.rsplit('/',1)[1]
281
- if last_segment.startswith('v') and last_segment[1:].isdigit():
282
- self.chat_url = f"{base_url}/chat/completions"
283
- else:
284
- self.chat_url = f"{base_url}/v1/chat/completions"
285
-
286
- self.headers = kwargs['headers'] if 'headers' in kwargs else {
287
- "Content-Type": "application/json",
288
- }
289
- if api_key is not None:
290
- self.headers["Authorization"] = f"Bearer {api_key}"
291
-
292
- self.frequency_penalty = float(kwargs['frequency_penalty']) if 'frequency_penalty' in kwargs else None
293
- self.max_completion_tokens = int(kwargs['max_completion_tokens']) if 'max_completion_tokens' in kwargs else None
294
- self.n = int(kwargs['n']) if 'n' in kwargs else None
295
- self.parallel_tool_calls = bool(kwargs['parallel_tool_calls']) if 'parallel_tool_calls' in kwargs else None
296
- self.presence_penalty = float(kwargs['presence_penalty']) if 'presence_penalty' in kwargs else None
297
- self.prompt_cache_key = kwargs['prompt_cache_key'] if 'prompt_cache_key' in kwargs else None
298
- self.reasoning_effort = kwargs['reasoning_effort'] if 'reasoning_effort' in kwargs else None
299
- self.safety_identifier = kwargs['safety_identifier'] if 'safety_identifier' in kwargs else None
300
- self.seed = int(kwargs['seed']) if 'seed' in kwargs else None
301
- self.service_tier = kwargs['service_tier'] if 'service_tier' in kwargs else None
302
- self.stop = kwargs['stop'] if 'stop' in kwargs else None
303
- self.store = bool(kwargs['store']) if 'store' in kwargs else None
304
- self.temperature = float(kwargs['temperature']) if 'temperature' in kwargs else None
305
- self.top_logprobs = int(kwargs['top_logprobs']) if 'top_logprobs' in kwargs else None
306
- self.top_p = float(kwargs['top_p']) if 'top_p' in kwargs else None
307
- self.verbosity = kwargs['verbosity'] if 'verbosity' in kwargs else None
308
- self.stream = bool(kwargs['stream']) if 'stream' in kwargs else None
309
- self.enable_thinking = bool(kwargs['enable_thinking']) if 'enable_thinking' in kwargs else None
310
-
311
- @classmethod
312
- def test(cls, base_url=None, api_key=None, models={}, **kwargs):
313
- return base_url is not None and api_key is not None and len(models) > 0
314
-
315
- async def load(self):
316
- pass
317
-
318
- async def chat(self, chat):
319
- model = chat['model']
320
- if model in self.models:
321
- chat['model'] = self.models[model]
322
-
323
- # with open(os.path.join(os.path.dirname(__file__), 'chat.wip.json'), "w") as f:
324
- # f.write(json.dumps(chat, indent=2))
325
-
326
- if self.frequency_penalty is not None:
327
- chat['frequency_penalty'] = self.frequency_penalty
328
- if self.max_completion_tokens is not None:
329
- chat['max_completion_tokens'] = self.max_completion_tokens
330
- if self.n is not None:
331
- chat['n'] = self.n
332
- if self.parallel_tool_calls is not None:
333
- chat['parallel_tool_calls'] = self.parallel_tool_calls
334
- if self.presence_penalty is not None:
335
- chat['presence_penalty'] = self.presence_penalty
336
- if self.prompt_cache_key is not None:
337
- chat['prompt_cache_key'] = self.prompt_cache_key
338
- if self.reasoning_effort is not None:
339
- chat['reasoning_effort'] = self.reasoning_effort
340
- if self.safety_identifier is not None:
341
- chat['safety_identifier'] = self.safety_identifier
342
- if self.seed is not None:
343
- chat['seed'] = self.seed
344
- if self.service_tier is not None:
345
- chat['service_tier'] = self.service_tier
346
- if self.stop is not None:
347
- chat['stop'] = self.stop
348
- if self.store is not None:
349
- chat['store'] = self.store
350
- if self.temperature is not None:
351
- chat['temperature'] = self.temperature
352
- if self.top_logprobs is not None:
353
- chat['top_logprobs'] = self.top_logprobs
354
- if self.top_p is not None:
355
- chat['top_p'] = self.top_p
356
- if self.verbosity is not None:
357
- chat['verbosity'] = self.verbosity
358
- if self.enable_thinking is not None:
359
- chat['enable_thinking'] = self.enable_thinking
360
-
361
- chat = await process_chat(chat)
362
- _log(f"POST {self.chat_url}")
363
- _log(chat_summary(chat))
364
- async with aiohttp.ClientSession() as session:
365
- async with session.post(self.chat_url, headers=self.headers, data=json.dumps(chat), timeout=aiohttp.ClientTimeout(total=120)) as response:
366
- return await response_json(response)
367
-
368
- class OllamaProvider(OpenAiProvider):
369
- def __init__(self, base_url, models, all_models=False, **kwargs):
370
- super().__init__(base_url=base_url, models=models, **kwargs)
371
- self.all_models = all_models
372
-
373
- async def load(self):
374
- if self.all_models:
375
- await self.load_models(default_models=self.models)
376
-
377
- async def get_models(self):
378
- ret = {}
379
- try:
380
- async with aiohttp.ClientSession() as session:
381
- _log(f"GET {self.base_url}/api/tags")
382
- async with session.get(f"{self.base_url}/api/tags", headers=self.headers, timeout=aiohttp.ClientTimeout(total=120)) as response:
383
- data = await response_json(response)
384
- for model in data.get('models', []):
385
- name = model['model']
386
- if name.endswith(":latest"):
387
- name = name[:-7]
388
- ret[name] = name
389
- _log(f"Loaded Ollama models: {ret}")
390
- except Exception as e:
391
- _log(f"Error getting Ollama models: {e}")
392
- # return empty dict if ollama is not available
393
- return ret
394
-
395
- async def load_models(self, default_models):
396
- """Load models if all_models was requested"""
397
- if self.all_models:
398
- self.models = await self.get_models()
399
- if default_models:
400
- self.models = {**default_models, **self.models}
401
-
402
- @classmethod
403
- def test(cls, base_url=None, models={}, all_models=False, **kwargs):
404
- return base_url is not None and (len(models) > 0 or all_models)
405
-
406
- class GoogleOpenAiProvider(OpenAiProvider):
407
- def __init__(self, api_key, models, **kwargs):
408
- super().__init__(base_url="https://generativelanguage.googleapis.com", api_key=api_key, models=models, **kwargs)
409
- self.chat_url = "https://generativelanguage.googleapis.com/v1beta/chat/completions"
410
-
411
- @classmethod
412
- def test(cls, api_key=None, models={}, **kwargs):
413
- return api_key is not None and len(models) > 0
414
-
415
- class GoogleProvider(OpenAiProvider):
416
- def __init__(self, models, api_key, safety_settings=None, thinking_config=None, curl=False, **kwargs):
417
- super().__init__(base_url="https://generativelanguage.googleapis.com", api_key=api_key, models=models, **kwargs)
418
- self.safety_settings = safety_settings
419
- self.thinking_config = thinking_config
420
- self.curl = curl
421
- self.headers = kwargs['headers'] if 'headers' in kwargs else {
422
- "Content-Type": "application/json",
423
- }
424
- # Google fails when using Authorization header, use query string param instead
425
- if 'Authorization' in self.headers:
426
- del self.headers['Authorization']
427
-
428
- @classmethod
429
- def test(cls, api_key=None, models={}, **kwargs):
430
- return api_key is not None and len(models) > 0
431
-
432
- async def chat(self, chat):
433
- model = chat['model']
434
- if model in self.models:
435
- chat['model'] = self.models[model]
436
-
437
- chat = await process_chat(chat)
438
- generationConfig = {}
439
-
440
- # Filter out system messages and convert to proper Gemini format
441
- contents = []
442
- system_prompt = None
443
-
444
- async with aiohttp.ClientSession() as session:
445
- for message in chat['messages']:
446
- if message['role'] == 'system':
447
- system_prompt = message
448
- elif 'content' in message:
449
- if isinstance(message['content'], list):
450
- parts = []
451
- for item in message['content']:
452
- if 'type' in item:
453
- if item['type'] == 'image_url' and 'image_url' in item:
454
- image_url = item['image_url']
455
- if 'url' not in image_url:
456
- continue
457
- url = image_url['url']
458
- if not url.startswith('data:'):
459
- raise(Exception("Image was not downloaded: " + url))
460
- # Extract mime type from data uri
461
- mimetype = url.split(';',1)[0].split(':',1)[1] if ';' in url else "image/png"
462
- base64Data = url.split(',',1)[1]
463
- parts.append({
464
- "inline_data": {
465
- "mime_type": mimetype,
466
- "data": base64Data
467
- }
468
- })
469
- elif item['type'] == 'input_audio' and 'input_audio' in item:
470
- input_audio = item['input_audio']
471
- if 'data' not in input_audio:
472
- continue
473
- data = input_audio['data']
474
- format = input_audio['format']
475
- mimetype = f"audio/{format}"
476
- parts.append({
477
- "inline_data": {
478
- "mime_type": mimetype,
479
- "data": data
480
- }
481
- })
482
- elif item['type'] == 'file' and 'file' in item:
483
- file = item['file']
484
- if 'file_data' not in file:
485
- continue
486
- data = file['file_data']
487
- if not data.startswith('data:'):
488
- raise(Exception("File was not downloaded: " + data))
489
- # Extract mime type from data uri
490
- mimetype = data.split(';',1)[0].split(':',1)[1] if ';' in data else "application/octet-stream"
491
- base64Data = data.split(',',1)[1]
492
- parts.append({
493
- "inline_data": {
494
- "mime_type": mimetype,
495
- "data": base64Data
496
- }
497
- })
498
- if 'text' in item:
499
- text = item['text']
500
- parts.append({"text": text})
501
- if len(parts) > 0:
502
- contents.append({
503
- "role": message['role'] if 'role' in message and message['role'] == 'user' else 'model',
504
- "parts": parts
505
- })
506
- else:
507
- content = message['content']
508
- contents.append({
509
- "role": message['role'] if 'role' in message and message['role'] == 'user' else 'model',
510
- "parts": [{"text": content}]
511
- })
512
-
513
- gemini_chat = {
514
- "contents": contents,
515
- }
516
-
517
- if self.safety_settings:
518
- gemini_chat['safetySettings'] = self.safety_settings
519
-
520
- # Add system instruction if present
521
- if system_prompt is not None:
522
- gemini_chat['systemInstruction'] = {
523
- "parts": [{"text": system_prompt['content']}]
524
- }
525
-
526
- if 'stop' in chat:
527
- generationConfig['stopSequences'] = [chat['stop']]
528
- if 'temperature' in chat:
529
- generationConfig['temperature'] = chat['temperature']
530
- if 'top_p' in chat:
531
- generationConfig['topP'] = chat['top_p']
532
- if 'top_logprobs' in chat:
533
- generationConfig['topK'] = chat['top_logprobs']
534
-
535
- if 'thinkingConfig' in chat:
536
- generationConfig['thinkingConfig'] = chat['thinkingConfig']
537
- elif self.thinking_config:
538
- generationConfig['thinkingConfig'] = self.thinking_config
539
-
540
- if len(generationConfig) > 0:
541
- gemini_chat['generationConfig'] = generationConfig
542
-
543
- started_at = int(time.time() * 1000)
544
- gemini_chat_url = f"https://generativelanguage.googleapis.com/v1beta/models/{chat['model']}:generateContent?key={self.api_key}"
545
-
546
- _log(f"POST {gemini_chat_url}")
547
- _log(gemini_chat_summary(gemini_chat))
548
-
549
- if self.curl:
550
- curl_args = [
551
- 'curl',
552
- '-X', 'POST',
553
- '-H', 'Content-Type: application/json',
554
- '-d', json.dumps(gemini_chat),
555
- gemini_chat_url
556
- ]
557
- try:
558
- o = subprocess.run(curl_args, check=True, capture_output=True, text=True, timeout=120)
559
- obj = json.loads(o.stdout)
560
- except Exception as e:
561
- raise Exception(f"Error executing curl: {e}")
562
- else:
563
- async with session.post(gemini_chat_url, headers=self.headers, data=json.dumps(gemini_chat), timeout=aiohttp.ClientTimeout(total=120)) as res:
564
- obj = await response_json(res)
565
- _log(f"google response:\n{json.dumps(obj, indent=2)}")
566
-
567
- response = {
568
- "id": f"chatcmpl-{started_at}",
569
- "created": started_at,
570
- "model": obj.get('modelVersion', chat['model']),
571
- }
572
- choices = []
573
- i = 0
574
- if 'error' in obj:
575
- _log(f"Error: {obj['error']}")
576
- raise Exception(obj['error']['message'])
577
- for candidate in obj['candidates']:
578
- role = "assistant"
579
- if 'content' in candidate and 'role' in candidate['content']:
580
- role = "assistant" if candidate['content']['role'] == 'model' else candidate['content']['role']
581
-
582
- # Safely extract content from all text parts
583
- content = ""
584
- reasoning = ""
585
- if 'content' in candidate and 'parts' in candidate['content']:
586
- text_parts = []
587
- reasoning_parts = []
588
- for part in candidate['content']['parts']:
589
- if 'text' in part:
590
- if 'thought' in part and part['thought']:
591
- reasoning_parts.append(part['text'])
592
- else:
593
- text_parts.append(part['text'])
594
- content = ' '.join(text_parts)
595
- reasoning = ' '.join(reasoning_parts)
596
-
597
- choice = {
598
- "index": i,
599
- "finish_reason": candidate.get('finishReason', 'stop'),
600
- "message": {
601
- "role": role,
602
- "content": content,
603
- },
604
- }
605
- if reasoning:
606
- choice['message']['reasoning'] = reasoning
607
- choices.append(choice)
608
- i += 1
609
- response['choices'] = choices
610
- if 'usageMetadata' in obj:
611
- usage = obj['usageMetadata']
612
- response['usage'] = {
613
- "completion_tokens": usage['candidatesTokenCount'],
614
- "total_tokens": usage['totalTokenCount'],
615
- "prompt_tokens": usage['promptTokenCount'],
616
- }
617
- return response
618
-
619
- def get_models():
620
- ret = []
621
- for provider in g_handlers.values():
622
- for model in provider.models.keys():
623
- if model not in ret:
624
- ret.append(model)
625
- ret.sort()
626
- return ret
627
-
628
- async def chat_completion(chat):
629
- model = chat['model']
630
- # get first provider that has the model
631
- candidate_providers = [name for name, provider in g_handlers.items() if model in provider.models]
632
- if len(candidate_providers) == 0:
633
- raise(Exception(f"Model {model} not found"))
634
-
635
- first_exception = None
636
- for name in candidate_providers:
637
- provider = g_handlers[name]
638
- _log(f"provider: {name} {type(provider).__name__}")
639
- try:
640
- response = await provider.chat(chat.copy())
641
- return response
642
- except Exception as e:
643
- if first_exception is None:
644
- first_exception = e
645
- _log(f"Provider {name} failed: {e}")
646
- continue
647
-
648
- # If we get here, all providers failed
649
- raise first_exception
650
-
651
- async def cli_chat(chat, image=None, audio=None, file=None, args=None, raw=False):
652
- if g_default_model:
653
- chat['model'] = g_default_model
654
-
655
- # Apply args parameters to chat request
656
- if args:
657
- chat = apply_args_to_chat(chat, args)
658
-
659
- # process_chat downloads the image, just adding the reference here
660
- if image is not None:
661
- first_message = None
662
- for message in chat['messages']:
663
- if message['role'] == 'user':
664
- first_message = message
665
- break
666
- image_content = {
667
- "type": "image_url",
668
- "image_url": {
669
- "url": image
670
- }
671
- }
672
- if 'content' in first_message:
673
- if isinstance(first_message['content'], list):
674
- image_url = None
675
- for item in first_message['content']:
676
- if 'image_url' in item:
677
- image_url = item['image_url']
678
- # If no image_url, add one
679
- if image_url is None:
680
- first_message['content'].insert(0,image_content)
681
- else:
682
- image_url['url'] = image
683
- else:
684
- first_message['content'] = [
685
- image_content,
686
- { "type": "text", "text": first_message['content'] }
687
- ]
688
- if audio is not None:
689
- first_message = None
690
- for message in chat['messages']:
691
- if message['role'] == 'user':
692
- first_message = message
693
- break
694
- audio_content = {
695
- "type": "input_audio",
696
- "input_audio": {
697
- "data": audio,
698
- "format": "mp3"
699
- }
700
- }
701
- if 'content' in first_message:
702
- if isinstance(first_message['content'], list):
703
- input_audio = None
704
- for item in first_message['content']:
705
- if 'input_audio' in item:
706
- input_audio = item['input_audio']
707
- # If no input_audio, add one
708
- if input_audio is None:
709
- first_message['content'].insert(0,audio_content)
710
- else:
711
- input_audio['data'] = audio
712
- else:
713
- first_message['content'] = [
714
- audio_content,
715
- { "type": "text", "text": first_message['content'] }
716
- ]
717
- if file is not None:
718
- first_message = None
719
- for message in chat['messages']:
720
- if message['role'] == 'user':
721
- first_message = message
722
- break
723
- file_content = {
724
- "type": "file",
725
- "file": {
726
- "filename": get_filename(file),
727
- "file_data": file
728
- }
729
- }
730
- if 'content' in first_message:
731
- if isinstance(first_message['content'], list):
732
- file_data = None
733
- for item in first_message['content']:
734
- if 'file' in item:
735
- file_data = item['file']
736
- # If no file_data, add one
737
- if file_data is None:
738
- first_message['content'].insert(0,file_content)
739
- else:
740
- file_data['filename'] = get_filename(file)
741
- file_data['file_data'] = file
742
- else:
743
- first_message['content'] = [
744
- file_content,
745
- { "type": "text", "text": first_message['content'] }
746
- ]
747
-
748
- if g_verbose:
749
- printdump(chat)
750
-
751
- try:
752
- response = await chat_completion(chat)
753
- if raw:
754
- print(json.dumps(response, indent=2))
755
- exit(0)
756
- else:
757
- answer = response['choices'][0]['message']['content']
758
- print(answer)
759
- except HTTPError as e:
760
- # HTTP error (4xx, 5xx)
761
- print(f"{e}:\n{e.body}")
762
- exit(1)
763
- except aiohttp.ClientConnectionError as e:
764
- # Connection issues
765
- print(f"Connection error: {e}")
766
- exit(1)
767
- except asyncio.TimeoutError as e:
768
- # Timeout
769
- print(f"Timeout error: {e}")
770
- exit(1)
771
-
772
- def config_str(key):
773
- return key in g_config and g_config[key] or None
774
-
775
- def init_llms(config):
776
- global g_config, g_handlers
777
-
778
- g_config = config
779
- g_handlers = {}
780
- # iterate over config and replace $ENV with env value
781
- for key, value in g_config.items():
782
- if isinstance(value, str) and value.startswith("$"):
783
- g_config[key] = os.environ.get(value[1:], "")
784
-
785
- # if g_verbose:
786
- # printdump(g_config)
787
- providers = g_config['providers']
788
-
789
- for name, orig in providers.items():
790
- definition = orig.copy()
791
- provider_type = definition['type']
792
- if 'enabled' in definition and not definition['enabled']:
793
- continue
794
-
795
- # Replace API keys with environment variables if they start with $
796
- if 'api_key' in definition:
797
- value = definition['api_key']
798
- if isinstance(value, str) and value.startswith("$"):
799
- definition['api_key'] = os.environ.get(value[1:], "")
800
-
801
- # Create a copy of definition without the 'type' key for constructor kwargs
802
- constructor_kwargs = {k: v for k, v in definition.items() if k != 'type' and k != 'enabled'}
803
- constructor_kwargs['headers'] = g_config['defaults']['headers'].copy()
804
-
805
- if provider_type == 'OpenAiProvider' and OpenAiProvider.test(**constructor_kwargs):
806
- g_handlers[name] = OpenAiProvider(**constructor_kwargs)
807
- elif provider_type == 'OllamaProvider' and OllamaProvider.test(**constructor_kwargs):
808
- g_handlers[name] = OllamaProvider(**constructor_kwargs)
809
- elif provider_type == 'GoogleProvider' and GoogleProvider.test(**constructor_kwargs):
810
- g_handlers[name] = GoogleProvider(**constructor_kwargs)
811
- elif provider_type == 'GoogleOpenAiProvider' and GoogleOpenAiProvider.test(**constructor_kwargs):
812
- g_handlers[name] = GoogleOpenAiProvider(**constructor_kwargs)
813
-
814
- return g_handlers
815
-
816
- async def load_llms():
817
- global g_handlers
818
- _log("Loading providers...")
819
- for name, provider in g_handlers.items():
820
- await provider.load()
821
-
822
- def save_config(config):
823
- global g_config
824
- g_config = config
825
- with open(g_config_path, "w") as f:
826
- json.dump(g_config, f, indent=4)
827
- _log(f"Saved config to {g_config_path}")
828
-
829
- def github_url(filename):
830
- return f"https://raw.githubusercontent.com/ServiceStack/llms/refs/heads/main/{filename}"
831
-
832
- async def save_text(url, save_path):
833
- async with aiohttp.ClientSession() as session:
834
- async with session.get(url) as resp:
835
- text = await resp.text()
836
- if resp.status >= 400:
837
- raise HTTPError(resp.status, reason=resp.reason, body=text, headers=dict(resp.headers))
838
- os.makedirs(os.path.dirname(save_path), exist_ok=True)
839
- with open(save_path, "w") as f:
840
- f.write(text)
841
- return text
842
-
843
- async def save_default_config(config_path):
844
- global g_config
845
- config_json = await save_text(github_url("llms.json"), config_path)
846
- g_config = json.loads(config_json)
847
-
848
- async def update_llms():
849
- """
850
- Update llms.py from GitHub
851
- """
852
- await save_text(github_url("llms.py"), __file__)
853
-
854
- def provider_status():
855
- enabled = list(g_handlers.keys())
856
- disabled = [provider for provider in g_config['providers'].keys() if provider not in enabled]
857
- enabled.sort()
858
- disabled.sort()
859
- return enabled, disabled
860
-
861
- def print_status():
862
- enabled, disabled = provider_status()
863
- if len(enabled) > 0:
864
- print(f"\nEnabled: {', '.join(enabled)}")
865
- else:
866
- print("\nEnabled: None")
867
- if len(disabled) > 0:
868
- print(f"Disabled: {', '.join(disabled)}")
869
- else:
870
- print("Disabled: None")
871
-
872
- def home_llms_path(filename):
873
- return f"{os.environ.get('HOME')}/.llms/{filename}"
874
-
875
- def get_config_path():
876
- home_config_path = home_llms_path("llms.json")
877
- check_paths = [
878
- "./llms.json",
879
- home_config_path,
880
- ]
881
- if os.environ.get("LLMS_CONFIG_PATH"):
882
- check_paths.insert(0, os.environ.get("LLMS_CONFIG_PATH"))
883
-
884
- for check_path in check_paths:
885
- g_config_path = os.path.normpath(os.path.join(os.path.dirname(__file__), check_path))
886
- if os.path.exists(g_config_path):
887
- return g_config_path
888
- return None
889
-
890
- def get_ui_path():
891
- ui_paths = [
892
- home_llms_path("ui.json"),
893
- "ui.json"
894
- ]
895
- for ui_path in ui_paths:
896
- if os.path.exists(ui_path):
897
- return ui_path
898
- return None
899
-
900
- def enable_provider(provider):
901
- msg = None
902
- provider_config = g_config['providers'][provider]
903
- provider_config['enabled'] = True
904
- if 'api_key' in provider_config:
905
- api_key = provider_config['api_key']
906
- if isinstance(api_key, str):
907
- if api_key.startswith("$"):
908
- if not os.environ.get(api_key[1:], ""):
909
- msg = f"WARNING: {provider} requires missing API Key in Environment Variable {api_key}"
910
- else:
911
- msg = f"WARNING: {provider} is not configured with an API Key"
912
- save_config(g_config)
913
- init_llms(g_config)
914
- return provider_config, msg
915
-
916
- def disable_provider(provider):
917
- provider_config = g_config['providers'][provider]
918
- provider_config['enabled'] = False
919
- save_config(g_config)
920
- init_llms(g_config)
921
-
922
- def resolve_root():
923
- # Try to find the resource root directory
924
- # When installed as a package, static files may be in different locations
925
-
926
- # Method 1: Try importlib.resources for package data (Python 3.9+)
927
- try:
928
- try:
929
- # Try to access the package resources
930
- pkg_files = resources.files("llms")
931
- # Check if ui directory exists in package resources
932
- if hasattr(pkg_files, 'is_dir') and (pkg_files / "ui").is_dir():
933
- _log(f"RESOURCE ROOT (package): {pkg_files}")
934
- return pkg_files
935
- except (FileNotFoundError, AttributeError, TypeError):
936
- # Package doesn't have the resources, try other methods
937
- pass
938
- except ImportError:
939
- # importlib.resources not available (Python < 3.9)
940
- pass
941
-
942
- # Method 2: Try to find data files in sys.prefix (where data_files are installed)
943
- # Get all possible installation directories
944
- possible_roots = [
945
- Path(sys.prefix), # Standard installation
946
- Path(sys.prefix) / "share", # Some distributions
947
- Path(sys.base_prefix), # Virtual environments
948
- Path(sys.base_prefix) / "share",
949
- ]
950
-
951
- # Add site-packages directories
952
- for site_dir in site.getsitepackages():
953
- possible_roots.extend([
954
- Path(site_dir),
955
- Path(site_dir).parent,
956
- Path(site_dir).parent / "share",
957
- ])
958
-
959
- # Add user site directory
960
- try:
961
- user_site = site.getusersitepackages()
962
- if user_site:
963
- possible_roots.extend([
964
- Path(user_site),
965
- Path(user_site).parent,
966
- Path(user_site).parent / "share",
967
- ])
968
- except AttributeError:
969
- pass
970
-
971
- for root in possible_roots:
972
- try:
973
- if root.exists() and (root / "index.html").exists() and (root / "ui").is_dir():
974
- _log(f"RESOURCE ROOT (data files): {root}")
975
- return root
976
- except (OSError, PermissionError):
977
- continue
978
-
979
- # Method 3: Development mode - look relative to this file
980
- # __file__ is *this* module; look in same directory first, then parent
981
- dev_roots = [
982
- Path(__file__).resolve().parent, # Same directory as llms.py
983
- Path(__file__).resolve().parent.parent, # Parent directory (repo root)
984
- ]
985
-
986
- for root in dev_roots:
987
- try:
988
- if (root / "index.html").exists() and (root / "ui").is_dir():
989
- _log(f"RESOURCE ROOT (development): {root}")
990
- return root
991
- except (OSError, PermissionError):
992
- continue
993
-
994
- # Fallback: use the directory containing this file
995
- from_file = Path(__file__).resolve().parent
996
- _log(f"RESOURCE ROOT (fallback): {from_file}")
997
- return from_file
998
-
999
- def resource_exists(resource_path):
1000
- # Check if resource files exist (handle both Path and Traversable objects)
1001
- try:
1002
- if hasattr(resource_path, 'is_file'):
1003
- return resource_path.is_file()
1004
- else:
1005
- return os.path.exists(resource_path)
1006
- except (OSError, AttributeError):
1007
- pass
1008
-
1009
- def read_resource_text(resource_path):
1010
- if hasattr(resource_path, 'read_text'):
1011
- return resource_path.read_text()
1012
- else:
1013
- with open(resource_path, "r") as f:
1014
- return f.read()
1015
-
1016
- def read_resource_file_bytes(resource_file):
1017
- try:
1018
- if hasattr(_ROOT, 'joinpath'):
1019
- # importlib.resources Traversable
1020
- index_resource = _ROOT.joinpath(resource_file)
1021
- if index_resource.is_file():
1022
- return index_resource.read_bytes()
1023
- else:
1024
- # Regular Path object
1025
- index_path = _ROOT / resource_file
1026
- if index_path.exists():
1027
- return index_path.read_bytes()
1028
- except (OSError, PermissionError, AttributeError) as e:
1029
- _log(f"Error reading resource bytes: {e}")
1030
-
1031
- def main():
1032
- global _ROOT, g_verbose, g_default_model, g_logprefix, g_config_path, g_ui_path
1033
-
1034
- parser = argparse.ArgumentParser(description=f"llms v{VERSION}")
1035
- parser.add_argument('--config', default=None, help='Path to config file', metavar='FILE')
1036
- parser.add_argument('-m', '--model', default=None, help='Model to use')
1037
-
1038
- parser.add_argument('--chat', default=None, help='OpenAI Chat Completion Request to send', metavar='REQUEST')
1039
- parser.add_argument('-s', '--system', default=None, help='System prompt to use for chat completion', metavar='PROMPT')
1040
- parser.add_argument('--image', default=None, help='Image input to use in chat completion')
1041
- parser.add_argument('--audio', default=None, help='Audio input to use in chat completion')
1042
- parser.add_argument('--file', default=None, help='File input to use in chat completion')
1043
- parser.add_argument('--args', default=None, help='URL-encoded parameters to add to chat request (e.g. "temperature=0.7&seed=111")', metavar='PARAMS')
1044
- parser.add_argument('--raw', action='store_true', help='Return raw AI JSON response')
1045
-
1046
- parser.add_argument('--list', action='store_true', help='Show list of enabled providers and their models (alias ls provider?)')
1047
-
1048
- parser.add_argument('--serve', default=None, help='Port to start an OpenAI Chat compatible server on', metavar='PORT')
1049
-
1050
- parser.add_argument('--enable', default=None, help='Enable a provider', metavar='PROVIDER')
1051
- parser.add_argument('--disable', default=None, help='Disable a provider', metavar='PROVIDER')
1052
- parser.add_argument('--default', default=None, help='Configure the default model to use', metavar='MODEL')
1053
-
1054
- parser.add_argument('--init', action='store_true', help='Create a default llms.json')
1055
-
1056
- parser.add_argument('--root', default=None, help='Change root directory for UI files', metavar='PATH')
1057
- parser.add_argument('--logprefix', default="", help='Prefix used in log messages', metavar='PREFIX')
1058
- parser.add_argument('--verbose', action='store_true', help='Verbose output')
1059
- parser.add_argument('--update', action='store_true', help='Update to latest version')
1060
-
1061
- cli_args, extra_args = parser.parse_known_args()
1062
- if cli_args.verbose:
1063
- g_verbose = True
1064
- # printdump(cli_args)
1065
- if cli_args.model:
1066
- g_default_model = cli_args.model
1067
- if cli_args.logprefix:
1068
- g_logprefix = cli_args.logprefix
1069
-
1070
- if cli_args.config is not None:
1071
- g_config_path = os.path.join(os.path.dirname(__file__), cli_args.config)
1072
-
1073
- _ROOT = resolve_root()
1074
- if cli_args.root:
1075
- _ROOT = Path(cli_args.root)
1076
-
1077
- if not _ROOT:
1078
- print("Resource root not found")
1079
- exit(1)
1080
-
1081
- g_config_path = os.path.join(os.path.dirname(__file__), cli_args.config) if cli_args.config else get_config_path()
1082
- g_ui_path = get_ui_path()
1083
-
1084
- home_config_path = home_llms_path("llms.json")
1085
- resource_config_path = _ROOT / "llms.json"
1086
- home_ui_path = home_llms_path("ui.json")
1087
- resource_ui_path = _ROOT / "ui.json"
1088
-
1089
- if cli_args.init:
1090
- if os.path.exists(home_config_path):
1091
- print(f"llms.json already exists at {home_config_path}")
1092
- else:
1093
- asyncio.run(save_default_config(home_config_path))
1094
- print(f"Created default config at {home_config_path}")
1095
-
1096
- if os.path.exists(home_ui_path):
1097
- print(f"ui.json already exists at {home_ui_path}")
1098
- else:
1099
- asyncio.run(save_text(github_url("ui.json"), home_ui_path))
1100
- print(f"Created default ui config at {home_ui_path}")
1101
- exit(0)
1102
-
1103
- if not g_config_path or not os.path.exists(g_config_path):
1104
- # copy llms.json and ui.json to llms_home
1105
-
1106
- if not os.path.exists(home_config_path) and resource_exists(resource_config_path):
1107
- llms_home = os.path.dirname(home_config_path)
1108
- os.makedirs(llms_home, exist_ok=True)
1109
-
1110
- # Read config from resource (handle both Path and Traversable objects)
1111
- try:
1112
- config_json = read_resource_text(resource_config_path)
1113
- with open(home_config_path, "w") as f:
1114
- f.write(config_json)
1115
- _log(f"Created default config at {home_config_path}")
1116
- except (OSError, AttributeError) as e:
1117
- _log(f"Error reading resource config: {e}")
1118
-
1119
- # Read UI config from resource
1120
- if not os.path.exists(home_ui_path) and resource_exists(resource_ui_path):
1121
- try:
1122
- ui_json = read_resource_text(resource_ui_path)
1123
- with open(home_ui_path, "w") as f:
1124
- f.write(ui_json)
1125
- _log(f"Created default ui config at {home_ui_path}")
1126
- except (OSError, AttributeError) as e:
1127
- _log(f"Error reading resource ui config: {e}")
1128
-
1129
- # Update g_config_path to point to the copied file
1130
- g_config_path = home_config_path
1131
- else:
1132
- print("Config file not found. Create one with --init or use --config <path>")
1133
- exit(1)
1134
-
1135
- # read contents
1136
- with open(g_config_path, "r") as f:
1137
- config_json = f.read()
1138
- init_llms(json.loads(config_json))
1139
- asyncio.run(load_llms())
1140
-
1141
- # print names
1142
- _log(f"enabled providers: {', '.join(g_handlers.keys())}")
1143
-
1144
- filter_list = []
1145
- if len(extra_args) > 0:
1146
- arg = extra_args[0]
1147
- if arg == 'ls':
1148
- cli_args.list = True
1149
- if len(extra_args) > 1:
1150
- filter_list = extra_args[1:]
1151
-
1152
- if cli_args.list:
1153
- # Show list of enabled providers and their models
1154
- enabled = []
1155
- for name, provider in g_handlers.items():
1156
- if len(filter_list) > 0 and name not in filter_list:
1157
- continue
1158
- print(f"{name}:")
1159
- enabled.append(name)
1160
- for model in provider.models:
1161
- print(f" {model}")
1162
-
1163
- print_status()
1164
- exit(0)
1165
-
1166
- if cli_args.serve is not None:
1167
- port = int(cli_args.serve)
1168
-
1169
- app = web.Application()
1170
-
1171
- async def chat_handler(request):
1172
- try:
1173
- chat = await request.json()
1174
- response = await chat_completion(chat)
1175
- return web.json_response(response)
1176
- except Exception as e:
1177
- return web.json_response({"error": str(e)}, status=500)
1178
- app.router.add_post('/v1/chat/completions', chat_handler)
1179
-
1180
- async def models_handler(request):
1181
- return web.json_response(get_models())
1182
- app.router.add_get('/models', models_handler)
1183
-
1184
- async def status_handler(request):
1185
- enabled, disabled = provider_status()
1186
- return web.json_response({
1187
- "all": list(g_config['providers'].keys()),
1188
- "enabled": enabled,
1189
- "disabled": disabled,
1190
- })
1191
- app.router.add_get('/status', status_handler)
1192
-
1193
- async def provider_handler(request):
1194
- provider = request.match_info.get('provider', "")
1195
- data = await request.json()
1196
- msg = None
1197
- if provider:
1198
- if data.get('enable', False):
1199
- provider_config, msg = enable_provider(provider)
1200
- _log(f"Enabled provider {provider}")
1201
- await load_llms()
1202
- elif data.get('disable', False):
1203
- disable_provider(provider)
1204
- _log(f"Disabled provider {provider}")
1205
- enabled, disabled = provider_status()
1206
- return web.json_response({
1207
- "enabled": enabled,
1208
- "disabled": disabled,
1209
- "feedback": msg or "",
1210
- })
1211
- app.router.add_post('/providers/{provider}', provider_handler)
1212
-
1213
- async def ui_static(request: web.Request) -> web.Response:
1214
- path = Path(request.match_info["path"])
1215
-
1216
- try:
1217
- # Handle both Path objects and importlib.resources Traversable objects
1218
- if hasattr(_ROOT, 'joinpath'):
1219
- # importlib.resources Traversable
1220
- resource = _ROOT.joinpath("ui").joinpath(str(path))
1221
- if not resource.is_file():
1222
- raise web.HTTPNotFound
1223
- content = resource.read_bytes()
1224
- else:
1225
- # Regular Path object
1226
- resource = _ROOT / "ui" / path
1227
- if not resource.is_file():
1228
- raise web.HTTPNotFound
1229
- try:
1230
- resource.relative_to(Path(_ROOT)) # basic directory-traversal guard
1231
- except ValueError:
1232
- raise web.HTTPBadRequest(text="Invalid path")
1233
- content = resource.read_bytes()
1234
-
1235
- content_type, _ = mimetypes.guess_type(str(path))
1236
- if content_type is None:
1237
- content_type = "application/octet-stream"
1238
- return web.Response(body=content, content_type=content_type)
1239
- except (OSError, PermissionError, AttributeError):
1240
- raise web.HTTPNotFound
1241
-
1242
- app.router.add_get("/ui/{path:.*}", ui_static, name="ui_static")
1243
-
1244
- async def not_found_handler(request):
1245
- return web.Response(text="404: Not Found", status=404)
1246
- app.router.add_get('/favicon.ico', not_found_handler)
1247
-
1248
- # Serve index.html from root
1249
- async def index_handler(request):
1250
- index_content = read_resource_file_bytes("index.html")
1251
- if index_content is None:
1252
- raise web.HTTPNotFound
1253
- return web.Response(body=index_content, content_type='text/html')
1254
- app.router.add_get('/', index_handler)
1255
-
1256
- # Serve index.html as fallback route (SPA routing)
1257
- app.router.add_route('*', '/{tail:.*}', index_handler)
1258
-
1259
- if os.path.exists(g_ui_path):
1260
- async def ui_json_handler(request):
1261
- with open(g_ui_path, "r") as f:
1262
- ui = json.load(f)
1263
- if 'defaults' not in ui:
1264
- ui['defaults'] = g_config['defaults']
1265
- enabled, disabled = provider_status()
1266
- ui['status'] = {
1267
- "all": list(g_config['providers'].keys()),
1268
- "enabled": enabled,
1269
- "disabled": disabled
1270
- }
1271
- return web.json_response(ui)
1272
- app.router.add_get('/ui.json', ui_json_handler)
1273
-
1274
- print(f"Starting server on port {port}...")
1275
- web.run_app(app, host='0.0.0.0', port=port)
1276
- exit(0)
1277
-
1278
- if cli_args.enable is not None:
1279
- if cli_args.enable.endswith(','):
1280
- cli_args.enable = cli_args.enable[:-1].strip()
1281
- enable_providers = [cli_args.enable]
1282
- all_providers = g_config['providers'].keys()
1283
- msgs = []
1284
- if len(extra_args) > 0:
1285
- for arg in extra_args:
1286
- if arg.endswith(','):
1287
- arg = arg[:-1].strip()
1288
- if arg in all_providers:
1289
- enable_providers.append(arg)
1290
-
1291
- for provider in enable_providers:
1292
- if provider not in g_config['providers']:
1293
- print(f"Provider {provider} not found")
1294
- print(f"Available providers: {', '.join(g_config['providers'].keys())}")
1295
- exit(1)
1296
- if provider in g_config['providers']:
1297
- provider_config, msg = enable_provider(provider)
1298
- print(f"\nEnabled provider {provider}:")
1299
- printdump(provider_config)
1300
- if msg:
1301
- msgs.append(msg)
1302
-
1303
- print_status()
1304
- if len(msgs) > 0:
1305
- print("\n" + "\n".join(msgs))
1306
- exit(0)
1307
-
1308
- if cli_args.disable is not None:
1309
- if cli_args.disable.endswith(','):
1310
- cli_args.disable = cli_args.disable[:-1].strip()
1311
- disable_providers = [cli_args.disable]
1312
- all_providers = g_config['providers'].keys()
1313
- if len(extra_args) > 0:
1314
- for arg in extra_args:
1315
- if arg.endswith(','):
1316
- arg = arg[:-1].strip()
1317
- if arg in all_providers:
1318
- disable_providers.append(arg)
1319
-
1320
- for provider in disable_providers:
1321
- if provider not in g_config['providers']:
1322
- print(f"Provider {provider} not found")
1323
- print(f"Available providers: {', '.join(g_config['providers'].keys())}")
1324
- exit(1)
1325
- disable_provider(provider)
1326
- print(f"\nDisabled provider {provider}")
1327
-
1328
- print_status()
1329
- exit(0)
1330
-
1331
- if cli_args.default is not None:
1332
- default_model = cli_args.default
1333
- all_models = get_models()
1334
- if default_model not in all_models:
1335
- print(f"Model {default_model} not found")
1336
- print(f"Available models: {', '.join(all_models)}")
1337
- exit(1)
1338
- default_text = g_config['defaults']['text']
1339
- default_text['model'] = default_model
1340
- save_config(g_config)
1341
- print(f"\nDefault model set to: {default_model}")
1342
- exit(0)
1343
-
1344
- if cli_args.update:
1345
- asyncio.run(update_llms())
1346
- print(f"{__file__} updated")
1347
- exit(0)
1348
-
1349
- 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:
1350
- try:
1351
- chat = g_config['defaults']['text']
1352
- if cli_args.image is not None:
1353
- chat = g_config['defaults']['image']
1354
- elif cli_args.audio is not None:
1355
- chat = g_config['defaults']['audio']
1356
- elif cli_args.file is not None:
1357
- chat = g_config['defaults']['file']
1358
- if cli_args.chat is not None:
1359
- chat_path = os.path.join(os.path.dirname(__file__), cli_args.chat)
1360
- if not os.path.exists(chat_path):
1361
- print(f"Chat request template not found: {chat_path}")
1362
- exit(1)
1363
- _log(f"Using chat: {chat_path}")
1364
-
1365
- with open (chat_path, "r") as f:
1366
- chat_json = f.read()
1367
- chat = json.loads(chat_json)
1368
-
1369
- if cli_args.system is not None:
1370
- chat['messages'].insert(0, {'role': 'system', 'content': cli_args.system})
1371
-
1372
- if len(extra_args) > 0:
1373
- prompt = ' '.join(extra_args)
1374
- # replace content of last message if exists, else add
1375
- last_msg = chat['messages'][-1] if 'messages' in chat else None
1376
- if last_msg and last_msg['role'] == 'user':
1377
- if isinstance(last_msg['content'], list):
1378
- last_msg['content'][-1]['text'] = prompt
1379
- else:
1380
- last_msg['content'] = prompt
1381
- else:
1382
- chat['messages'].append({'role': 'user', 'content': prompt})
1383
-
1384
- # Parse args parameters if provided
1385
- args = None
1386
- if cli_args.args is not None:
1387
- args = parse_args_params(cli_args.args)
1388
-
1389
- asyncio.run(cli_chat(chat, image=cli_args.image, audio=cli_args.audio, file=cli_args.file, args=args, raw=cli_args.raw))
1390
- exit(0)
1391
- except Exception as e:
1392
- print(f"{cli_args.logprefix}Error: {e}")
1393
- if cli_args.verbose:
1394
- traceback.print_exc()
1395
- exit(1)
1396
-
1397
- # show usage from ArgumentParser
1398
- parser.print_help()
1399
-
1400
-
1401
- if __name__ == "__main__":
1402
- main()