llms-py 3.0.6__py3-none-any.whl → 3.0.8__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/extensions/analytics/ui/index.mjs +1 -1
- llms/extensions/app/__init__.py +3 -1
- llms/extensions/app/ui/Recents.mjs +1 -1
- llms/extensions/app/ui/threadStore.mjs +4 -3
- llms/extensions/core_tools/__init__.py +14 -12
- llms/extensions/providers/__init__.py +2 -0
- llms/extensions/providers/anthropic.py +1 -1
- llms/extensions/providers/chutes.py +7 -9
- llms/extensions/providers/google.py +3 -3
- llms/extensions/providers/nvidia.py +9 -11
- llms/extensions/providers/openai.py +1 -3
- llms/extensions/providers/zai.py +182 -0
- llms/extensions/tools/__init__.py +140 -1
- llms/extensions/tools/ui/index.mjs +552 -50
- llms/llms.json +14 -2
- llms/main.py +461 -99
- llms/providers-extra.json +38 -0
- llms/providers.json +1 -1
- llms/ui/App.mjs +1 -1
- llms/ui/ai.mjs +1 -1
- llms/ui/app.css +287 -18
- llms/ui/ctx.mjs +16 -3
- llms/ui/index.mjs +1 -1
- llms/ui/modules/chat/ChatBody.mjs +384 -107
- llms/ui/modules/chat/index.mjs +18 -4
- llms/ui/tailwind.input.css +54 -0
- llms/ui/utils.mjs +33 -4
- {llms_py-3.0.6.dist-info → llms_py-3.0.8.dist-info}/METADATA +1 -1
- {llms_py-3.0.6.dist-info → llms_py-3.0.8.dist-info}/RECORD +33 -32
- {llms_py-3.0.6.dist-info → llms_py-3.0.8.dist-info}/WHEEL +0 -0
- {llms_py-3.0.6.dist-info → llms_py-3.0.8.dist-info}/entry_points.txt +0 -0
- {llms_py-3.0.6.dist-info → llms_py-3.0.8.dist-info}/licenses/LICENSE +0 -0
- {llms_py-3.0.6.dist-info → llms_py-3.0.8.dist-info}/top_level.txt +0 -0
llms/main.py
CHANGED
|
@@ -28,7 +28,7 @@ from datetime import datetime
|
|
|
28
28
|
from importlib import resources # Py≥3.9 (pip install importlib_resources for 3.7/3.8)
|
|
29
29
|
from io import BytesIO
|
|
30
30
|
from pathlib import Path
|
|
31
|
-
from typing import get_type_hints
|
|
31
|
+
from typing import Optional, get_type_hints
|
|
32
32
|
from urllib.parse import parse_qs, urlencode, urljoin
|
|
33
33
|
|
|
34
34
|
import aiohttp
|
|
@@ -41,7 +41,7 @@ try:
|
|
|
41
41
|
except ImportError:
|
|
42
42
|
HAS_PIL = False
|
|
43
43
|
|
|
44
|
-
VERSION = "3.0.
|
|
44
|
+
VERSION = "3.0.8"
|
|
45
45
|
_ROOT = None
|
|
46
46
|
DEBUG = os.getenv("DEBUG") == "1"
|
|
47
47
|
MOCK = os.getenv("MOCK") == "1"
|
|
@@ -211,8 +211,8 @@ def pluralize(word, count):
|
|
|
211
211
|
|
|
212
212
|
|
|
213
213
|
def get_file_mime_type(filename):
|
|
214
|
-
|
|
215
|
-
return
|
|
214
|
+
mimetype, _ = mimetypes.guess_type(filename)
|
|
215
|
+
return mimetype or "application/octet-stream"
|
|
216
216
|
|
|
217
217
|
|
|
218
218
|
def price_to_string(price: float | int | str | None) -> str | None:
|
|
@@ -369,6 +369,75 @@ def function_to_tool_definition(func):
|
|
|
369
369
|
}
|
|
370
370
|
|
|
371
371
|
|
|
372
|
+
async def download_file(url):
|
|
373
|
+
async with aiohttp.ClientSession() as session:
|
|
374
|
+
return await session_download_file(session, url)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
async def session_download_file(session, url, default_mimetype="application/octet-stream"):
|
|
378
|
+
try:
|
|
379
|
+
async with session.get(url, timeout=aiohttp.ClientTimeout(total=120)) as response:
|
|
380
|
+
response.raise_for_status()
|
|
381
|
+
content = await response.read()
|
|
382
|
+
mimetype = response.headers.get("Content-Type")
|
|
383
|
+
disposition = response.headers.get("Content-Disposition")
|
|
384
|
+
if mimetype and ";" in mimetype:
|
|
385
|
+
mimetype = mimetype.split(";")[0]
|
|
386
|
+
ext = None
|
|
387
|
+
if disposition:
|
|
388
|
+
start = disposition.index('filename="') + len('filename="')
|
|
389
|
+
end = disposition.index('"', start)
|
|
390
|
+
filename = disposition[start:end]
|
|
391
|
+
if not mimetype:
|
|
392
|
+
mimetype = mimetypes.guess_type(filename)[0] or default_mimetype
|
|
393
|
+
else:
|
|
394
|
+
filename = url.split("/")[-1]
|
|
395
|
+
if "." not in filename:
|
|
396
|
+
if mimetype is None:
|
|
397
|
+
mimetype = default_mimetype
|
|
398
|
+
ext = mimetypes.guess_extension(mimetype) or mimetype.split("/")[1]
|
|
399
|
+
filename = f"{filename}.{ext}"
|
|
400
|
+
|
|
401
|
+
if not ext:
|
|
402
|
+
ext = Path(filename).suffix.lstrip(".")
|
|
403
|
+
|
|
404
|
+
info = {
|
|
405
|
+
"url": url,
|
|
406
|
+
"type": mimetype,
|
|
407
|
+
"name": filename,
|
|
408
|
+
"ext": ext,
|
|
409
|
+
}
|
|
410
|
+
return content, info
|
|
411
|
+
except Exception as e:
|
|
412
|
+
_err(f"Error downloading file: {url}", e)
|
|
413
|
+
raise e
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def read_binary_file(url):
|
|
417
|
+
try:
|
|
418
|
+
path = Path(url)
|
|
419
|
+
with open(url, "rb") as f:
|
|
420
|
+
content = f.read()
|
|
421
|
+
info_path = path.stem + ".info.json"
|
|
422
|
+
if os.path.exists(info_path):
|
|
423
|
+
with open(info_path) as f_info:
|
|
424
|
+
info = json.load(f_info)
|
|
425
|
+
return content, info
|
|
426
|
+
|
|
427
|
+
stat = path.stat()
|
|
428
|
+
info = {
|
|
429
|
+
"date": int(stat.st_mtime),
|
|
430
|
+
"name": path.name,
|
|
431
|
+
"ext": path.suffix.lstrip("."),
|
|
432
|
+
"type": mimetypes.guess_type(path.name)[0],
|
|
433
|
+
"url": f"/~cache/{path.name[:2]}/{path.name}",
|
|
434
|
+
}
|
|
435
|
+
return content, info
|
|
436
|
+
except Exception as e:
|
|
437
|
+
_err(f"Error reading file: {url}", e)
|
|
438
|
+
raise e
|
|
439
|
+
|
|
440
|
+
|
|
372
441
|
async def process_chat(chat, provider_id=None):
|
|
373
442
|
if not chat:
|
|
374
443
|
raise Exception("No chat provided")
|
|
@@ -397,31 +466,20 @@ async def process_chat(chat, provider_id=None):
|
|
|
397
466
|
url = get_cache_path(url[8:])
|
|
398
467
|
if is_url(url):
|
|
399
468
|
_log(f"Downloading image: {url}")
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
mimetype = response.headers["Content-Type"]
|
|
407
|
-
# convert/resize image if needed
|
|
408
|
-
content, mimetype = convert_image_if_needed(content, mimetype)
|
|
409
|
-
# convert to data uri
|
|
410
|
-
image_url["url"] = (
|
|
411
|
-
f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}"
|
|
412
|
-
)
|
|
469
|
+
content, info = await session_download_file(session, url, default_mimetype="image/png")
|
|
470
|
+
mimetype = info["type"]
|
|
471
|
+
# convert/resize image if needed
|
|
472
|
+
content, mimetype = convert_image_if_needed(content, mimetype)
|
|
473
|
+
# convert to data uri
|
|
474
|
+
image_url["url"] = f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}"
|
|
413
475
|
elif is_file_path(url):
|
|
414
476
|
_log(f"Reading image: {url}")
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
# convert to data uri
|
|
422
|
-
image_url["url"] = (
|
|
423
|
-
f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}"
|
|
424
|
-
)
|
|
477
|
+
content, info = read_binary_file(url)
|
|
478
|
+
mimetype = info["type"]
|
|
479
|
+
# convert/resize image if needed
|
|
480
|
+
content, mimetype = convert_image_if_needed(content, mimetype)
|
|
481
|
+
# convert to data uri
|
|
482
|
+
image_url["url"] = f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}"
|
|
425
483
|
elif url.startswith("data:"):
|
|
426
484
|
# Extract existing data URI and process it
|
|
427
485
|
if ";base64," in url:
|
|
@@ -443,29 +501,24 @@ async def process_chat(chat, provider_id=None):
|
|
|
443
501
|
url = input_audio["data"]
|
|
444
502
|
if url.startswith("/~cache/"):
|
|
445
503
|
url = get_cache_path(url[8:])
|
|
446
|
-
mimetype = get_file_mime_type(get_filename(url))
|
|
447
504
|
if is_url(url):
|
|
448
505
|
_log(f"Downloading audio: {url}")
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
input_audio["data"] = base64.b64encode(content).decode("utf-8")
|
|
457
|
-
if provider_id == "alibaba":
|
|
458
|
-
input_audio["data"] = f"data:{mimetype};base64,{input_audio['data']}"
|
|
459
|
-
input_audio["format"] = mimetype.rsplit("/", 1)[1]
|
|
506
|
+
content, info = await session_download_file(session, url, default_mimetype="audio/mp3")
|
|
507
|
+
mimetype = info["type"]
|
|
508
|
+
# convert to base64
|
|
509
|
+
input_audio["data"] = base64.b64encode(content).decode("utf-8")
|
|
510
|
+
if provider_id == "alibaba":
|
|
511
|
+
input_audio["data"] = f"data:{mimetype};base64,{input_audio['data']}"
|
|
512
|
+
input_audio["format"] = mimetype.rsplit("/", 1)[1]
|
|
460
513
|
elif is_file_path(url):
|
|
461
514
|
_log(f"Reading audio: {url}")
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
515
|
+
content, info = read_binary_file(url)
|
|
516
|
+
mimetype = info["type"]
|
|
517
|
+
# convert to base64
|
|
518
|
+
input_audio["data"] = base64.b64encode(content).decode("utf-8")
|
|
519
|
+
if provider_id == "alibaba":
|
|
520
|
+
input_audio["data"] = f"data:{mimetype};base64,{input_audio['data']}"
|
|
521
|
+
input_audio["format"] = mimetype.rsplit("/", 1)[1]
|
|
469
522
|
elif is_base_64(url):
|
|
470
523
|
pass # use base64 data as-is
|
|
471
524
|
else:
|
|
@@ -476,24 +529,24 @@ async def process_chat(chat, provider_id=None):
|
|
|
476
529
|
url = file["file_data"]
|
|
477
530
|
if url.startswith("/~cache/"):
|
|
478
531
|
url = get_cache_path(url[8:])
|
|
479
|
-
mimetype = get_file_mime_type(get_filename(url))
|
|
480
532
|
if is_url(url):
|
|
481
533
|
_log(f"Downloading file: {url}")
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
)
|
|
534
|
+
content, info = await session_download_file(
|
|
535
|
+
session, url, default_mimetype="application/pdf"
|
|
536
|
+
)
|
|
537
|
+
mimetype = info["type"]
|
|
538
|
+
file["filename"] = info["name"]
|
|
539
|
+
file["file_data"] = (
|
|
540
|
+
f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}"
|
|
541
|
+
)
|
|
489
542
|
elif is_file_path(url):
|
|
490
543
|
_log(f"Reading file: {url}")
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
544
|
+
content, info = read_binary_file(url)
|
|
545
|
+
mimetype = info["type"]
|
|
546
|
+
file["filename"] = info["name"]
|
|
547
|
+
file["file_data"] = (
|
|
548
|
+
f"data:{mimetype};base64,{base64.b64encode(content).decode('utf-8')}"
|
|
549
|
+
)
|
|
497
550
|
elif url.startswith("data:"):
|
|
498
551
|
if "filename" not in file:
|
|
499
552
|
file["filename"] = "file"
|
|
@@ -583,8 +636,9 @@ def cache_message_inline_data(m):
|
|
|
583
636
|
ext = file_ext_from_mimetype(mimetype)
|
|
584
637
|
filename = f"{filename}.{ext}"
|
|
585
638
|
|
|
586
|
-
cache_url,
|
|
639
|
+
cache_url, info = save_bytes_to_cache(base64_data, filename)
|
|
587
640
|
file_info["file_data"] = cache_url
|
|
641
|
+
file_info["filename"] = info["name"]
|
|
588
642
|
except Exception as e:
|
|
589
643
|
_log(f"Error caching inline file: {e}")
|
|
590
644
|
|
|
@@ -598,7 +652,7 @@ class HTTPError(Exception):
|
|
|
598
652
|
super().__init__(f"HTTP {status} {reason}")
|
|
599
653
|
|
|
600
654
|
|
|
601
|
-
def save_bytes_to_cache(base64_data, filename, file_info, ignore_info=False):
|
|
655
|
+
def save_bytes_to_cache(base64_data, filename, file_info=None, ignore_info=False):
|
|
602
656
|
ext = filename.split(".")[-1]
|
|
603
657
|
mimetype = get_file_mime_type(filename)
|
|
604
658
|
content = base64.b64decode(base64_data) if isinstance(base64_data, str) else base64_data
|
|
@@ -631,7 +685,8 @@ def save_bytes_to_cache(base64_data, filename, file_info, ignore_info=False):
|
|
|
631
685
|
"type": mimetype,
|
|
632
686
|
"name": filename,
|
|
633
687
|
}
|
|
634
|
-
|
|
688
|
+
if file_info:
|
|
689
|
+
info.update(file_info)
|
|
635
690
|
|
|
636
691
|
# Save metadata
|
|
637
692
|
info_path = os.path.splitext(full_path)[0] + ".info.json"
|
|
@@ -645,6 +700,14 @@ def save_bytes_to_cache(base64_data, filename, file_info, ignore_info=False):
|
|
|
645
700
|
return url, info
|
|
646
701
|
|
|
647
702
|
|
|
703
|
+
def save_audio_to_cache(base64_data, filename, audio_info, ignore_info=False):
|
|
704
|
+
return save_bytes_to_cache(base64_data, filename, audio_info, ignore_info)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def save_video_to_cache(base64_data, filename, file_info, ignore_info=False):
|
|
708
|
+
return save_bytes_to_cache(base64_data, filename, file_info, ignore_info)
|
|
709
|
+
|
|
710
|
+
|
|
648
711
|
def save_image_to_cache(base64_data, filename, image_info, ignore_info=False):
|
|
649
712
|
ext = filename.split(".")[-1]
|
|
650
713
|
mimetype = get_file_mime_type(filename)
|
|
@@ -757,6 +820,12 @@ def chat_to_username(chat):
|
|
|
757
820
|
return None
|
|
758
821
|
|
|
759
822
|
|
|
823
|
+
def chat_to_aspect_ratio(chat):
|
|
824
|
+
if "image_config" in chat and "aspect_ratio" in chat["image_config"]:
|
|
825
|
+
return chat["image_config"]["aspect_ratio"]
|
|
826
|
+
return None
|
|
827
|
+
|
|
828
|
+
|
|
760
829
|
def last_user_prompt(chat):
|
|
761
830
|
prompt = ""
|
|
762
831
|
if "messages" in chat:
|
|
@@ -1342,6 +1411,162 @@ def g_chat_request(template=None, text=None, model=None, system_prompt=None):
|
|
|
1342
1411
|
return chat
|
|
1343
1412
|
|
|
1344
1413
|
|
|
1414
|
+
def tool_result_part(result: dict, function_name: Optional[str] = None, function_args: Optional[dict] = None):
|
|
1415
|
+
args = function_args or {}
|
|
1416
|
+
type = result.get("type")
|
|
1417
|
+
prompt = args.get("prompt") or args.get("text") or args.get("message")
|
|
1418
|
+
if type == "text":
|
|
1419
|
+
return result.get("text"), None
|
|
1420
|
+
elif type == "image":
|
|
1421
|
+
format = result.get("format") or args.get("format") or "png"
|
|
1422
|
+
filename = result.get("filename") or args.get("filename") or f"{function_name}-{int(time.time())}.{format}"
|
|
1423
|
+
mime_type = get_file_mime_type(filename)
|
|
1424
|
+
image_info = {"type": mime_type}
|
|
1425
|
+
if prompt:
|
|
1426
|
+
image_info["prompt"] = prompt
|
|
1427
|
+
if "model" in args:
|
|
1428
|
+
image_info["model"] = args["model"]
|
|
1429
|
+
if "aspect_ratio" in args:
|
|
1430
|
+
image_info["aspect_ratio"] = args["aspect_ratio"]
|
|
1431
|
+
base64_data = result.get("data")
|
|
1432
|
+
if not base64_data:
|
|
1433
|
+
_dbg(f"Image data not found for {function_name}")
|
|
1434
|
+
return None, None
|
|
1435
|
+
url, _ = save_image_to_cache(base64_data, filename, image_info=image_info, ignore_info=True)
|
|
1436
|
+
resource = {
|
|
1437
|
+
"type": "image_url",
|
|
1438
|
+
"image_url": {
|
|
1439
|
+
"url": url,
|
|
1440
|
+
},
|
|
1441
|
+
}
|
|
1442
|
+
text = f"\n"
|
|
1443
|
+
return text, resource
|
|
1444
|
+
elif type == "audio":
|
|
1445
|
+
format = result.get("format") or args.get("format") or "mp3"
|
|
1446
|
+
filename = result.get("filename") or args.get("filename") or f"{function_name}-{int(time.time())}.{format}"
|
|
1447
|
+
mime_type = get_file_mime_type(filename)
|
|
1448
|
+
audio_info = {"type": mime_type}
|
|
1449
|
+
if prompt:
|
|
1450
|
+
audio_info["prompt"] = prompt
|
|
1451
|
+
if "model" in args:
|
|
1452
|
+
audio_info["model"] = args["model"]
|
|
1453
|
+
base64_data = result.get("data")
|
|
1454
|
+
if not base64_data:
|
|
1455
|
+
_dbg(f"Audio data not found for {function_name}")
|
|
1456
|
+
return None, None
|
|
1457
|
+
url, _ = save_audio_to_cache(base64_data, filename, audio_info=audio_info, ignore_info=True)
|
|
1458
|
+
resource = {
|
|
1459
|
+
"type": "audio_url",
|
|
1460
|
+
"audio_url": {
|
|
1461
|
+
"url": url,
|
|
1462
|
+
},
|
|
1463
|
+
}
|
|
1464
|
+
text = f"[{args.get('prompt') or filename}]({url})\n"
|
|
1465
|
+
return text, resource
|
|
1466
|
+
elif type == "file":
|
|
1467
|
+
filename = result.get("filename") or args.get("filename") or result.get("name") or args.get("name")
|
|
1468
|
+
format = result.get("format") or args.get("format") or (get_filename(filename) if filename else "txt")
|
|
1469
|
+
if not filename:
|
|
1470
|
+
filename = f"{function_name}-{int(time.time())}.{format}"
|
|
1471
|
+
|
|
1472
|
+
mime_type = get_file_mime_type(filename)
|
|
1473
|
+
file_info = {"type": mime_type}
|
|
1474
|
+
if prompt:
|
|
1475
|
+
file_info["prompt"] = prompt
|
|
1476
|
+
if "model" in args:
|
|
1477
|
+
file_info["model"] = args["model"]
|
|
1478
|
+
base64_data = result.get("data")
|
|
1479
|
+
if not base64_data:
|
|
1480
|
+
_dbg(f"File data not found for {function_name}")
|
|
1481
|
+
return None, None
|
|
1482
|
+
url, info = save_bytes_to_cache(base64_data, filename, file_info=file_info)
|
|
1483
|
+
resource = {
|
|
1484
|
+
"type": "file",
|
|
1485
|
+
"file": {
|
|
1486
|
+
"file_data": url,
|
|
1487
|
+
"filename": info["name"],
|
|
1488
|
+
},
|
|
1489
|
+
}
|
|
1490
|
+
text = f"[{args.get('prompt') or filename}]({url})\n"
|
|
1491
|
+
return text, resource
|
|
1492
|
+
else:
|
|
1493
|
+
try:
|
|
1494
|
+
return json.dumps(result), None
|
|
1495
|
+
except Exception as e:
|
|
1496
|
+
_dbg(f"Error converting result to JSON: {e}")
|
|
1497
|
+
try:
|
|
1498
|
+
return str(result), None
|
|
1499
|
+
except Exception as e:
|
|
1500
|
+
_dbg(f"Error converting result to string: {e}")
|
|
1501
|
+
return None, None
|
|
1502
|
+
|
|
1503
|
+
|
|
1504
|
+
def g_tool_result(result, function_name: Optional[str] = None, function_args: Optional[dict] = None):
|
|
1505
|
+
content = []
|
|
1506
|
+
resources = []
|
|
1507
|
+
args = function_args or {}
|
|
1508
|
+
if isinstance(result, dict):
|
|
1509
|
+
text, res = tool_result_part(result, function_name, args)
|
|
1510
|
+
if text:
|
|
1511
|
+
content.append(text)
|
|
1512
|
+
if res:
|
|
1513
|
+
resources.append(res)
|
|
1514
|
+
elif isinstance(result, list):
|
|
1515
|
+
for item in result:
|
|
1516
|
+
text, res = tool_result_part(item, function_name, args)
|
|
1517
|
+
if text:
|
|
1518
|
+
content.append(text)
|
|
1519
|
+
if res:
|
|
1520
|
+
resources.append(res)
|
|
1521
|
+
else:
|
|
1522
|
+
content = [str(result)]
|
|
1523
|
+
|
|
1524
|
+
text = "\n".join(content)
|
|
1525
|
+
return text, resources
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
async def g_exec_tool(function_name, function_args):
|
|
1529
|
+
if function_name in g_app.tools:
|
|
1530
|
+
try:
|
|
1531
|
+
func = g_app.tools[function_name]
|
|
1532
|
+
is_async = inspect.iscoroutinefunction(func)
|
|
1533
|
+
_dbg(f"Executing {'async' if is_async else 'sync'} tool '{function_name}' with args: {function_args}")
|
|
1534
|
+
if is_async:
|
|
1535
|
+
return g_tool_result(await func(**function_args), function_name, function_args)
|
|
1536
|
+
else:
|
|
1537
|
+
return g_tool_result(func(**function_args), function_name, function_args)
|
|
1538
|
+
except Exception as e:
|
|
1539
|
+
return f"Error executing tool '{function_name}': {to_error_message(e)}", None
|
|
1540
|
+
return f"Error: Tool '{function_name}' not found", None
|
|
1541
|
+
|
|
1542
|
+
|
|
1543
|
+
def group_resources(resources: list):
|
|
1544
|
+
"""
|
|
1545
|
+
converts list of parts into a grouped dictionary, e.g:
|
|
1546
|
+
[{"type: "image_url", "image_url": {"url": "/image.jpg"}}] =>
|
|
1547
|
+
{"images": [{"type": "image_url", "image_url": {"url": "/image.jpg"}}] }
|
|
1548
|
+
"""
|
|
1549
|
+
grouped = {}
|
|
1550
|
+
for res in resources:
|
|
1551
|
+
type = res.get("type")
|
|
1552
|
+
if not type:
|
|
1553
|
+
continue
|
|
1554
|
+
if type == "image_url":
|
|
1555
|
+
group = "images"
|
|
1556
|
+
elif type == "audio_url":
|
|
1557
|
+
group = "audios"
|
|
1558
|
+
elif type == "file_urls" or type == "file":
|
|
1559
|
+
group = "files"
|
|
1560
|
+
elif type == "text":
|
|
1561
|
+
group = "texts"
|
|
1562
|
+
else:
|
|
1563
|
+
group = "others"
|
|
1564
|
+
if group not in grouped:
|
|
1565
|
+
grouped[group] = []
|
|
1566
|
+
grouped[group].append(res)
|
|
1567
|
+
return grouped
|
|
1568
|
+
|
|
1569
|
+
|
|
1345
1570
|
async def g_chat_completion(chat, context=None):
|
|
1346
1571
|
try:
|
|
1347
1572
|
model = chat.get("model")
|
|
@@ -1439,21 +1664,15 @@ async def g_chat_completion(chat, context=None):
|
|
|
1439
1664
|
try:
|
|
1440
1665
|
function_args = json.loads(tool_call["function"]["arguments"])
|
|
1441
1666
|
except Exception as e:
|
|
1442
|
-
tool_result = f"Error
|
|
1667
|
+
tool_result = f"Error: Failed to parse JSON arguments for tool '{function_name}': {to_error_message(e)}"
|
|
1443
1668
|
else:
|
|
1444
|
-
tool_result =
|
|
1445
|
-
if function_name in g_app.tools:
|
|
1446
|
-
try:
|
|
1447
|
-
func = g_app.tools[function_name]
|
|
1448
|
-
if inspect.iscoroutinefunction(func):
|
|
1449
|
-
tool_result = await func(**function_args)
|
|
1450
|
-
else:
|
|
1451
|
-
tool_result = func(**function_args)
|
|
1452
|
-
except Exception as e:
|
|
1453
|
-
tool_result = f"Error executing tool {function_name}: {e}"
|
|
1669
|
+
tool_result, resources = await g_exec_tool(function_name, function_args)
|
|
1454
1670
|
|
|
1455
1671
|
# Append tool result to history
|
|
1456
1672
|
tool_msg = {"role": "tool", "tool_call_id": tool_call["id"], "content": to_content(tool_result)}
|
|
1673
|
+
|
|
1674
|
+
tool_msg.update(group_resources(resources))
|
|
1675
|
+
|
|
1457
1676
|
current_chat["messages"].append(tool_msg)
|
|
1458
1677
|
tool_history.append(tool_msg)
|
|
1459
1678
|
|
|
@@ -2327,6 +2546,8 @@ class AppExtensions:
|
|
|
2327
2546
|
self.error_auth_required = create_error_response("Authentication required", "Unauthorized")
|
|
2328
2547
|
self.ui_extensions = []
|
|
2329
2548
|
self.chat_request_filters = []
|
|
2549
|
+
self.extensions = []
|
|
2550
|
+
self.loaded = False
|
|
2330
2551
|
self.chat_tool_filters = []
|
|
2331
2552
|
self.chat_response_filters = []
|
|
2332
2553
|
self.chat_error_filters = []
|
|
@@ -2339,6 +2560,7 @@ class AppExtensions:
|
|
|
2339
2560
|
self.shutdown_handlers = []
|
|
2340
2561
|
self.tools = {}
|
|
2341
2562
|
self.tool_definitions = []
|
|
2563
|
+
self.tool_groups = {}
|
|
2342
2564
|
self.index_headers = []
|
|
2343
2565
|
self.index_footers = []
|
|
2344
2566
|
self.request_args = {
|
|
@@ -2539,8 +2761,8 @@ class ExtensionContext:
|
|
|
2539
2761
|
def to_file_info(self, chat, info=None, response=None):
|
|
2540
2762
|
return to_file_info(chat, info=info, response=response)
|
|
2541
2763
|
|
|
2542
|
-
def save_image_to_cache(self, base64_data, filename, image_info):
|
|
2543
|
-
return save_image_to_cache(base64_data, filename, image_info)
|
|
2764
|
+
def save_image_to_cache(self, base64_data, filename, image_info, ignore_info=False):
|
|
2765
|
+
return save_image_to_cache(base64_data, filename, image_info, ignore_info=ignore_info)
|
|
2544
2766
|
|
|
2545
2767
|
def save_bytes_to_cache(self, bytes_data, filename, file_info):
|
|
2546
2768
|
return save_bytes_to_cache(bytes_data, filename, file_info)
|
|
@@ -2551,6 +2773,15 @@ class ExtensionContext:
|
|
|
2551
2773
|
def json_from_file(self, path):
|
|
2552
2774
|
return json_from_file(path)
|
|
2553
2775
|
|
|
2776
|
+
def download_file(self, url):
|
|
2777
|
+
return download_file(url)
|
|
2778
|
+
|
|
2779
|
+
def session_download_file(self, session, url):
|
|
2780
|
+
return session_download_file(session, url)
|
|
2781
|
+
|
|
2782
|
+
def read_binary_file(self, url):
|
|
2783
|
+
return read_binary_file(url)
|
|
2784
|
+
|
|
2554
2785
|
def log(self, message):
|
|
2555
2786
|
if self.verbose:
|
|
2556
2787
|
print(f"[{self.name}] {message}", flush=True)
|
|
@@ -2621,25 +2852,25 @@ class ExtensionContext:
|
|
|
2621
2852
|
|
|
2622
2853
|
self.app.server_add_get.append((os.path.join(self.ext_prefix, "{path:.*}"), serve_static, {}))
|
|
2623
2854
|
|
|
2855
|
+
def web_path(self, method, path):
|
|
2856
|
+
full_path = os.path.join(self.ext_prefix, path) if path else self.ext_prefix
|
|
2857
|
+
self.dbg(f"Registered {method:<6} {full_path}")
|
|
2858
|
+
return full_path
|
|
2859
|
+
|
|
2624
2860
|
def add_get(self, path, handler, **kwargs):
|
|
2625
|
-
self.
|
|
2626
|
-
self.app.server_add_get.append((os.path.join(self.ext_prefix, path), handler, kwargs))
|
|
2861
|
+
self.app.server_add_get.append((self.web_path("GET", path), handler, kwargs))
|
|
2627
2862
|
|
|
2628
2863
|
def add_post(self, path, handler, **kwargs):
|
|
2629
|
-
self.
|
|
2630
|
-
self.app.server_add_post.append((os.path.join(self.ext_prefix, path), handler, kwargs))
|
|
2864
|
+
self.app.server_add_post.append((self.web_path("POST", path), handler, kwargs))
|
|
2631
2865
|
|
|
2632
2866
|
def add_put(self, path, handler, **kwargs):
|
|
2633
|
-
self.
|
|
2634
|
-
self.app.server_add_put.append((os.path.join(self.ext_prefix, path), handler, kwargs))
|
|
2867
|
+
self.app.server_add_put.append((self.web_path("PUT", path), handler, kwargs))
|
|
2635
2868
|
|
|
2636
2869
|
def add_delete(self, path, handler, **kwargs):
|
|
2637
|
-
self.
|
|
2638
|
-
self.app.server_add_delete.append((os.path.join(self.ext_prefix, path), handler, kwargs))
|
|
2870
|
+
self.app.server_add_delete.append((self.web_path("DELETE", path), handler, kwargs))
|
|
2639
2871
|
|
|
2640
2872
|
def add_patch(self, path, handler, **kwargs):
|
|
2641
|
-
self.
|
|
2642
|
-
self.app.server_add_patch.append((os.path.join(self.ext_prefix, path), handler, kwargs))
|
|
2873
|
+
self.app.server_add_patch.append((self.web_path("PATCH", path), handler, kwargs))
|
|
2643
2874
|
|
|
2644
2875
|
def add_importmaps(self, dict):
|
|
2645
2876
|
self.app.import_maps.update(dict)
|
|
@@ -2671,14 +2902,88 @@ class ExtensionContext:
|
|
|
2671
2902
|
def get_provider(self, name):
|
|
2672
2903
|
return g_handlers.get(name)
|
|
2673
2904
|
|
|
2674
|
-
def
|
|
2905
|
+
def sanitize_tool_def(self, tool_def):
|
|
2906
|
+
"""
|
|
2907
|
+
Merge $defs parameter into tool_def property to reduce client/server complexity
|
|
2908
|
+
"""
|
|
2909
|
+
# parameters = {
|
|
2910
|
+
# "$defs": {
|
|
2911
|
+
# "AspectRatio": {
|
|
2912
|
+
# "description": "Supported aspect ratios for image generation.",
|
|
2913
|
+
# "enum": [
|
|
2914
|
+
# "1:1",
|
|
2915
|
+
# "2:3",
|
|
2916
|
+
# "16:9"
|
|
2917
|
+
# ],
|
|
2918
|
+
# "type": "string"
|
|
2919
|
+
# }
|
|
2920
|
+
# },
|
|
2921
|
+
# "properties": {
|
|
2922
|
+
# "prompt": {
|
|
2923
|
+
# "type": "string"
|
|
2924
|
+
# },
|
|
2925
|
+
# "model": {
|
|
2926
|
+
# "default": "gemini-2.5-flash-image",
|
|
2927
|
+
# "type": "string"
|
|
2928
|
+
# },
|
|
2929
|
+
# "aspect_ratio": {
|
|
2930
|
+
# "$ref": "#/$defs/AspectRatio",
|
|
2931
|
+
# "default": "1:1"
|
|
2932
|
+
# }
|
|
2933
|
+
# },
|
|
2934
|
+
# "required": [
|
|
2935
|
+
# "prompt"
|
|
2936
|
+
# ],
|
|
2937
|
+
# "type": "object"
|
|
2938
|
+
# }
|
|
2939
|
+
type = tool_def.get("type")
|
|
2940
|
+
if type == "function":
|
|
2941
|
+
func_def = tool_def.get("function", {})
|
|
2942
|
+
parameters = func_def.get("parameters", {})
|
|
2943
|
+
defs = parameters.get("$defs", {})
|
|
2944
|
+
properties = parameters.get("properties", {})
|
|
2945
|
+
for prop_name, prop_def in properties.items():
|
|
2946
|
+
if "$ref" in prop_def:
|
|
2947
|
+
ref = prop_def["$ref"]
|
|
2948
|
+
if ref.startswith("#/$defs/"):
|
|
2949
|
+
def_name = ref.replace("#/$defs/", "")
|
|
2950
|
+
if def_name in defs:
|
|
2951
|
+
prop_def.update(defs[def_name])
|
|
2952
|
+
del prop_def["$ref"]
|
|
2953
|
+
if "$defs" in parameters:
|
|
2954
|
+
del parameters["$defs"]
|
|
2955
|
+
return tool_def
|
|
2956
|
+
|
|
2957
|
+
def register_tool(self, func, tool_def=None, group=None):
|
|
2675
2958
|
if tool_def is None:
|
|
2676
2959
|
tool_def = function_to_tool_definition(func)
|
|
2677
2960
|
|
|
2678
2961
|
name = tool_def["function"]["name"]
|
|
2679
|
-
self.
|
|
2962
|
+
if name in self.app.tools:
|
|
2963
|
+
self.log(f"Overriding existing tool: {name}")
|
|
2964
|
+
self.app.tool_definitions = [t for t in self.app.tool_definitions if t["function"]["name"] != name]
|
|
2965
|
+
for g_tools in self.app.tool_groups.values():
|
|
2966
|
+
if name in g_tools:
|
|
2967
|
+
g_tools.remove(name)
|
|
2968
|
+
else:
|
|
2969
|
+
self.log(f"Registered tool: {name}")
|
|
2970
|
+
|
|
2680
2971
|
self.app.tools[name] = func
|
|
2681
|
-
self.app.tool_definitions.append(tool_def)
|
|
2972
|
+
self.app.tool_definitions.append(self.sanitize_tool_def(tool_def))
|
|
2973
|
+
if not group:
|
|
2974
|
+
group = "custom"
|
|
2975
|
+
if group not in self.app.tool_groups:
|
|
2976
|
+
self.app.tool_groups[group] = []
|
|
2977
|
+
self.app.tool_groups[group].append(name)
|
|
2978
|
+
|
|
2979
|
+
def get_tool_definition(self, name):
|
|
2980
|
+
for tool_def in self.app.tool_definitions:
|
|
2981
|
+
if tool_def["function"]["name"] == name:
|
|
2982
|
+
return tool_def
|
|
2983
|
+
return None
|
|
2984
|
+
|
|
2985
|
+
def group_resources(self, resources: list):
|
|
2986
|
+
return group_resources(resources)
|
|
2682
2987
|
|
|
2683
2988
|
def check_auth(self, request):
|
|
2684
2989
|
return self.app.check_auth(request)
|
|
@@ -2692,18 +2997,35 @@ class ExtensionContext:
|
|
|
2692
2997
|
def get_user_path(self, username=None):
|
|
2693
2998
|
return self.app.get_user_path(username)
|
|
2694
2999
|
|
|
3000
|
+
def context_to_username(self, context):
|
|
3001
|
+
if context and "request" in context:
|
|
3002
|
+
return self.get_username(context["request"])
|
|
3003
|
+
return None
|
|
3004
|
+
|
|
2695
3005
|
def should_cancel_thread(self, context):
|
|
2696
3006
|
return should_cancel_thread(context)
|
|
2697
3007
|
|
|
2698
3008
|
def cache_message_inline_data(self, message):
|
|
2699
3009
|
return cache_message_inline_data(message)
|
|
2700
3010
|
|
|
3011
|
+
async def exec_tool(self, name, args):
|
|
3012
|
+
return await g_exec_tool(name, args)
|
|
3013
|
+
|
|
3014
|
+
def tool_result(self, result, function_name: Optional[str] = None, function_args: Optional[dict] = None):
|
|
3015
|
+
return g_tool_result(result, function_name, function_args)
|
|
3016
|
+
|
|
3017
|
+
def tool_result_part(self, result: dict, function_name: Optional[str] = None, function_args: Optional[dict] = None):
|
|
3018
|
+
return tool_result_part(result, function_name, function_args)
|
|
3019
|
+
|
|
2701
3020
|
def to_content(self, result):
|
|
2702
3021
|
return to_content(result)
|
|
2703
3022
|
|
|
2704
3023
|
def create_chat_with_tools(self, chat, use_tools="all"):
|
|
2705
3024
|
return self.app.create_chat_with_tools(chat, use_tools)
|
|
2706
3025
|
|
|
3026
|
+
def chat_to_aspect_ratio(self, chat):
|
|
3027
|
+
return chat_to_aspect_ratio(chat)
|
|
3028
|
+
|
|
2707
3029
|
|
|
2708
3030
|
def get_extensions_path():
|
|
2709
3031
|
return os.getenv("LLMS_EXTENSIONS_DIR", home_llms_path("extensions"))
|
|
@@ -2802,6 +3124,8 @@ def install_extensions():
|
|
|
2802
3124
|
|
|
2803
3125
|
_log(f"Installing {ext_count} extension{'' if ext_count == 1 else 's'}...")
|
|
2804
3126
|
|
|
3127
|
+
extensions = []
|
|
3128
|
+
|
|
2805
3129
|
for item_path in extension_dirs:
|
|
2806
3130
|
item = os.path.basename(item_path)
|
|
2807
3131
|
|
|
@@ -2841,11 +3165,45 @@ def install_extensions():
|
|
|
2841
3165
|
if os.path.exists(os.path.join(ui_path, "index.mjs")):
|
|
2842
3166
|
ctx.register_ui_extension("index.mjs")
|
|
2843
3167
|
|
|
3168
|
+
# include __load__ and __run__ hooks if they exist
|
|
3169
|
+
load_func = getattr(module, "__load__", None)
|
|
3170
|
+
if callable(load_func) and not inspect.iscoroutinefunction(load_func):
|
|
3171
|
+
_log(f"Warning: Extension {item} __load__ must be async")
|
|
3172
|
+
load_func = None
|
|
3173
|
+
|
|
3174
|
+
run_func = getattr(module, "__run__", None)
|
|
3175
|
+
if callable(run_func) and inspect.iscoroutinefunction(run_func):
|
|
3176
|
+
_log(f"Warning: Extension {item} __run__ must be sync")
|
|
3177
|
+
run_func = None
|
|
3178
|
+
|
|
3179
|
+
extensions.append({"name": item, "module": module, "ctx": ctx, "load": load_func, "run": run_func})
|
|
2844
3180
|
except Exception as e:
|
|
2845
3181
|
_err(f"Failed to install extension {item}", e)
|
|
2846
3182
|
else:
|
|
2847
3183
|
_dbg(f"Extension {item} not found: {item_path} is not a directory {os.path.exists(item_path)}")
|
|
2848
3184
|
|
|
3185
|
+
return extensions
|
|
3186
|
+
|
|
3187
|
+
|
|
3188
|
+
async def load_extensions():
|
|
3189
|
+
"""
|
|
3190
|
+
Calls the `__load__(ctx)` async function in all installed extensions concurrently.
|
|
3191
|
+
"""
|
|
3192
|
+
tasks = []
|
|
3193
|
+
for ext in g_app.extensions:
|
|
3194
|
+
if ext.get("load"):
|
|
3195
|
+
task = ext["load"](ext["ctx"])
|
|
3196
|
+
tasks.append({"name": ext["name"], "task": task})
|
|
3197
|
+
|
|
3198
|
+
if len(tasks) > 0:
|
|
3199
|
+
_log(f"Loading {len(tasks)} extensions...")
|
|
3200
|
+
results = await asyncio.gather(*[t["task"] for t in tasks], return_exceptions=True)
|
|
3201
|
+
for i, result in enumerate(results):
|
|
3202
|
+
if isinstance(result, Exception):
|
|
3203
|
+
# Gather returns results in order corresponding to tasks
|
|
3204
|
+
extension = tasks[i]
|
|
3205
|
+
_err(f"Failed to load extension {extension['name']}", result)
|
|
3206
|
+
|
|
2849
3207
|
|
|
2850
3208
|
def run_extension_cli():
|
|
2851
3209
|
"""
|
|
@@ -3190,9 +3548,16 @@ def main():
|
|
|
3190
3548
|
asyncio.run(update_extensions(cli_args.update))
|
|
3191
3549
|
exit(0)
|
|
3192
3550
|
|
|
3193
|
-
install_extensions()
|
|
3551
|
+
g_app.extensions = install_extensions()
|
|
3194
3552
|
|
|
3195
|
-
|
|
3553
|
+
# Use a persistent event loop to ensure async connections (like MCP)
|
|
3554
|
+
# established in load_extensions() remain active during cli_chat()
|
|
3555
|
+
loop = asyncio.new_event_loop()
|
|
3556
|
+
asyncio.set_event_loop(loop)
|
|
3557
|
+
|
|
3558
|
+
loop.run_until_complete(reload_providers())
|
|
3559
|
+
loop.run_until_complete(load_extensions())
|
|
3560
|
+
g_app.loaded = True
|
|
3196
3561
|
|
|
3197
3562
|
# print names
|
|
3198
3563
|
_log(f"enabled providers: {', '.join(g_handlers.keys())}")
|
|
@@ -3245,7 +3610,9 @@ def main():
|
|
|
3245
3610
|
# Check validity of models for a provider
|
|
3246
3611
|
provider_name = cli_args.check
|
|
3247
3612
|
model_names = extra_args if len(extra_args) > 0 else None
|
|
3248
|
-
|
|
3613
|
+
provider_name = cli_args.check
|
|
3614
|
+
model_names = extra_args if len(extra_args) > 0 else None
|
|
3615
|
+
loop.run_until_complete(check_models(provider_name, model_names))
|
|
3249
3616
|
g_app.exit(0)
|
|
3250
3617
|
|
|
3251
3618
|
if cli_args.serve is not None:
|
|
@@ -3452,11 +3819,6 @@ def main():
|
|
|
3452
3819
|
|
|
3453
3820
|
app.router.add_get("/ext", extensions_handler)
|
|
3454
3821
|
|
|
3455
|
-
async def tools_handler(request):
|
|
3456
|
-
return web.json_response(g_app.tool_definitions)
|
|
3457
|
-
|
|
3458
|
-
app.router.add_get("/ext/tools", tools_handler)
|
|
3459
|
-
|
|
3460
3822
|
async def cache_handler(request):
|
|
3461
3823
|
path = request.match_info["tail"]
|
|
3462
3824
|
full_path = get_cache_path(path)
|
|
@@ -3491,7 +3853,7 @@ def main():
|
|
|
3491
3853
|
if not str(requested_path).startswith(str(cache_root)):
|
|
3492
3854
|
_dbg(f"Forbidden: {requested_path} is not in {cache_root}")
|
|
3493
3855
|
return web.Response(text="403: Forbidden", status=403)
|
|
3494
|
-
except Exception:
|
|
3856
|
+
except Exception as e:
|
|
3495
3857
|
_err(f"Forbidden: {requested_path} is not in {cache_root}", e)
|
|
3496
3858
|
return web.Response(text="403: Forbidden", status=403)
|
|
3497
3859
|
|
|
@@ -4013,7 +4375,7 @@ def main():
|
|
|
4013
4375
|
if cli_args.args is not None:
|
|
4014
4376
|
args = parse_args_params(cli_args.args)
|
|
4015
4377
|
|
|
4016
|
-
|
|
4378
|
+
loop.run_until_complete(
|
|
4017
4379
|
cli_chat(
|
|
4018
4380
|
chat,
|
|
4019
4381
|
tools=cli_args.tools,
|