llms-py 3.0.0b2__py3-none-any.whl → 3.0.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. llms/__pycache__/main.cpython-314.pyc +0 -0
  2. llms/index.html +2 -1
  3. llms/llms.json +50 -17
  4. llms/main.py +484 -544
  5. llms/providers/__pycache__/anthropic.cpython-314.pyc +0 -0
  6. llms/providers/__pycache__/chutes.cpython-314.pyc +0 -0
  7. llms/providers/__pycache__/google.cpython-314.pyc +0 -0
  8. llms/providers/__pycache__/nvidia.cpython-314.pyc +0 -0
  9. llms/providers/__pycache__/openai.cpython-314.pyc +0 -0
  10. llms/providers/__pycache__/openrouter.cpython-314.pyc +0 -0
  11. llms/providers/anthropic.py +189 -0
  12. llms/providers/chutes.py +152 -0
  13. llms/providers/google.py +306 -0
  14. llms/providers/nvidia.py +107 -0
  15. llms/providers/openai.py +159 -0
  16. llms/providers/openrouter.py +70 -0
  17. llms/providers-extra.json +356 -0
  18. llms/providers.json +1 -1
  19. llms/ui/App.mjs +132 -60
  20. llms/ui/ai.mjs +76 -10
  21. llms/ui/app.css +1 -4962
  22. llms/ui/ctx.mjs +196 -0
  23. llms/ui/index.mjs +75 -171
  24. llms/ui/lib/charts.mjs +9 -13
  25. llms/ui/markdown.mjs +6 -0
  26. llms/ui/{Analytics.mjs → modules/analytics.mjs} +76 -64
  27. llms/ui/{Main.mjs → modules/chat/ChatBody.mjs} +56 -133
  28. llms/ui/{SettingsDialog.mjs → modules/chat/SettingsDialog.mjs} +8 -8
  29. llms/ui/{ChatPrompt.mjs → modules/chat/index.mjs} +239 -45
  30. llms/ui/modules/layout.mjs +267 -0
  31. llms/ui/modules/model-selector.mjs +851 -0
  32. llms/ui/{Recents.mjs → modules/threads/Recents.mjs} +0 -2
  33. llms/ui/{Sidebar.mjs → modules/threads/index.mjs} +46 -44
  34. llms/ui/{threadStore.mjs → modules/threads/threadStore.mjs} +10 -7
  35. llms/ui/utils.mjs +82 -123
  36. {llms_py-3.0.0b2.dist-info → llms_py-3.0.0b3.dist-info}/METADATA +1 -1
  37. llms_py-3.0.0b3.dist-info/RECORD +65 -0
  38. llms/ui/Avatar.mjs +0 -86
  39. llms/ui/Brand.mjs +0 -52
  40. llms/ui/OAuthSignIn.mjs +0 -61
  41. llms/ui/ProviderIcon.mjs +0 -36
  42. llms/ui/ProviderStatus.mjs +0 -104
  43. llms/ui/SignIn.mjs +0 -65
  44. llms/ui/Welcome.mjs +0 -8
  45. llms/ui/model-selector.mjs +0 -686
  46. llms/ui.json +0 -1069
  47. llms_py-3.0.0b2.dist-info/RECORD +0 -58
  48. {llms_py-3.0.0b2.dist-info → llms_py-3.0.0b3.dist-info}/WHEEL +0 -0
  49. {llms_py-3.0.0b2.dist-info → llms_py-3.0.0b3.dist-info}/entry_points.txt +0 -0
  50. {llms_py-3.0.0b2.dist-info → llms_py-3.0.0b3.dist-info}/licenses/LICENSE +0 -0
  51. {llms_py-3.0.0b2.dist-info → llms_py-3.0.0b3.dist-info}/top_level.txt +0 -0
llms/main.py CHANGED
@@ -38,11 +38,13 @@ try:
38
38
  except ImportError:
39
39
  HAS_PIL = False
40
40
 
41
- VERSION = "3.0.0b2"
41
+ VERSION = "3.0.0b3"
42
42
  _ROOT = None
43
43
  DEBUG = True # os.getenv("PYPI_SERVICESTACK") is not None
44
+ MOCK = False
45
+ MOCK_DIR = os.getenv("MOCK_DIR")
46
+ MOCK = os.getenv("MOCK") == "1"
44
47
  g_config_path = None
45
- g_ui_path = None
46
48
  g_config = None
47
49
  g_providers = None
48
50
  g_handlers = {}
@@ -102,17 +104,6 @@ def chat_summary(chat):
102
104
  return json.dumps(clone, indent=2)
103
105
 
104
106
 
105
- def gemini_chat_summary(gemini_chat):
106
- """Summarize Gemini chat completion request for logging. Replace inline_data with size of content only"""
107
- clone = json.loads(json.dumps(gemini_chat))
108
- for content in clone["contents"]:
109
- for part in content["parts"]:
110
- if "inline_data" in part:
111
- data = part["inline_data"]["data"]
112
- part["inline_data"]["data"] = f"({len(data)})"
113
- return json.dumps(clone, indent=2)
114
-
115
-
116
107
  image_exts = ["png", "webp", "jpg", "jpeg", "gif", "bmp", "svg", "tiff", "ico"]
117
108
  audio_exts = ["mp3", "wav", "ogg", "flac", "m4a", "opus", "webm"]
118
109
 
@@ -206,6 +197,10 @@ def is_base_64(data):
206
197
  return False
207
198
 
208
199
 
200
+ def id_to_name(id):
201
+ return id.replace("-", " ").title()
202
+
203
+
209
204
  def get_file_mime_type(filename):
210
205
  mime_type, _ = mimetypes.guess_type(filename)
211
206
  return mime_type or "application/octet-stream"
@@ -467,6 +462,61 @@ class HTTPError(Exception):
467
462
  super().__init__(f"HTTP {status} {reason}")
468
463
 
469
464
 
465
+ def save_image_to_cache(base64_data, filename, image_info):
466
+ ext = filename.split(".")[-1]
467
+ mimetype = get_file_mime_type(filename)
468
+ content = base64.b64decode(base64_data) if isinstance(base64_data, str) else base64_data
469
+ sha256_hash = hashlib.sha256(content).hexdigest()
470
+
471
+ save_filename = f"{sha256_hash}.{ext}" if ext else sha256_hash
472
+
473
+ # Use first 2 chars for subdir to avoid too many files in one dir
474
+ subdir = sha256_hash[:2]
475
+ relative_path = f"{subdir}/{save_filename}"
476
+ full_path = get_cache_path(relative_path)
477
+
478
+ url = f"~cache/{relative_path}"
479
+
480
+ # if file and its .info.json already exists, return it
481
+ info_path = os.path.splitext(full_path)[0] + ".info.json"
482
+ if os.path.exists(full_path) and os.path.exists(info_path):
483
+ return url, json.load(open(info_path))
484
+
485
+ os.makedirs(os.path.dirname(full_path), exist_ok=True)
486
+
487
+ with open(full_path, "wb") as f:
488
+ f.write(content)
489
+ info = {
490
+ "date": int(time.time()),
491
+ "url": url,
492
+ "size": len(content),
493
+ "type": mimetype,
494
+ "name": filename,
495
+ }
496
+ info.update(image_info)
497
+
498
+ # If image, get dimensions
499
+ if HAS_PIL and mimetype.startswith("image/"):
500
+ try:
501
+ with Image.open(BytesIO(content)) as img:
502
+ info["width"] = img.width
503
+ info["height"] = img.height
504
+ except Exception:
505
+ pass
506
+
507
+ if "width" in info and "height" in info:
508
+ _log(f"Saved image to cache: {full_path} ({len(content)} bytes) {info['width']}x{info['height']}")
509
+ else:
510
+ _log(f"Saved image to cache: {full_path} ({len(content)} bytes)")
511
+
512
+ # Save metadata
513
+ info_path = os.path.splitext(full_path)[0] + ".info.json"
514
+ with open(info_path, "w") as f:
515
+ json.dump(info, f)
516
+
517
+ return url, info
518
+
519
+
470
520
  async def response_json(response):
471
521
  text = await response.text()
472
522
  if response.status >= 400:
@@ -476,6 +526,120 @@ async def response_json(response):
476
526
  return body
477
527
 
478
528
 
529
+ def chat_to_prompt(chat):
530
+ prompt = ""
531
+ if "messages" in chat:
532
+ for message in chat["messages"]:
533
+ if message["role"] == "user":
534
+ # if content is string
535
+ if isinstance(message["content"], str):
536
+ if prompt:
537
+ prompt += "\n"
538
+ prompt += message["content"]
539
+ elif isinstance(message["content"], list):
540
+ # if content is array of objects
541
+ for part in message["content"]:
542
+ if part["type"] == "text":
543
+ if prompt:
544
+ prompt += "\n"
545
+ prompt += part["text"]
546
+ return prompt
547
+
548
+
549
+ def last_user_prompt(chat):
550
+ prompt = ""
551
+ if "messages" in chat:
552
+ for message in chat["messages"]:
553
+ if message["role"] == "user":
554
+ # if content is string
555
+ if isinstance(message["content"], str):
556
+ prompt = message["content"]
557
+ elif isinstance(message["content"], list):
558
+ # if content is array of objects
559
+ for part in message["content"]:
560
+ if part["type"] == "text":
561
+ prompt = part["text"]
562
+ return prompt
563
+
564
+
565
+ # Image Generator Providers
566
+ class GeneratorBase:
567
+ def __init__(self, **kwargs):
568
+ self.id = kwargs.get("id")
569
+ self.api = kwargs.get("api")
570
+ self.api_key = kwargs.get("api_key")
571
+ self.headers = {
572
+ "Accept": "application/json",
573
+ "Content-Type": "application/json",
574
+ }
575
+ self.chat_url = f"{self.api}/chat/completions"
576
+ self.default_content = "I've generated the image for you."
577
+
578
+ def validate(self, **kwargs):
579
+ if not self.api_key:
580
+ api_keys = ", ".join(self.env)
581
+ return f"Provider '{self.name}' requires API Key {api_keys}"
582
+ return None
583
+
584
+ def test(self, **kwargs):
585
+ error_msg = self.validate(**kwargs)
586
+ if error_msg:
587
+ _log(error_msg)
588
+ return False
589
+ return True
590
+
591
+ async def load(self):
592
+ pass
593
+
594
+ def gen_summary(self, gen):
595
+ """Summarize gen response for logging."""
596
+ clone = json.loads(json.dumps(gen))
597
+ return json.dumps(clone, indent=2)
598
+
599
+ def chat_summary(self, chat):
600
+ return chat_summary(chat)
601
+
602
+ def process_chat(self, chat, provider_id=None):
603
+ return process_chat(chat, provider_id)
604
+
605
+ async def response_json(self, response):
606
+ return await response_json(response)
607
+
608
+ def get_headers(self, provider, chat):
609
+ headers = self.headers.copy()
610
+ if provider is not None:
611
+ headers["Authorization"] = f"Bearer {provider.api_key}"
612
+ elif self.api_key:
613
+ headers["Authorization"] = f"Bearer {self.api_key}"
614
+ return headers
615
+
616
+ def to_response(self, response, chat, started_at):
617
+ raise NotImplementedError
618
+
619
+ async def chat(self, chat, provider=None):
620
+ return {
621
+ "choices": [
622
+ {
623
+ "message": {
624
+ "role": "assistant",
625
+ "content": "Not Implemented",
626
+ "images": [
627
+ {
628
+ "type": "image_url",
629
+ "image_url": {
630
+ "url": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSJjdXJyZW50Q29sb3IiIGQ9Ik0xMiAyMGE4IDggMCAxIDAgMC0xNmE4IDggMCAwIDAgMCAxNm0wIDJDNi40NzcgMjIgMiAxNy41MjMgMiAxMlM2LjQ3NyAyIDEyIDJzMTAgNC40NzcgMTAgMTBzLTQuNDc3IDEwLTEwIDEwbS0xLTZoMnYyaC0yem0wLTEwaDJ2OGgtMnoiLz48L3N2Zz4=",
631
+ },
632
+ }
633
+ ],
634
+ }
635
+ }
636
+ ]
637
+ }
638
+
639
+
640
+ # OpenAI Providers
641
+
642
+
479
643
  class OpenAiCompatible:
480
644
  sdk = "@ai-sdk/openai-compatible"
481
645
 
@@ -487,8 +651,9 @@ class OpenAiCompatible:
487
651
 
488
652
  self.id = kwargs.get("id")
489
653
  self.api = kwargs.get("api").strip("/")
654
+ self.env = kwargs.get("env", [])
490
655
  self.api_key = kwargs.get("api_key")
491
- self.name = kwargs.get("name", self.id.replace("-", " ").title().replace(" ", ""))
656
+ self.name = kwargs.get("name", id_to_name(self.id))
492
657
  self.set_models(**kwargs)
493
658
 
494
659
  self.chat_url = f"{self.api}/chat/completions"
@@ -516,6 +681,7 @@ class OpenAiCompatible:
516
681
  self.stream = bool(kwargs["stream"]) if "stream" in kwargs else None
517
682
  self.enable_thinking = bool(kwargs["enable_thinking"]) if "enable_thinking" in kwargs else None
518
683
  self.check = kwargs.get("check")
684
+ self.modalities = kwargs.get("modalities", {})
519
685
 
520
686
  def set_models(self, **kwargs):
521
687
  models = kwargs.get("models", {})
@@ -541,11 +707,18 @@ class OpenAiCompatible:
541
707
  _log(f"Filtering {len(self.models)} models, excluding models that match regex: {exclude_models}")
542
708
  self.models = {k: v for k, v in self.models.items() if not re.search(exclude_models, k)}
543
709
 
710
+ def validate(self, **kwargs):
711
+ if not self.api_key:
712
+ api_keys = ", ".join(self.env)
713
+ return f"Provider '{self.name}' requires API Key {api_keys}"
714
+ return None
715
+
544
716
  def test(self, **kwargs):
545
- ret = self.api and self.api_key and (len(self.models) > 0)
546
- if not ret:
547
- _log(f"Provider {self.name} Missing: {self.api}, {self.api_key}, {len(self.models)}")
548
- return ret
717
+ error_msg = self.validate(**kwargs)
718
+ if error_msg:
719
+ _log(error_msg)
720
+ return False
721
+ return True
549
722
 
550
723
  async def load(self):
551
724
  if not self.models:
@@ -593,8 +766,12 @@ class OpenAiCompatible:
593
766
  if "/" in model:
594
767
  last_part = model.split("/")[-1]
595
768
  return self.provider_model(last_part)
769
+
596
770
  return None
597
771
 
772
+ def response_json(self, response):
773
+ return response_json(response)
774
+
598
775
  def to_response(self, response, chat, started_at):
599
776
  if "metadata" not in response:
600
777
  response["metadata"] = {}
@@ -603,12 +780,28 @@ class OpenAiCompatible:
603
780
  pricing = self.model_cost(chat["model"])
604
781
  if pricing and "input" in pricing and "output" in pricing:
605
782
  response["metadata"]["pricing"] = f"{pricing['input']}/{pricing['output']}"
606
- _log(json.dumps(response, indent=2))
607
783
  return response
608
784
 
785
+ def chat_summary(self, chat):
786
+ return chat_summary(chat)
787
+
788
+ def process_chat(self, chat, provider_id=None):
789
+ return process_chat(chat, provider_id)
790
+
609
791
  async def chat(self, chat):
610
792
  chat["model"] = self.provider_model(chat["model"]) or chat["model"]
611
793
 
794
+ if "modalities" in chat:
795
+ for modality in chat["modalities"]:
796
+ # use default implementation for text modalities
797
+ if modality == "text":
798
+ continue
799
+ modality_provider = self.modalities.get(modality)
800
+ if modality_provider:
801
+ return await modality_provider.chat(chat, self)
802
+ else:
803
+ raise Exception(f"Provider {self.name} does not support '{modality}' modality")
804
+
612
805
  # with open(os.path.join(os.path.dirname(__file__), 'chat.wip.json'), "w") as f:
613
806
  # f.write(json.dumps(chat, indent=2))
614
807
 
@@ -661,193 +854,6 @@ class OpenAiCompatible:
661
854
  return self.to_response(await response_json(response), chat, started_at)
662
855
 
663
856
 
664
- class OpenAiProvider(OpenAiCompatible):
665
- sdk = "@ai-sdk/openai"
666
-
667
- def __init__(self, **kwargs):
668
- if "api" not in kwargs:
669
- kwargs["api"] = "https://api.openai.com/v1"
670
- super().__init__(**kwargs)
671
-
672
-
673
- class AnthropicProvider(OpenAiCompatible):
674
- sdk = "@ai-sdk/anthropic"
675
-
676
- def __init__(self, **kwargs):
677
- if "api" not in kwargs:
678
- kwargs["api"] = "https://api.anthropic.com/v1"
679
- super().__init__(**kwargs)
680
-
681
- # Anthropic uses x-api-key header instead of Authorization
682
- if self.api_key:
683
- self.headers = self.headers.copy()
684
- if "Authorization" in self.headers:
685
- del self.headers["Authorization"]
686
- self.headers["x-api-key"] = self.api_key
687
-
688
- if "anthropic-version" not in self.headers:
689
- self.headers = self.headers.copy()
690
- self.headers["anthropic-version"] = "2023-06-01"
691
- self.chat_url = f"{self.api}/messages"
692
-
693
- async def chat(self, chat):
694
- chat["model"] = self.provider_model(chat["model"]) or chat["model"]
695
-
696
- chat = await process_chat(chat, provider_id=self.id)
697
-
698
- # Transform OpenAI format to Anthropic format
699
- anthropic_request = {
700
- "model": chat["model"],
701
- "messages": [],
702
- }
703
-
704
- # Extract system message (Anthropic uses top-level 'system' parameter)
705
- system_messages = []
706
- for message in chat.get("messages", []):
707
- if message.get("role") == "system":
708
- content = message.get("content", "")
709
- if isinstance(content, str):
710
- system_messages.append(content)
711
- elif isinstance(content, list):
712
- for item in content:
713
- if item.get("type") == "text":
714
- system_messages.append(item.get("text", ""))
715
-
716
- if system_messages:
717
- anthropic_request["system"] = "\n".join(system_messages)
718
-
719
- # Transform messages (exclude system messages)
720
- for message in chat.get("messages", []):
721
- if message.get("role") == "system":
722
- continue
723
-
724
- anthropic_message = {"role": message.get("role"), "content": []}
725
-
726
- content = message.get("content", "")
727
- if isinstance(content, str):
728
- anthropic_message["content"] = content
729
- elif isinstance(content, list):
730
- for item in content:
731
- if item.get("type") == "text":
732
- anthropic_message["content"].append({"type": "text", "text": item.get("text", "")})
733
- elif item.get("type") == "image_url" and "image_url" in item:
734
- # Transform OpenAI image_url format to Anthropic format
735
- image_url = item["image_url"].get("url", "")
736
- if image_url.startswith("data:"):
737
- # Extract media type and base64 data
738
- parts = image_url.split(";base64,", 1)
739
- if len(parts) == 2:
740
- media_type = parts[0].replace("data:", "")
741
- base64_data = parts[1]
742
- anthropic_message["content"].append(
743
- {
744
- "type": "image",
745
- "source": {"type": "base64", "media_type": media_type, "data": base64_data},
746
- }
747
- )
748
-
749
- anthropic_request["messages"].append(anthropic_message)
750
-
751
- # Handle max_tokens (required by Anthropic, uses max_tokens not max_completion_tokens)
752
- if "max_completion_tokens" in chat:
753
- anthropic_request["max_tokens"] = chat["max_completion_tokens"]
754
- elif "max_tokens" in chat:
755
- anthropic_request["max_tokens"] = chat["max_tokens"]
756
- else:
757
- # Anthropic requires max_tokens, set a default
758
- anthropic_request["max_tokens"] = 4096
759
-
760
- # Copy other supported parameters
761
- if "temperature" in chat:
762
- anthropic_request["temperature"] = chat["temperature"]
763
- if "top_p" in chat:
764
- anthropic_request["top_p"] = chat["top_p"]
765
- if "top_k" in chat:
766
- anthropic_request["top_k"] = chat["top_k"]
767
- if "stop" in chat:
768
- anthropic_request["stop_sequences"] = chat["stop"] if isinstance(chat["stop"], list) else [chat["stop"]]
769
- if "stream" in chat:
770
- anthropic_request["stream"] = chat["stream"]
771
- if "tools" in chat:
772
- anthropic_request["tools"] = chat["tools"]
773
- if "tool_choice" in chat:
774
- anthropic_request["tool_choice"] = chat["tool_choice"]
775
-
776
- _log(f"POST {self.chat_url}")
777
- _log(f"Anthropic Request: {json.dumps(anthropic_request, indent=2)}")
778
-
779
- async with aiohttp.ClientSession() as session:
780
- started_at = time.time()
781
- async with session.post(
782
- self.chat_url,
783
- headers=self.headers,
784
- data=json.dumps(anthropic_request),
785
- timeout=aiohttp.ClientTimeout(total=120),
786
- ) as response:
787
- return self.to_response(await response_json(response), chat, started_at)
788
-
789
- def to_response(self, response, chat, started_at):
790
- """Convert Anthropic response format to OpenAI-compatible format."""
791
- # Transform Anthropic response to OpenAI format
792
- openai_response = {
793
- "id": response.get("id", ""),
794
- "object": "chat.completion",
795
- "created": int(started_at),
796
- "model": response.get("model", ""),
797
- "choices": [],
798
- "usage": {},
799
- }
800
-
801
- # Transform content blocks to message content
802
- content_parts = []
803
- thinking_parts = []
804
-
805
- for block in response.get("content", []):
806
- if block.get("type") == "text":
807
- content_parts.append(block.get("text", ""))
808
- elif block.get("type") == "thinking":
809
- # Store thinking blocks separately (some models include reasoning)
810
- thinking_parts.append(block.get("thinking", ""))
811
-
812
- # Combine all text content
813
- message_content = "\n".join(content_parts) if content_parts else ""
814
-
815
- # Create the choice object
816
- choice = {
817
- "index": 0,
818
- "message": {"role": "assistant", "content": message_content},
819
- "finish_reason": response.get("stop_reason", "stop"),
820
- }
821
-
822
- # Add thinking as metadata if present
823
- if thinking_parts:
824
- choice["message"]["thinking"] = "\n".join(thinking_parts)
825
-
826
- openai_response["choices"].append(choice)
827
-
828
- # Transform usage
829
- if "usage" in response:
830
- usage = response["usage"]
831
- openai_response["usage"] = {
832
- "prompt_tokens": usage.get("input_tokens", 0),
833
- "completion_tokens": usage.get("output_tokens", 0),
834
- "total_tokens": usage.get("input_tokens", 0) + usage.get("output_tokens", 0),
835
- }
836
-
837
- # Add metadata
838
- if "metadata" not in openai_response:
839
- openai_response["metadata"] = {}
840
- openai_response["metadata"]["duration"] = int((time.time() - started_at) * 1000)
841
-
842
- if chat is not None and "model" in chat:
843
- cost = self.model_cost(chat["model"])
844
- if cost and "input" in cost and "output" in cost:
845
- openai_response["metadata"]["pricing"] = f"{cost['input']}/{cost['output']}"
846
-
847
- _log(json.dumps(openai_response, indent=2))
848
- return openai_response
849
-
850
-
851
857
  class MistralProvider(OpenAiCompatible):
852
858
  sdk = "@ai-sdk/mistral"
853
859
 
@@ -943,8 +949,8 @@ class OllamaProvider(OpenAiCompatible):
943
949
  }
944
950
  self.models = models
945
951
 
946
- def test(self, **kwargs):
947
- return True
952
+ def validate(self, **kwargs):
953
+ return None
948
954
 
949
955
 
950
956
  class LMStudioProvider(OllamaProvider):
@@ -973,223 +979,6 @@ class LMStudioProvider(OllamaProvider):
973
979
  return ret
974
980
 
975
981
 
976
- # class GoogleOpenAiProvider(OpenAiCompatible):
977
- # sdk = "google-openai-compatible"
978
-
979
- # def __init__(self, api_key, **kwargs):
980
- # super().__init__(api="https://generativelanguage.googleapis.com", api_key=api_key, **kwargs)
981
- # self.chat_url = "https://generativelanguage.googleapis.com/v1beta/chat/completions"
982
-
983
-
984
- class GoogleProvider(OpenAiCompatible):
985
- sdk = "@ai-sdk/google"
986
-
987
- def __init__(self, **kwargs):
988
- new_kwargs = {"api": "https://generativelanguage.googleapis.com", **kwargs}
989
- super().__init__(**new_kwargs)
990
- self.safety_settings = kwargs.get("safety_settings")
991
- self.thinking_config = kwargs.get("thinking_config")
992
- self.curl = kwargs.get("curl")
993
- self.headers = kwargs.get("headers", {"Content-Type": "application/json"})
994
- # Google fails when using Authorization header, use query string param instead
995
- if "Authorization" in self.headers:
996
- del self.headers["Authorization"]
997
-
998
- async def chat(self, chat):
999
- chat["model"] = self.provider_model(chat["model"]) or chat["model"]
1000
-
1001
- chat = await process_chat(chat)
1002
- generation_config = {}
1003
-
1004
- # Filter out system messages and convert to proper Gemini format
1005
- contents = []
1006
- system_prompt = None
1007
-
1008
- async with aiohttp.ClientSession() as session:
1009
- for message in chat["messages"]:
1010
- if message["role"] == "system":
1011
- content = message["content"]
1012
- if isinstance(content, list):
1013
- for item in content:
1014
- if "text" in item:
1015
- system_prompt = item["text"]
1016
- break
1017
- elif isinstance(content, str):
1018
- system_prompt = content
1019
- elif "content" in message:
1020
- if isinstance(message["content"], list):
1021
- parts = []
1022
- for item in message["content"]:
1023
- if "type" in item:
1024
- if item["type"] == "image_url" and "image_url" in item:
1025
- image_url = item["image_url"]
1026
- if "url" not in image_url:
1027
- continue
1028
- url = image_url["url"]
1029
- if not url.startswith("data:"):
1030
- raise (Exception("Image was not downloaded: " + url))
1031
- # Extract mime type from data uri
1032
- mimetype = url.split(";", 1)[0].split(":", 1)[1] if ";" in url else "image/png"
1033
- base64_data = url.split(",", 1)[1]
1034
- parts.append({"inline_data": {"mime_type": mimetype, "data": base64_data}})
1035
- elif item["type"] == "input_audio" and "input_audio" in item:
1036
- input_audio = item["input_audio"]
1037
- if "data" not in input_audio:
1038
- continue
1039
- data = input_audio["data"]
1040
- format = input_audio["format"]
1041
- mimetype = f"audio/{format}"
1042
- parts.append({"inline_data": {"mime_type": mimetype, "data": data}})
1043
- elif item["type"] == "file" and "file" in item:
1044
- file = item["file"]
1045
- if "file_data" not in file:
1046
- continue
1047
- data = file["file_data"]
1048
- if not data.startswith("data:"):
1049
- raise (Exception("File was not downloaded: " + data))
1050
- # Extract mime type from data uri
1051
- mimetype = (
1052
- data.split(";", 1)[0].split(":", 1)[1]
1053
- if ";" in data
1054
- else "application/octet-stream"
1055
- )
1056
- base64_data = data.split(",", 1)[1]
1057
- parts.append({"inline_data": {"mime_type": mimetype, "data": base64_data}})
1058
- if "text" in item:
1059
- text = item["text"]
1060
- parts.append({"text": text})
1061
- if len(parts) > 0:
1062
- contents.append(
1063
- {
1064
- "role": message["role"]
1065
- if "role" in message and message["role"] == "user"
1066
- else "model",
1067
- "parts": parts,
1068
- }
1069
- )
1070
- else:
1071
- content = message["content"]
1072
- contents.append(
1073
- {
1074
- "role": message["role"] if "role" in message and message["role"] == "user" else "model",
1075
- "parts": [{"text": content}],
1076
- }
1077
- )
1078
-
1079
- gemini_chat = {
1080
- "contents": contents,
1081
- }
1082
-
1083
- if self.safety_settings:
1084
- gemini_chat["safetySettings"] = self.safety_settings
1085
-
1086
- # Add system instruction if present
1087
- if system_prompt is not None:
1088
- gemini_chat["systemInstruction"] = {"parts": [{"text": system_prompt}]}
1089
-
1090
- if "max_completion_tokens" in chat:
1091
- generation_config["maxOutputTokens"] = chat["max_completion_tokens"]
1092
- if "stop" in chat:
1093
- generation_config["stopSequences"] = [chat["stop"]]
1094
- if "temperature" in chat:
1095
- generation_config["temperature"] = chat["temperature"]
1096
- if "top_p" in chat:
1097
- generation_config["topP"] = chat["top_p"]
1098
- if "top_logprobs" in chat:
1099
- generation_config["topK"] = chat["top_logprobs"]
1100
-
1101
- if "thinkingConfig" in chat:
1102
- generation_config["thinkingConfig"] = chat["thinkingConfig"]
1103
- elif self.thinking_config:
1104
- generation_config["thinkingConfig"] = self.thinking_config
1105
-
1106
- if len(generation_config) > 0:
1107
- gemini_chat["generationConfig"] = generation_config
1108
-
1109
- started_at = int(time.time() * 1000)
1110
- gemini_chat_url = f"https://generativelanguage.googleapis.com/v1beta/models/{chat['model']}:generateContent?key={self.api_key}"
1111
-
1112
- _log(f"POST {gemini_chat_url}")
1113
- _log(gemini_chat_summary(gemini_chat))
1114
- started_at = time.time()
1115
-
1116
- if self.curl:
1117
- curl_args = [
1118
- "curl",
1119
- "-X",
1120
- "POST",
1121
- "-H",
1122
- "Content-Type: application/json",
1123
- "-d",
1124
- json.dumps(gemini_chat),
1125
- gemini_chat_url,
1126
- ]
1127
- try:
1128
- o = subprocess.run(curl_args, check=True, capture_output=True, text=True, timeout=120)
1129
- obj = json.loads(o.stdout)
1130
- except Exception as e:
1131
- raise Exception(f"Error executing curl: {e}") from e
1132
- else:
1133
- async with session.post(
1134
- gemini_chat_url,
1135
- headers=self.headers,
1136
- data=json.dumps(gemini_chat),
1137
- timeout=aiohttp.ClientTimeout(total=120),
1138
- ) as res:
1139
- obj = await response_json(res)
1140
- _log(f"google response:\n{json.dumps(obj, indent=2)}")
1141
-
1142
- response = {
1143
- "id": f"chatcmpl-{started_at}",
1144
- "created": started_at,
1145
- "model": obj.get("modelVersion", chat["model"]),
1146
- }
1147
- choices = []
1148
- if "error" in obj:
1149
- _log(f"Error: {obj['error']}")
1150
- raise Exception(obj["error"]["message"])
1151
- for i, candidate in enumerate(obj["candidates"]):
1152
- role = "assistant"
1153
- if "content" in candidate and "role" in candidate["content"]:
1154
- role = "assistant" if candidate["content"]["role"] == "model" else candidate["content"]["role"]
1155
-
1156
- # Safely extract content from all text parts
1157
- content = ""
1158
- reasoning = ""
1159
- if "content" in candidate and "parts" in candidate["content"]:
1160
- text_parts = []
1161
- reasoning_parts = []
1162
- for part in candidate["content"]["parts"]:
1163
- if "text" in part:
1164
- if "thought" in part and part["thought"]:
1165
- reasoning_parts.append(part["text"])
1166
- else:
1167
- text_parts.append(part["text"])
1168
- content = " ".join(text_parts)
1169
- reasoning = " ".join(reasoning_parts)
1170
-
1171
- choice = {
1172
- "index": i,
1173
- "finish_reason": candidate.get("finishReason", "stop"),
1174
- "message": {
1175
- "role": role,
1176
- "content": content,
1177
- },
1178
- }
1179
- if reasoning:
1180
- choice["message"]["reasoning"] = reasoning
1181
- choices.append(choice)
1182
- response["choices"] = choices
1183
- if "usageMetadata" in obj:
1184
- usage = obj["usageMetadata"]
1185
- response["usage"] = {
1186
- "completion_tokens": usage["candidatesTokenCount"],
1187
- "total_tokens": usage["totalTokenCount"],
1188
- "prompt_tokens": usage["promptTokenCount"],
1189
- }
1190
- return self.to_response(response, chat, started_at)
1191
-
1192
-
1193
982
  def get_provider_model(model_name):
1194
983
  for provider in g_handlers.values():
1195
984
  provider_model = provider.provider_model(model_name)
@@ -1337,8 +1126,29 @@ async def cli_chat(chat, image=None, audio=None, file=None, args=None, raw=False
1337
1126
  print(json.dumps(response, indent=2))
1338
1127
  exit(0)
1339
1128
  else:
1340
- answer = response["choices"][0]["message"]["content"]
1341
- print(answer)
1129
+ msg = response["choices"][0]["message"]
1130
+ if "answer" in msg:
1131
+ answer = msg["content"]
1132
+ print(answer)
1133
+
1134
+ generated_files = []
1135
+ for choice in response["choices"]:
1136
+ if "message" in choice:
1137
+ msg = choice["message"]
1138
+ if "images" in msg:
1139
+ for image in msg["images"]:
1140
+ image_url = image["image_url"]["url"]
1141
+ generated_files.append(image_url)
1142
+
1143
+ if len(generated_files) > 0:
1144
+ print("\nSaved files:")
1145
+ for file in generated_files:
1146
+ if file.startswith("~cache"):
1147
+ print(get_cache_path(file[7:]))
1148
+ _log(f"http://localhost:8000/{file}")
1149
+ else:
1150
+ print(file)
1151
+
1342
1152
  except HTTPError as e:
1343
1153
  # HTTP error (4xx, 5xx)
1344
1154
  print(f"{e}:\n{e.body}")
@@ -1380,22 +1190,26 @@ def init_llms(config, providers):
1380
1190
  providers = g_config["providers"]
1381
1191
 
1382
1192
  for id, orig in providers.items():
1383
- definition = orig.copy()
1384
- if "enabled" in definition and not definition["enabled"]:
1193
+ if "enabled" in orig and not orig["enabled"]:
1385
1194
  continue
1386
1195
 
1387
- provider_id = definition.get("id", id)
1388
- if "id" not in definition:
1389
- definition["id"] = provider_id
1390
- provider = g_providers.get(provider_id)
1391
- constructor_kwargs = create_provider_kwargs(definition, provider)
1392
- provider = create_provider(constructor_kwargs)
1393
-
1196
+ provider, constructor_kwargs = create_provider_from_definition(id, orig)
1394
1197
  if provider and provider.test(**constructor_kwargs):
1395
1198
  g_handlers[id] = provider
1396
1199
  return g_handlers
1397
1200
 
1398
1201
 
1202
+ def create_provider_from_definition(id, orig):
1203
+ definition = orig.copy()
1204
+ provider_id = definition.get("id", id)
1205
+ if "id" not in definition:
1206
+ definition["id"] = provider_id
1207
+ provider = g_providers.get(provider_id)
1208
+ constructor_kwargs = create_provider_kwargs(definition, provider)
1209
+ provider = create_provider(constructor_kwargs)
1210
+ return provider, constructor_kwargs
1211
+
1212
+
1399
1213
  def create_provider_kwargs(definition, provider=None):
1400
1214
  if provider:
1401
1215
  provider = provider.copy()
@@ -1423,6 +1237,15 @@ def create_provider_kwargs(definition, provider=None):
1423
1237
  if isinstance(value, (list, dict)):
1424
1238
  constructor_kwargs[key] = value.copy()
1425
1239
  constructor_kwargs["headers"] = g_config["defaults"]["headers"].copy()
1240
+
1241
+ if "modalities" in definition:
1242
+ constructor_kwargs["modalities"] = {}
1243
+ for modality, modality_definition in definition["modalities"].items():
1244
+ modality_provider = create_provider(modality_definition)
1245
+ if not modality_provider:
1246
+ return None
1247
+ constructor_kwargs["modalities"][modality] = modality_provider
1248
+
1426
1249
  return constructor_kwargs
1427
1250
 
1428
1251
 
@@ -1438,6 +1261,8 @@ def create_provider(provider):
1438
1261
  for provider_type in g_app.all_providers:
1439
1262
  if provider_type.sdk == npm_sdk:
1440
1263
  kwargs = create_provider_kwargs(provider)
1264
+ if kwargs is None:
1265
+ kwargs = provider
1441
1266
  return provider_type(**kwargs)
1442
1267
 
1443
1268
  _log(f"Could not find provider {provider_label} with npm sdk {npm_sdk}")
@@ -1491,11 +1316,23 @@ async def update_providers(home_providers_path):
1491
1316
  global g_providers
1492
1317
  text = await get_text("https://models.dev/api.json")
1493
1318
  all_providers = json.loads(text)
1319
+ extra_providers = {}
1320
+ extra_providers_path = home_providers_path.replace("providers.json", "providers-extra.json")
1321
+ if os.path.exists(extra_providers_path):
1322
+ with open(extra_providers_path) as f:
1323
+ extra_providers = json.load(f)
1494
1324
 
1495
1325
  filtered_providers = {}
1496
1326
  for id, provider in all_providers.items():
1497
1327
  if id in g_config["providers"]:
1498
1328
  filtered_providers[id] = provider
1329
+ if id in extra_providers and "models" in extra_providers[id]:
1330
+ for model_id, model in extra_providers[id]["models"].items():
1331
+ if "id" not in model:
1332
+ model["id"] = model_id
1333
+ if "name" not in model:
1334
+ model["name"] = id_to_name(model["id"])
1335
+ filtered_providers[id]["models"][model_id] = model
1499
1336
 
1500
1337
  os.makedirs(os.path.dirname(home_providers_path), exist_ok=True)
1501
1338
  with open(home_providers_path, "w", encoding="utf-8") as f:
@@ -1548,26 +1385,18 @@ def get_config_path():
1548
1385
  return None
1549
1386
 
1550
1387
 
1551
- def get_ui_path():
1552
- ui_paths = [home_llms_path("ui.json"), "ui.json"]
1553
- for ui_path in ui_paths:
1554
- if os.path.exists(ui_path):
1555
- return ui_path
1556
- return None
1557
-
1558
-
1559
1388
  def enable_provider(provider):
1560
1389
  msg = None
1561
1390
  provider_config = g_config["providers"][provider]
1391
+ if not provider_config:
1392
+ return None, f"Provider {provider} not found"
1393
+
1394
+ provider, constructor_kwargs = create_provider_from_definition(provider, provider_config)
1395
+ msg = provider.validate(**constructor_kwargs)
1396
+ if msg:
1397
+ return None, msg
1398
+
1562
1399
  provider_config["enabled"] = True
1563
- if "api_key" in provider_config:
1564
- api_key = provider_config["api_key"]
1565
- if isinstance(api_key, str):
1566
- if api_key.startswith("$"):
1567
- if not os.environ.get(api_key[1:], ""):
1568
- msg = f"WARNING: {provider} requires missing API Key in Environment Variable {api_key}"
1569
- else:
1570
- msg = f"WARNING: {provider} is not configured with an API Key"
1571
1400
  save_config(g_config)
1572
1401
  init_llms(g_config, g_providers)
1573
1402
  return provider_config, msg
@@ -1892,9 +1721,14 @@ async def text_from_resource_or_url(filename):
1892
1721
 
1893
1722
  async def save_home_configs():
1894
1723
  home_config_path = home_llms_path("llms.json")
1895
- home_ui_path = home_llms_path("ui.json")
1896
1724
  home_providers_path = home_llms_path("providers.json")
1897
- if os.path.exists(home_config_path) and os.path.exists(home_ui_path) and os.path.exists(home_providers_path):
1725
+ home_providers_extra_path = home_llms_path("providers-extra.json")
1726
+
1727
+ if (
1728
+ os.path.exists(home_config_path)
1729
+ and os.path.exists(home_providers_path)
1730
+ and os.path.exists(home_providers_extra_path)
1731
+ ):
1898
1732
  return
1899
1733
 
1900
1734
  llms_home = os.path.dirname(home_config_path)
@@ -1906,17 +1740,17 @@ async def save_home_configs():
1906
1740
  f.write(config_json)
1907
1741
  _log(f"Created default config at {home_config_path}")
1908
1742
 
1909
- if not os.path.exists(home_ui_path):
1910
- ui_json = await text_from_resource_or_url("ui.json")
1911
- with open(home_ui_path, "w", encoding="utf-8") as f:
1912
- f.write(ui_json)
1913
- _log(f"Created default ui config at {home_ui_path}")
1914
-
1915
1743
  if not os.path.exists(home_providers_path):
1916
1744
  providers_json = await text_from_resource_or_url("providers.json")
1917
1745
  with open(home_providers_path, "w", encoding="utf-8") as f:
1918
1746
  f.write(providers_json)
1919
1747
  _log(f"Created default providers config at {home_providers_path}")
1748
+
1749
+ if not os.path.exists(home_providers_extra_path):
1750
+ extra_json = await text_from_resource_or_url("providers-extra.json")
1751
+ with open(home_providers_extra_path, "w", encoding="utf-8") as f:
1752
+ f.write(extra_json)
1753
+ _log(f"Created default extra providers config at {home_providers_extra_path}")
1920
1754
  except Exception:
1921
1755
  print("Could not create llms.json. Create one with --init or use --config <path>")
1922
1756
  exit(1)
@@ -1953,59 +1787,52 @@ async def reload_providers():
1953
1787
  return g_handlers
1954
1788
 
1955
1789
 
1956
- async def watch_config_files(config_path, ui_path, interval=1):
1790
+ async def watch_config_files(config_path, providers_path, interval=1):
1957
1791
  """Watch config files and reload providers when they change"""
1958
1792
  global g_config
1959
1793
 
1960
1794
  config_path = Path(config_path)
1961
- ui_path = Path(ui_path) if ui_path else None
1795
+ providers_path = Path(providers_path)
1796
+
1797
+ _log(f"Watching config file: {config_path}")
1798
+ _log(f"Watching providers file: {providers_path}")
1962
1799
 
1963
- file_mtimes = {}
1800
+ def get_latest_mtime():
1801
+ ret = 0
1802
+ name = "llms.json"
1803
+ if config_path.is_file():
1804
+ ret = config_path.stat().st_mtime
1805
+ name = config_path.name
1806
+ if providers_path.is_file() and providers_path.stat().st_mtime > ret:
1807
+ ret = providers_path.stat().st_mtime
1808
+ name = providers_path.name
1809
+ return ret, name
1964
1810
 
1965
- _log(f"Watching config files: {config_path}" + (f", {ui_path}" if ui_path else ""))
1811
+ latest_mtime, name = get_latest_mtime()
1966
1812
 
1967
1813
  while True:
1968
1814
  await asyncio.sleep(interval)
1969
1815
 
1970
1816
  # Check llms.json
1971
1817
  try:
1972
- if config_path.is_file():
1973
- mtime = config_path.stat().st_mtime
1818
+ new_mtime, name = get_latest_mtime()
1819
+ if new_mtime > latest_mtime:
1820
+ _log(f"Config file changed: {name}")
1821
+ latest_mtime = new_mtime
1974
1822
 
1975
- if str(config_path) not in file_mtimes:
1976
- file_mtimes[str(config_path)] = mtime
1977
- elif file_mtimes[str(config_path)] != mtime:
1978
- _log(f"Config file changed: {config_path.name}")
1979
- file_mtimes[str(config_path)] = mtime
1823
+ try:
1824
+ # Reload llms.json
1825
+ with open(config_path) as f:
1826
+ g_config = json.load(f)
1980
1827
 
1981
- try:
1982
- # Reload llms.json
1983
- with open(config_path) as f:
1984
- g_config = json.load(f)
1985
-
1986
- # Reload providers
1987
- await reload_providers()
1988
- _log("Providers reloaded successfully")
1989
- except Exception as e:
1990
- _log(f"Error reloading config: {e}")
1828
+ # Reload providers
1829
+ await reload_providers()
1830
+ _log("Providers reloaded successfully")
1831
+ except Exception as e:
1832
+ _log(f"Error reloading config: {e}")
1991
1833
  except FileNotFoundError:
1992
1834
  pass
1993
1835
 
1994
- # Check ui.json
1995
- if ui_path:
1996
- try:
1997
- if ui_path.is_file():
1998
- mtime = ui_path.stat().st_mtime
1999
-
2000
- if str(ui_path) not in file_mtimes:
2001
- file_mtimes[str(ui_path)] = mtime
2002
- elif file_mtimes[str(ui_path)] != mtime:
2003
- _log(f"Config file changed: {ui_path.name}")
2004
- file_mtimes[str(ui_path)] = mtime
2005
- _log("ui.json reloaded - reload page to update")
2006
- except FileNotFoundError:
2007
- pass
2008
-
2009
1836
 
2010
1837
  def get_session_token(request):
2011
1838
  return request.query.get("session") or request.headers.get("X-Session-Token") or request.cookies.get("llms-token")
@@ -2026,16 +1853,25 @@ class AppExtensions:
2026
1853
  self.server_add_post = []
2027
1854
  self.all_providers = [
2028
1855
  OpenAiCompatible,
2029
- OpenAiProvider,
2030
- AnthropicProvider,
2031
1856
  MistralProvider,
2032
1857
  GroqProvider,
2033
1858
  XaiProvider,
2034
1859
  CodestralProvider,
2035
- GoogleProvider,
2036
1860
  OllamaProvider,
2037
1861
  LMStudioProvider,
2038
1862
  ]
1863
+ self.aspect_ratios = {
1864
+ "1:1": "1024×1024",
1865
+ "2:3": "832×1248",
1866
+ "3:2": "1248×832",
1867
+ "3:4": "864×1184",
1868
+ "4:3": "1184×864",
1869
+ "4:5": "896×1152",
1870
+ "5:4": "1152×896",
1871
+ "9:16": "768×1344",
1872
+ "16:9": "1344×768",
1873
+ "21:9": "1536×672",
1874
+ }
2039
1875
 
2040
1876
 
2041
1877
  class ExtensionContext:
@@ -2043,18 +1879,43 @@ class ExtensionContext:
2043
1879
  self.app = app
2044
1880
  self.path = path
2045
1881
  self.name = os.path.basename(path)
1882
+ if self.name.endswith(".py"):
1883
+ self.name = self.name[:-3]
2046
1884
  self.ext_prefix = f"/ext/{self.name}"
1885
+ self.MOCK = MOCK
1886
+ self.MOCK_DIR = MOCK_DIR
1887
+ self.debug = DEBUG
1888
+ self.verbose = g_verbose
1889
+
1890
+ def chat_to_prompt(self, chat):
1891
+ return chat_to_prompt(chat)
1892
+
1893
+ def last_user_prompt(self, chat):
1894
+ return last_user_prompt(chat)
1895
+
1896
+ def save_image_to_cache(self, base64_data, filename, image_info):
1897
+ return save_image_to_cache(base64_data, filename, image_info)
1898
+
1899
+ def text_from_file(self, path):
1900
+ return text_from_file(path)
2047
1901
 
2048
1902
  def log(self, message):
2049
- print(f"[{self.name}] {message}", flush=True)
1903
+ if self.verbose:
1904
+ print(f"[{self.name}] {message}", flush=True)
1905
+ return message
1906
+
1907
+ def log_json(self, obj):
1908
+ if self.verbose:
1909
+ print(f"[{self.name}] {json.dumps(obj, indent=2)}", flush=True)
1910
+ return obj
2050
1911
 
2051
1912
  def dbg(self, message):
2052
- if DEBUG:
1913
+ if self.debug:
2053
1914
  print(f"DEBUG [{self.name}]: {message}", flush=True)
2054
1915
 
2055
1916
  def err(self, message, e):
2056
1917
  print(f"ERROR [{self.name}]: {message}", e)
2057
- if g_verbose:
1918
+ if self.verbose:
2058
1919
  print(traceback.format_exc(), flush=True)
2059
1920
 
2060
1921
  def add_provider(self, provider):
@@ -2122,6 +1983,33 @@ class ExtensionContext:
2122
1983
  return None
2123
1984
 
2124
1985
 
1986
+ def load_builtin_extensions():
1987
+ providers_path = _ROOT / "providers"
1988
+ if not providers_path.exists():
1989
+ return
1990
+
1991
+ for item in os.listdir(providers_path):
1992
+ if not item.endswith(".py") or item == "__init__.py":
1993
+ continue
1994
+
1995
+ item_path = providers_path / item
1996
+ module_name = item[:-3]
1997
+
1998
+ try:
1999
+ spec = importlib.util.spec_from_file_location(module_name, item_path)
2000
+ if spec and spec.loader:
2001
+ module = importlib.util.module_from_spec(spec)
2002
+ sys.modules[f"llms.providers.{module_name}"] = module
2003
+ spec.loader.exec_module(module)
2004
+
2005
+ install_func = getattr(module, "__install__", None)
2006
+ if callable(install_func):
2007
+ install_func(ExtensionContext(g_app, item_path))
2008
+ _log(f"Loaded builtin extension: {module_name}")
2009
+ except Exception as e:
2010
+ _err(f"Failed to load builtin extension {module_name}", e)
2011
+
2012
+
2125
2013
  def get_extensions_path():
2126
2014
  return os.path.join(Path.home(), ".llms", "extensions")
2127
2015
 
@@ -2240,7 +2128,7 @@ def run_extension_cli():
2240
2128
 
2241
2129
 
2242
2130
  def main():
2243
- global _ROOT, g_verbose, g_default_model, g_logprefix, g_providers, g_config, g_config_path, g_ui_path, g_app
2131
+ global _ROOT, g_verbose, g_default_model, g_logprefix, g_providers, g_config, g_config_path, g_app
2244
2132
 
2245
2133
  parser = argparse.ArgumentParser(description=f"llms v{VERSION}")
2246
2134
  parser.add_argument("--config", default=None, help="Path to config file", metavar="FILE")
@@ -2254,6 +2142,7 @@ def main():
2254
2142
  parser.add_argument("--image", default=None, help="Image input to use in chat completion")
2255
2143
  parser.add_argument("--audio", default=None, help="Audio input to use in chat completion")
2256
2144
  parser.add_argument("--file", default=None, help="File input to use in chat completion")
2145
+ parser.add_argument("--out", default=None, help="Image or Video Generation Request", metavar="MODALITY")
2257
2146
  parser.add_argument(
2258
2147
  "--args",
2259
2148
  default=None,
@@ -2276,7 +2165,8 @@ def main():
2276
2165
  parser.add_argument("--default", default=None, help="Configure the default model to use", metavar="MODEL")
2277
2166
 
2278
2167
  parser.add_argument("--init", action="store_true", help="Create a default llms.json")
2279
- parser.add_argument("--update", action="store_true", help="Update local models.dev providers.json")
2168
+ parser.add_argument("--update-providers", action="store_true", help="Update local models.dev providers.json")
2169
+ parser.add_argument("--update-extensions", action="store_true", help="Update installed extensions")
2280
2170
 
2281
2171
  parser.add_argument("--root", default=None, help="Change root directory for UI files", metavar="PATH")
2282
2172
  parser.add_argument("--logprefix", default="", help="Prefix used in log messages", metavar="PREFIX")
@@ -2299,6 +2189,15 @@ def main():
2299
2189
  metavar="EXTENSION",
2300
2190
  )
2301
2191
 
2192
+ parser.add_argument(
2193
+ "--update",
2194
+ nargs="?",
2195
+ const="ls",
2196
+ default=None,
2197
+ help="Update an extension (use 'all' to update all extensions)",
2198
+ metavar="EXTENSION",
2199
+ )
2200
+
2302
2201
  # Load parser extensions, go through all extensions and load their parser arguments
2303
2202
  init_extensions(parser)
2304
2203
 
@@ -2322,8 +2221,8 @@ def main():
2322
2221
  exit(1)
2323
2222
 
2324
2223
  home_config_path = home_llms_path("llms.json")
2325
- home_ui_path = home_llms_path("ui.json")
2326
2224
  home_providers_path = home_llms_path("providers.json")
2225
+ home_providers_extra_path = home_llms_path("providers-extra.json")
2327
2226
 
2328
2227
  if cli_args.init:
2329
2228
  if os.path.exists(home_config_path):
@@ -2332,17 +2231,17 @@ def main():
2332
2231
  asyncio.run(save_default_config(home_config_path))
2333
2232
  print(f"Created default config at {home_config_path}")
2334
2233
 
2335
- if os.path.exists(home_ui_path):
2336
- print(f"ui.json already exists at {home_ui_path}")
2337
- else:
2338
- asyncio.run(save_text_url(github_url("ui.json"), home_ui_path))
2339
- print(f"Created default ui config at {home_ui_path}")
2340
-
2341
2234
  if os.path.exists(home_providers_path):
2342
2235
  print(f"providers.json already exists at {home_providers_path}")
2343
2236
  else:
2344
2237
  asyncio.run(save_text_url(github_url("providers.json"), home_providers_path))
2345
2238
  print(f"Created default providers config at {home_providers_path}")
2239
+
2240
+ if os.path.exists(home_providers_extra_path):
2241
+ print(f"providers-extra.json already exists at {home_providers_extra_path}")
2242
+ else:
2243
+ asyncio.run(save_text_url(github_url("providers-extra.json"), home_providers_extra_path))
2244
+ print(f"Created default extra providers config at {home_providers_extra_path}")
2346
2245
  exit(0)
2347
2246
 
2348
2247
  if cli_args.providers:
@@ -2359,36 +2258,36 @@ def main():
2359
2258
  g_config = load_config_json(config_json)
2360
2259
 
2361
2260
  config_dir = os.path.dirname(g_config_path)
2362
- # look for ui.json in same directory as config
2363
- ui_path = os.path.join(config_dir, "ui.json")
2364
- if os.path.exists(ui_path):
2365
- g_ui_path = ui_path
2366
- else:
2367
- if not os.path.exists(home_ui_path):
2368
- ui_json = text_from_resource("ui.json")
2369
- with open(home_ui_path, "w", encoding="utf-8") as f:
2370
- f.write(ui_json)
2371
- _log(f"Created default ui config at {home_ui_path}")
2372
- g_ui_path = home_ui_path
2373
2261
 
2374
2262
  if not g_providers and os.path.exists(os.path.join(config_dir, "providers.json")):
2375
2263
  g_providers = json.loads(text_from_file(os.path.join(config_dir, "providers.json")))
2376
2264
 
2377
2265
  else:
2378
- # ensure llms.json and ui.json exist in home directory
2266
+ # ensure llms.json and providers.json exist in home directory
2379
2267
  asyncio.run(save_home_configs())
2380
2268
  g_config_path = home_config_path
2381
- g_ui_path = home_ui_path
2382
2269
  g_config = load_config_json(text_from_file(g_config_path))
2383
2270
 
2384
2271
  if not g_providers:
2385
2272
  g_providers = json.loads(text_from_file(home_providers_path))
2386
2273
 
2387
- if cli_args.update:
2274
+ if cli_args.update_providers:
2388
2275
  asyncio.run(update_providers(home_providers_path))
2389
2276
  print(f"Updated {home_providers_path}")
2390
2277
  exit(0)
2391
2278
 
2279
+ # if home_providers_path is older than 1 day, update providers list
2280
+ if (
2281
+ os.path.exists(home_providers_path)
2282
+ and (time.time() - os.path.getmtime(home_providers_path)) > 86400
2283
+ and os.environ.get("LLMS_DISABLE_UPDATE", "") != "1"
2284
+ ):
2285
+ try:
2286
+ asyncio.run(update_providers(home_providers_path))
2287
+ _log(f"Updated {home_providers_path}")
2288
+ except Exception as e:
2289
+ _err("Failed to update providers", e)
2290
+
2392
2291
  if cli_args.add is not None:
2393
2292
  if cli_args.add == "ls":
2394
2293
 
@@ -2484,6 +2383,42 @@ def main():
2484
2383
 
2485
2384
  exit(0)
2486
2385
 
2386
+ if cli_args.update:
2387
+ if cli_args.update == "ls":
2388
+ # List installed extensions
2389
+ extensions_path = get_extensions_path()
2390
+ extensions = os.listdir(extensions_path)
2391
+ if len(extensions) == 0:
2392
+ print("No extensions installed.")
2393
+ exit(0)
2394
+ print("Installed extensions:")
2395
+ for extension in extensions:
2396
+ print(f" {extension}")
2397
+
2398
+ print("\nUsage:")
2399
+ print(" llms --update <extension>")
2400
+ print(" llms --update all")
2401
+ exit(0)
2402
+
2403
+ async def update_extensions(extension_name):
2404
+ extensions_path = get_extensions_path()
2405
+ for extension in os.listdir(extensions_path):
2406
+ extension_path = os.path.join(extensions_path, extension)
2407
+ if os.path.isdir(extension_path):
2408
+ if extension_name != "all" and extension != extension_name:
2409
+ continue
2410
+ result = subprocess.run(["git", "pull"], cwd=extension_path, capture_output=True)
2411
+ if result.returncode != 0:
2412
+ print(f"Failed to update extension {extension}: {result.stderr.decode('utf-8')}")
2413
+ continue
2414
+ print(f"Updated extension {extension}")
2415
+ _log(result.stdout.decode("utf-8"))
2416
+
2417
+ asyncio.run(update_extensions(cli_args.update))
2418
+ exit(0)
2419
+
2420
+ load_builtin_extensions()
2421
+
2487
2422
  asyncio.run(reload_providers())
2488
2423
 
2489
2424
  install_extensions()
@@ -2560,10 +2495,6 @@ def main():
2560
2495
  # Start server
2561
2496
  port = int(cli_args.serve)
2562
2497
 
2563
- if not os.path.exists(g_ui_path):
2564
- print(f"UI not found at {g_ui_path}")
2565
- exit(1)
2566
-
2567
2498
  # Validate auth configuration if enabled
2568
2499
  auth_enabled = g_config.get("auth", {}).get("enabled", False)
2569
2500
  if auth_enabled:
@@ -2696,8 +2627,9 @@ def main():
2696
2627
  if provider:
2697
2628
  if data.get("enable", False):
2698
2629
  provider_config, msg = enable_provider(provider)
2699
- _log(f"Enabled provider {provider}")
2700
- await load_llms()
2630
+ _log(f"Enabled provider {provider} {msg}")
2631
+ if not msg:
2632
+ await load_llms()
2701
2633
  elif data.get("disable", False):
2702
2634
  disable_provider(provider)
2703
2635
  _log(f"Disabled provider {provider}")
@@ -3104,19 +3036,18 @@ def main():
3104
3036
 
3105
3037
  app.router.add_get("/ui/{path:.*}", ui_static, name="ui_static")
3106
3038
 
3107
- async def ui_config_handler(request):
3108
- with open(g_ui_path, encoding="utf-8") as f:
3109
- ui = json.load(f)
3110
- if "defaults" not in ui:
3111
- ui["defaults"] = g_config["defaults"]
3112
- enabled, disabled = provider_status()
3113
- ui["status"] = {"all": list(g_config["providers"].keys()), "enabled": enabled, "disabled": disabled}
3114
- # Add auth configuration
3115
- ui["requiresAuth"] = auth_enabled
3116
- ui["authType"] = "oauth" if auth_enabled else "apikey"
3117
- return web.json_response(ui)
3039
+ async def config_handler(request):
3040
+ ret = {}
3041
+ if "defaults" not in ret:
3042
+ ret["defaults"] = g_config["defaults"]
3043
+ enabled, disabled = provider_status()
3044
+ ret["status"] = {"all": list(g_config["providers"].keys()), "enabled": enabled, "disabled": disabled}
3045
+ # Add auth configuration
3046
+ ret["requiresAuth"] = auth_enabled
3047
+ ret["authType"] = "oauth" if auth_enabled else "apikey"
3048
+ return web.json_response(ret)
3118
3049
 
3119
- app.router.add_get("/config", ui_config_handler)
3050
+ app.router.add_get("/config", config_handler)
3120
3051
 
3121
3052
  async def not_found_handler(request):
3122
3053
  return web.Response(text="404: Not Found", status=404)
@@ -3145,7 +3076,7 @@ def main():
3145
3076
  async def start_background_tasks(app):
3146
3077
  """Start background tasks when the app starts"""
3147
3078
  # Start watching config files in the background
3148
- asyncio.create_task(watch_config_files(g_config_path, g_ui_path))
3079
+ asyncio.create_task(watch_config_files(g_config_path, home_providers_path))
3149
3080
 
3150
3081
  app.on_startup.append(start_background_tasks)
3151
3082
 
@@ -3225,6 +3156,7 @@ def main():
3225
3156
  or cli_args.image is not None
3226
3157
  or cli_args.audio is not None
3227
3158
  or cli_args.file is not None
3159
+ or cli_args.out is not None
3228
3160
  or len(extra_args) > 0
3229
3161
  ):
3230
3162
  try:
@@ -3235,6 +3167,12 @@ def main():
3235
3167
  chat = g_config["defaults"]["audio"]
3236
3168
  elif cli_args.file is not None:
3237
3169
  chat = g_config["defaults"]["file"]
3170
+ elif cli_args.out is not None:
3171
+ template = f"out:{cli_args.out}"
3172
+ if template not in g_config["defaults"]:
3173
+ print(f"Template for output modality '{cli_args.out}' not found")
3174
+ exit(1)
3175
+ chat = g_config["defaults"][template]
3238
3176
  if cli_args.chat is not None:
3239
3177
  chat_path = os.path.join(os.path.dirname(__file__), cli_args.chat)
3240
3178
  if not os.path.exists(chat_path):
@@ -3286,4 +3224,6 @@ def main():
3286
3224
 
3287
3225
 
3288
3226
  if __name__ == "__main__":
3227
+ if MOCK or DEBUG:
3228
+ print(f"MOCK={MOCK} or DEBUG={DEBUG}")
3289
3229
  main()