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.
- llms/__init__.py +4 -0
- llms/__main__.py +9 -0
- llms/db.py +359 -0
- llms/extensions/analytics/ui/index.mjs +1444 -0
- llms/extensions/app/README.md +20 -0
- llms/extensions/app/__init__.py +589 -0
- llms/extensions/app/db.py +536 -0
- {llms_py-2.0.9.data/data → llms/extensions/app}/ui/Recents.mjs +100 -73
- llms_py-2.0.9.data/data/ui/Sidebar.mjs → llms/extensions/app/ui/index.mjs +150 -79
- llms/extensions/app/ui/threadStore.mjs +433 -0
- llms/extensions/core_tools/CALCULATOR.md +32 -0
- llms/extensions/core_tools/__init__.py +637 -0
- llms/extensions/core_tools/ui/codemirror/addon/edit/closebrackets.js +201 -0
- llms/extensions/core_tools/ui/codemirror/addon/edit/closetag.js +185 -0
- llms/extensions/core_tools/ui/codemirror/addon/edit/continuelist.js +101 -0
- llms/extensions/core_tools/ui/codemirror/addon/edit/matchbrackets.js +160 -0
- llms/extensions/core_tools/ui/codemirror/addon/edit/matchtags.js +66 -0
- llms/extensions/core_tools/ui/codemirror/addon/edit/trailingspace.js +27 -0
- llms/extensions/core_tools/ui/codemirror/addon/selection/active-line.js +72 -0
- llms/extensions/core_tools/ui/codemirror/addon/selection/mark-selection.js +119 -0
- llms/extensions/core_tools/ui/codemirror/addon/selection/selection-pointer.js +98 -0
- llms/extensions/core_tools/ui/codemirror/codemirror.css +344 -0
- llms/extensions/core_tools/ui/codemirror/codemirror.js +9884 -0
- llms/extensions/core_tools/ui/codemirror/doc/docs.css +225 -0
- llms/extensions/core_tools/ui/codemirror/doc/source_sans.woff +0 -0
- llms/extensions/core_tools/ui/codemirror/mode/clike/clike.js +942 -0
- llms/extensions/core_tools/ui/codemirror/mode/javascript/index.html +118 -0
- llms/extensions/core_tools/ui/codemirror/mode/javascript/javascript.js +962 -0
- llms/extensions/core_tools/ui/codemirror/mode/javascript/typescript.html +62 -0
- llms/extensions/core_tools/ui/codemirror/mode/python/python.js +402 -0
- llms/extensions/core_tools/ui/codemirror/theme/dracula.css +40 -0
- llms/extensions/core_tools/ui/codemirror/theme/mocha.css +135 -0
- llms/extensions/core_tools/ui/index.mjs +650 -0
- llms/extensions/gallery/README.md +61 -0
- llms/extensions/gallery/__init__.py +63 -0
- llms/extensions/gallery/db.py +243 -0
- llms/extensions/gallery/ui/index.mjs +482 -0
- llms/extensions/katex/README.md +39 -0
- llms/extensions/katex/__init__.py +6 -0
- llms/extensions/katex/ui/README.md +125 -0
- llms/extensions/katex/ui/contrib/auto-render.js +338 -0
- llms/extensions/katex/ui/contrib/auto-render.min.js +1 -0
- llms/extensions/katex/ui/contrib/auto-render.mjs +244 -0
- llms/extensions/katex/ui/contrib/copy-tex.js +127 -0
- llms/extensions/katex/ui/contrib/copy-tex.min.js +1 -0
- llms/extensions/katex/ui/contrib/copy-tex.mjs +105 -0
- llms/extensions/katex/ui/contrib/mathtex-script-type.js +109 -0
- llms/extensions/katex/ui/contrib/mathtex-script-type.min.js +1 -0
- llms/extensions/katex/ui/contrib/mathtex-script-type.mjs +24 -0
- llms/extensions/katex/ui/contrib/mhchem.js +3213 -0
- llms/extensions/katex/ui/contrib/mhchem.min.js +1 -0
- llms/extensions/katex/ui/contrib/mhchem.mjs +3109 -0
- llms/extensions/katex/ui/contrib/render-a11y-string.js +887 -0
- llms/extensions/katex/ui/contrib/render-a11y-string.min.js +1 -0
- llms/extensions/katex/ui/contrib/render-a11y-string.mjs +800 -0
- llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.ttf +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.woff +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.woff +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.woff +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.ttf +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.woff +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.woff2 +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.woff +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.ttf +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.woff +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.woff2 +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.ttf +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.woff +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.woff2 +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.woff +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.ttf +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.woff +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.woff2 +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.woff +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.woff +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.woff +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.ttf +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.woff +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.woff2 +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.ttf +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.woff +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.ttf +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.woff +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.ttf +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.woff +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.ttf +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.woff +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.woff +0 -0
- llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- llms/extensions/katex/ui/index.mjs +92 -0
- llms/extensions/katex/ui/katex-swap.css +1230 -0
- llms/extensions/katex/ui/katex-swap.min.css +1 -0
- llms/extensions/katex/ui/katex.css +1230 -0
- llms/extensions/katex/ui/katex.js +19080 -0
- llms/extensions/katex/ui/katex.min.css +1 -0
- llms/extensions/katex/ui/katex.min.js +1 -0
- llms/extensions/katex/ui/katex.min.mjs +1 -0
- llms/extensions/katex/ui/katex.mjs +18547 -0
- llms/extensions/providers/__init__.py +22 -0
- llms/extensions/providers/anthropic.py +233 -0
- llms/extensions/providers/cerebras.py +37 -0
- llms/extensions/providers/chutes.py +153 -0
- llms/extensions/providers/google.py +481 -0
- llms/extensions/providers/nvidia.py +103 -0
- llms/extensions/providers/openai.py +154 -0
- llms/extensions/providers/openrouter.py +74 -0
- llms/extensions/providers/zai.py +182 -0
- llms/extensions/system_prompts/README.md +22 -0
- llms/extensions/system_prompts/__init__.py +45 -0
- llms/extensions/system_prompts/ui/index.mjs +280 -0
- llms/extensions/system_prompts/ui/prompts.json +1067 -0
- llms/extensions/tools/__init__.py +144 -0
- llms/extensions/tools/ui/index.mjs +706 -0
- llms/index.html +58 -0
- llms/llms.json +400 -0
- llms/main.py +4407 -0
- llms/providers-extra.json +394 -0
- llms/providers.json +1 -0
- llms/ui/App.mjs +188 -0
- llms/ui/ai.mjs +217 -0
- llms/ui/app.css +7081 -0
- llms/ui/ctx.mjs +412 -0
- llms/ui/index.mjs +131 -0
- llms/ui/lib/chart.js +14 -0
- llms/ui/lib/charts.mjs +16 -0
- llms/ui/lib/color.js +14 -0
- llms/ui/lib/servicestack-vue.mjs +37 -0
- llms/ui/lib/vue.min.mjs +13 -0
- llms/ui/lib/vue.mjs +18530 -0
- {llms_py-2.0.9.data/data → llms}/ui/markdown.mjs +33 -15
- llms/ui/modules/chat/ChatBody.mjs +976 -0
- llms/ui/modules/chat/SettingsDialog.mjs +374 -0
- llms/ui/modules/chat/index.mjs +991 -0
- llms/ui/modules/icons.mjs +46 -0
- llms/ui/modules/layout.mjs +271 -0
- llms/ui/modules/model-selector.mjs +811 -0
- llms/ui/tailwind.input.css +742 -0
- {llms_py-2.0.9.data/data → llms}/ui/typography.css +133 -7
- llms/ui/utils.mjs +261 -0
- llms_py-3.0.10.dist-info/METADATA +49 -0
- llms_py-3.0.10.dist-info/RECORD +177 -0
- llms_py-3.0.10.dist-info/entry_points.txt +2 -0
- {llms_py-2.0.9.dist-info → llms_py-3.0.10.dist-info}/licenses/LICENSE +1 -2
- llms.py +0 -1402
- llms_py-2.0.9.data/data/index.html +0 -64
- llms_py-2.0.9.data/data/llms.json +0 -447
- llms_py-2.0.9.data/data/requirements.txt +0 -1
- llms_py-2.0.9.data/data/ui/App.mjs +0 -20
- llms_py-2.0.9.data/data/ui/ChatPrompt.mjs +0 -389
- llms_py-2.0.9.data/data/ui/Main.mjs +0 -680
- llms_py-2.0.9.data/data/ui/app.css +0 -3951
- llms_py-2.0.9.data/data/ui/lib/servicestack-vue.min.mjs +0 -37
- llms_py-2.0.9.data/data/ui/lib/vue.min.mjs +0 -12
- llms_py-2.0.9.data/data/ui/tailwind.input.css +0 -261
- llms_py-2.0.9.data/data/ui/threadStore.mjs +0 -273
- llms_py-2.0.9.data/data/ui/utils.mjs +0 -114
- llms_py-2.0.9.data/data/ui.json +0 -1069
- llms_py-2.0.9.dist-info/METADATA +0 -941
- llms_py-2.0.9.dist-info/RECORD +0 -30
- llms_py-2.0.9.dist-info/entry_points.txt +0 -2
- {llms_py-2.0.9.data/data → llms}/ui/fav.svg +0 -0
- {llms_py-2.0.9.data/data → llms}/ui/lib/highlight.min.mjs +0 -0
- {llms_py-2.0.9.data/data → llms}/ui/lib/idb.min.mjs +0 -0
- {llms_py-2.0.9.data/data → llms}/ui/lib/marked.min.mjs +0 -0
- /llms_py-2.0.9.data/data/ui/lib/servicestack-client.min.mjs → /llms/ui/lib/servicestack-client.mjs +0 -0
- {llms_py-2.0.9.data/data → llms}/ui/lib/vue-router.min.mjs +0 -0
- {llms_py-2.0.9.dist-info → llms_py-3.0.10.dist-info}/WHEEL +0 -0
- {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()
|