llms-py 3.0.1__py3-none-any.whl → 3.0.2__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 (35) hide show
  1. llms/{extensions/app/db_manager.py → db.py} +170 -15
  2. llms/extensions/app/__init__.py +95 -29
  3. llms/extensions/app/db.py +16 -124
  4. llms/extensions/app/ui/threadStore.mjs +20 -2
  5. llms/extensions/core_tools/__init__.py +37 -0
  6. llms/extensions/gallery/__init__.py +15 -13
  7. llms/extensions/gallery/db.py +117 -172
  8. llms/extensions/gallery/ui/index.mjs +1 -1
  9. llms/extensions/providers/__init__.py +3 -1
  10. llms/extensions/providers/anthropic.py +7 -3
  11. llms/extensions/providers/cerebras.py +37 -0
  12. llms/extensions/providers/chutes.py +1 -1
  13. llms/extensions/providers/google.py +131 -28
  14. llms/extensions/providers/nvidia.py +2 -2
  15. llms/extensions/providers/openai.py +2 -2
  16. llms/extensions/providers/openrouter.py +4 -2
  17. llms/llms.json +3 -0
  18. llms/main.py +81 -34
  19. llms/providers.json +1 -1
  20. llms/ui/ai.mjs +1 -1
  21. llms/ui/app.css +96 -3
  22. llms/ui/ctx.mjs +21 -0
  23. llms/ui/index.mjs +2 -0
  24. llms/ui/modules/chat/ChatBody.mjs +1 -0
  25. llms/ui/modules/chat/index.mjs +19 -1
  26. llms/ui/modules/icons.mjs +46 -0
  27. llms/ui/modules/layout.mjs +28 -0
  28. llms/ui/modules/model-selector.mjs +0 -40
  29. llms/ui/utils.mjs +9 -1
  30. {llms_py-3.0.1.dist-info → llms_py-3.0.2.dist-info}/METADATA +1 -1
  31. {llms_py-3.0.1.dist-info → llms_py-3.0.2.dist-info}/RECORD +35 -33
  32. {llms_py-3.0.1.dist-info → llms_py-3.0.2.dist-info}/WHEEL +0 -0
  33. {llms_py-3.0.1.dist-info → llms_py-3.0.2.dist-info}/entry_points.txt +0 -0
  34. {llms_py-3.0.1.dist-info → llms_py-3.0.2.dist-info}/licenses/LICENSE +0 -0
  35. {llms_py-3.0.1.dist-info → llms_py-3.0.2.dist-info}/top_level.txt +0 -0
@@ -79,16 +79,61 @@ def install_google(ctx):
79
79
  if "Authorization" in self.headers:
80
80
  del self.headers["Authorization"]
81
81
 
82
- async def chat(self, chat):
82
+ def provider_model(self, model):
83
+ if model.lower().startswith("gemini-"):
84
+ return model
85
+ return super().provider_model(model)
86
+
87
+ def model_info(self, model):
88
+ info = super().model_info(model)
89
+ if info:
90
+ return info
91
+ if model.lower().startswith("gemini-"):
92
+ return {
93
+ "id": model,
94
+ "name": model,
95
+ "cost": {"input": 0, "output": 0},
96
+ }
97
+ return None
98
+
99
+ async def chat(self, chat, context=None):
83
100
  chat["model"] = self.provider_model(chat["model"]) or chat["model"]
101
+ model_info = (context.get("modelInfo") if context is not None else None) or self.model_info(chat["model"])
84
102
 
85
103
  chat = await self.process_chat(chat)
86
104
  generation_config = {}
105
+ tools = None
106
+ supports_tool_calls = model_info.get("tool_call", False)
107
+
108
+ if "tools" in chat and supports_tool_calls:
109
+ function_declarations = []
110
+ gemini_tools = {}
111
+
112
+ for tool in chat["tools"]:
113
+ if tool["type"] == "function":
114
+ f = tool["function"]
115
+ function_declarations.append(
116
+ {
117
+ "name": f["name"],
118
+ "description": f.get("description"),
119
+ "parameters": f.get("parameters"),
120
+ }
121
+ )
122
+ elif tool["type"] == "file_search":
123
+ gemini_tools["file_search"] = tool["file_search"]
124
+
125
+ if function_declarations:
126
+ gemini_tools["function_declarations"] = function_declarations
127
+
128
+ tools = [gemini_tools] if gemini_tools else None
87
129
 
88
130
  # Filter out system messages and convert to proper Gemini format
89
131
  contents = []
90
132
  system_prompt = None
91
133
 
134
+ # Track tool call IDs to names for response mapping
135
+ tool_id_map = {}
136
+
92
137
  async with aiohttp.ClientSession() as session:
93
138
  for message in chat["messages"]:
94
139
  if message["role"] == "system":
@@ -101,8 +146,55 @@ def install_google(ctx):
101
146
  elif isinstance(content, str):
102
147
  system_prompt = content
103
148
  elif "content" in message:
149
+ role = "user"
150
+ if "role" in message:
151
+ if message["role"] == "user":
152
+ role = "user"
153
+ elif message["role"] == "assistant":
154
+ role = "model"
155
+ elif message["role"] == "tool":
156
+ role = "function"
157
+
158
+ parts = []
159
+
160
+ # Handle tool calls in assistant messages
161
+ if message.get("role") == "assistant" and "tool_calls" in message:
162
+ for tool_call in message["tool_calls"]:
163
+ tool_id_map[tool_call["id"]] = tool_call["function"]["name"]
164
+ parts.append(
165
+ {
166
+ "functionCall": {
167
+ "name": tool_call["function"]["name"],
168
+ "args": json.loads(tool_call["function"]["arguments"]),
169
+ }
170
+ }
171
+ )
172
+
173
+ # Handle tool responses from user
174
+ if message.get("role") == "tool":
175
+ # Gemini expects function response in 'functionResponse' part
176
+ # We need to find the name associated with this tool_call_id
177
+ tool_call_id = message.get("tool_call_id")
178
+ name = tool_id_map.get(tool_call_id)
179
+ # If we can't find the name (maybe from previous turn not in history or restart),
180
+ # we might have an issue. But let's try to proceed.
181
+ # Fallback: if we can't find the name, skip or try to infer?
182
+ # Gemini strict validation requires the name.
183
+ if name:
184
+ # content is the string response
185
+ # Some implementations pass the content directly.
186
+ # Google docs say: response: { "name": "...", "content": { ... } }
187
+ # Actually "response" field in functionResponse is a Struct/Map.
188
+ parts.append(
189
+ {
190
+ "functionResponse": {
191
+ "name": name,
192
+ "response": {"name": name, "content": message["content"]},
193
+ }
194
+ }
195
+ )
196
+
104
197
  if isinstance(message["content"], list):
105
- parts = []
106
198
  for item in message["content"]:
107
199
  if "type" in item:
108
200
  if item["type"] == "image_url" and "image_url" in item:
@@ -142,23 +234,14 @@ def install_google(ctx):
142
234
  if "text" in item:
143
235
  text = item["text"]
144
236
  parts.append({"text": text})
145
- if len(parts) > 0:
146
- contents.append(
147
- {
148
- "role": message["role"]
149
- if "role" in message and message["role"] == "user"
150
- else "model",
151
- "parts": parts,
152
- }
153
- )
154
- else:
155
- content = message["content"]
237
+ elif message["content"]: # String content
238
+ parts.append({"text": message["content"]})
239
+
240
+ if len(parts) > 0:
156
241
  contents.append(
157
242
  {
158
- "role": message["role"]
159
- if "role" in message and message["role"] == "user"
160
- else "model",
161
- "parts": [{"text": content}],
243
+ "role": role,
244
+ "parts": parts,
162
245
  }
163
246
  )
164
247
 
@@ -166,6 +249,9 @@ def install_google(ctx):
166
249
  "contents": contents,
167
250
  }
168
251
 
252
+ if tools:
253
+ gemini_chat["tools"] = tools
254
+
169
255
  if self.safety_settings:
170
256
  gemini_chat["safetySettings"] = self.safety_settings
171
257
 
@@ -192,18 +278,12 @@ def install_google(ctx):
192
278
  if len(generation_config) > 0:
193
279
  gemini_chat["generationConfig"] = generation_config
194
280
 
195
- if "tools" in chat:
196
- # gemini_chat["tools"] = chat["tools"]
197
- ctx.log("Error: tools not supported in Gemini")
198
- elif self.tools:
199
- # gemini_chat["tools"] = self.tools.copy()
200
- ctx.log("Error: tools not supported in Gemini")
201
-
202
281
  if "modalities" in chat:
203
282
  generation_config["responseModalities"] = [modality.upper() for modality in chat["modalities"]]
204
283
  if "image" in chat["modalities"] and "image_config" in chat:
205
284
  # delete thinkingConfig
206
- del generation_config["thinkingConfig"]
285
+ if "thinkingConfig" in generation_config:
286
+ del generation_config["thinkingConfig"]
207
287
  config_map = {
208
288
  "aspect_ratio": "aspectRatio",
209
289
  "image_size": "imageSize",
@@ -212,11 +292,16 @@ def install_google(ctx):
212
292
  config_map[k]: v for k, v in chat["image_config"].items() if k in config_map
213
293
  }
214
294
  if "audio" in chat["modalities"] and self.speech_config:
215
- del generation_config["thinkingConfig"]
295
+ if "thinkingConfig" in generation_config:
296
+ del generation_config["thinkingConfig"]
216
297
  generation_config["speechConfig"] = self.speech_config.copy()
217
298
  # Currently Google Audio Models only accept AUDIO
218
299
  generation_config["responseModalities"] = ["AUDIO"]
219
300
 
301
+ # Ensure generationConfig is set if we added anything to it
302
+ if len(generation_config) > 0:
303
+ gemini_chat["generationConfig"] = generation_config
304
+
220
305
  started_at = int(time.time() * 1000)
221
306
  gemini_chat_url = f"https://generativelanguage.googleapis.com/v1beta/models/{chat['model']}:generateContent?key={self.api_key}"
222
307
 
@@ -237,6 +322,8 @@ def install_google(ctx):
237
322
  timeout=aiohttp.ClientTimeout(total=120),
238
323
  ) as res:
239
324
  obj = await self.response_json(res)
325
+ if context is not None:
326
+ context["providerResponse"] = obj
240
327
  except Exception as e:
241
328
  ctx.log(f"Error: {res.status} {res.reason}: {e}")
242
329
  text = await res.text()
@@ -271,7 +358,7 @@ def install_google(ctx):
271
358
  "model": obj.get("modelVersion", chat["model"]),
272
359
  }
273
360
  choices = []
274
- for i, candidate in enumerate(obj["candidates"]):
361
+ for i, candidate in enumerate(obj.get("candidates", [])):
275
362
  role = "assistant"
276
363
  if "content" in candidate and "role" in candidate["content"]:
277
364
  role = "assistant" if candidate["content"]["role"] == "model" else candidate["content"]["role"]
@@ -281,6 +368,8 @@ def install_google(ctx):
281
368
  reasoning = ""
282
369
  images = []
283
370
  audios = []
371
+ tool_calls = []
372
+
284
373
  if "content" in candidate and "parts" in candidate["content"]:
285
374
  text_parts = []
286
375
  reasoning_parts = []
@@ -290,6 +379,16 @@ def install_google(ctx):
290
379
  reasoning_parts.append(part["text"])
291
380
  else:
292
381
  text_parts.append(part["text"])
382
+ if "functionCall" in part:
383
+ fc = part["functionCall"]
384
+ tool_calls.append(
385
+ {
386
+ "id": f"call_{len(tool_calls)}_{int(time.time())}", # Gemini doesn't return ID, generate one
387
+ "type": "function",
388
+ "function": {"name": fc["name"], "arguments": json.dumps(fc["args"])},
389
+ }
390
+ )
391
+
293
392
  if "inlineData" in part:
294
393
  inline_data = part["inlineData"]
295
394
  mime_type = inline_data.get("mimeType", "image/png")
@@ -354,7 +453,7 @@ def install_google(ctx):
354
453
  "finish_reason": candidate.get("finishReason", "stop"),
355
454
  "message": {
356
455
  "role": role,
357
- "content": content,
456
+ "content": content if content else None,
358
457
  },
359
458
  }
360
459
  if reasoning:
@@ -363,6 +462,10 @@ def install_google(ctx):
363
462
  choice["message"]["images"] = images
364
463
  if len(audios) > 0:
365
464
  choice["message"]["audios"] = audios
465
+ if len(tool_calls) > 0:
466
+ choice["message"]["tool_calls"] = tool_calls
467
+ # If we have tool calls, content can be null but message should probably exist
468
+
366
469
  choices.append(choice)
367
470
  response["choices"] = choices
368
471
  if "usageMetadata" in obj:
@@ -54,7 +54,7 @@ def install_nvidia(ctx):
54
54
  }
55
55
  raise Exception("No artifacts in response")
56
56
 
57
- async def chat(self, chat, provider=None):
57
+ async def chat(self, chat, provider=None, context=None):
58
58
  headers = self.get_headers(provider, chat)
59
59
  if provider is not None:
60
60
  chat["model"] = provider.provider_model(chat["model"]) or chat["model"]
@@ -100,6 +100,6 @@ def install_nvidia(ctx):
100
100
  data=json.dumps(gen_request),
101
101
  timeout=aiohttp.ClientTimeout(total=120),
102
102
  ) as response:
103
- return self.to_response(await self.response_json(response), chat, started_at)
103
+ return self.to_response(await self.response_json(response), chat, started_at, context=context)
104
104
 
105
105
  ctx.add_provider(NvidiaGenAi)
@@ -113,7 +113,7 @@ def install_openai(ctx):
113
113
  ctx.log(json.dumps(response, indent=2))
114
114
  raise Exception("No 'data' field in response.")
115
115
 
116
- async def chat(self, chat, provider=None):
116
+ async def chat(self, chat, provider=None, context=None):
117
117
  headers = self.get_headers(provider, chat)
118
118
 
119
119
  if chat["model"] in self.map_image_models:
@@ -145,7 +145,7 @@ def install_openai(ctx):
145
145
  text = await response.text()
146
146
  ctx.log(text[:1024] + (len(text) > 1024 and "..." or ""))
147
147
  if response.status < 300:
148
- return ctx.log_json(await self.to_response(json.loads(text), chat, started_at))
148
+ return ctx.log_json(await self.to_response(json.loads(text), chat, started_at, context=context))
149
149
  else:
150
150
  raise Exception(f"Failed to generate image {response.status}")
151
151
 
@@ -39,7 +39,7 @@ def install_openrouter(ctx):
39
39
 
40
40
  return response
41
41
 
42
- async def chat(self, chat, provider=None):
42
+ async def chat(self, chat, provider=None, context=None):
43
43
  headers = self.get_headers(provider, chat)
44
44
  if provider is not None:
45
45
  chat["model"] = provider.provider_model(chat["model"]) or chat["model"]
@@ -67,6 +67,8 @@ def install_openrouter(ctx):
67
67
  ) as response:
68
68
  if metadata:
69
69
  chat["metadata"] = metadata
70
- return ctx.log_json(self.to_response(await self.response_json(response), chat, started_at))
70
+ return ctx.log_json(
71
+ self.to_response(await self.response_json(response), chat, started_at, context=context)
72
+ )
71
73
 
72
74
  ctx.add_provider(OpenRouterGenerator)
llms/llms.json CHANGED
@@ -145,6 +145,9 @@
145
145
  "groq": {
146
146
  "enabled": true
147
147
  },
148
+ "cerebras": {
149
+ "enabled": true
150
+ },
148
151
  "codestral": {
149
152
  "enabled": true,
150
153
  "id": "codestral",
llms/main.py CHANGED
@@ -41,7 +41,7 @@ try:
41
41
  except ImportError:
42
42
  HAS_PIL = False
43
43
 
44
- VERSION = "3.0.1"
44
+ VERSION = "3.0.2"
45
45
  _ROOT = None
46
46
  DEBUG = os.getenv("DEBUG") == "1"
47
47
  MOCK = os.getenv("MOCK") == "1"
@@ -375,7 +375,7 @@ async def process_chat(chat, provider_id=None):
375
375
  if "stream" not in chat:
376
376
  chat["stream"] = False
377
377
  # Some providers don't support empty tools
378
- if "tools" in chat and len(chat["tools"]) == 0:
378
+ if "tools" in chat and (chat["tools"] is None or len(chat["tools"]) == 0):
379
379
  del chat["tools"]
380
380
  if "messages" not in chat:
381
381
  return chat
@@ -618,7 +618,7 @@ def save_bytes_to_cache(base64_data, filename, file_info, ignore_info=False):
618
618
  _dbg(f"Cached bytes exists: {relative_path}")
619
619
  if ignore_info:
620
620
  return url, None
621
- return url, json.load(open(info_path))
621
+ return url, json_from_file(info_path)
622
622
 
623
623
  os.makedirs(os.path.dirname(full_path), exist_ok=True)
624
624
 
@@ -665,7 +665,7 @@ def save_image_to_cache(base64_data, filename, image_info, ignore_info=False):
665
665
  _dbg(f"Saved image exists: {relative_path}")
666
666
  if ignore_info:
667
667
  return url, None
668
- return url, json.load(open(info_path))
668
+ return url, json_from_file(info_path)
669
669
 
670
670
  os.makedirs(os.path.dirname(full_path), exist_ok=True)
671
671
 
@@ -870,7 +870,7 @@ class GeneratorBase:
870
870
  def to_response(self, response, chat, started_at):
871
871
  raise NotImplementedError
872
872
 
873
- async def chat(self, chat, provider=None):
873
+ async def chat(self, chat, provider=None, context=None):
874
874
  return {
875
875
  "choices": [
876
876
  {
@@ -1030,7 +1030,7 @@ class OpenAiCompatible:
1030
1030
  def response_json(self, response):
1031
1031
  return response_json(response)
1032
1032
 
1033
- def to_response(self, response, chat, started_at):
1033
+ def to_response(self, response, chat, started_at, context=None):
1034
1034
  if "metadata" not in response:
1035
1035
  response["metadata"] = {}
1036
1036
  response["metadata"]["duration"] = int((time.time() - started_at) * 1000)
@@ -1038,6 +1038,8 @@ class OpenAiCompatible:
1038
1038
  pricing = self.model_cost(chat["model"])
1039
1039
  if pricing and "input" in pricing and "output" in pricing:
1040
1040
  response["metadata"]["pricing"] = f"{pricing['input']}/{pricing['output']}"
1041
+ if context is not None:
1042
+ context["providerResponse"] = response
1041
1043
  return response
1042
1044
 
1043
1045
  def chat_summary(self, chat):
@@ -1046,17 +1048,18 @@ class OpenAiCompatible:
1046
1048
  def process_chat(self, chat, provider_id=None):
1047
1049
  return process_chat(chat, provider_id)
1048
1050
 
1049
- async def chat(self, chat):
1051
+ async def chat(self, chat, context=None):
1050
1052
  chat["model"] = self.provider_model(chat["model"]) or chat["model"]
1051
1053
 
1052
- if "modalities" in chat:
1053
- for modality in chat.get("modalities", []):
1054
+ modalities = chat.get("modalities") or []
1055
+ if len(modalities) > 0:
1056
+ for modality in modalities:
1054
1057
  # use default implementation for text modalities
1055
1058
  if modality == "text":
1056
1059
  continue
1057
1060
  modality_provider = self.modalities.get(modality)
1058
1061
  if modality_provider:
1059
- return await modality_provider.chat(chat, self)
1062
+ return await modality_provider.chat(chat, self, context=context)
1060
1063
  else:
1061
1064
  raise Exception(f"Provider {self.name} does not support '{modality}' modality")
1062
1065
 
@@ -1110,7 +1113,7 @@ class OpenAiCompatible:
1110
1113
  self.chat_url, headers=self.headers, data=json.dumps(chat), timeout=aiohttp.ClientTimeout(total=120)
1111
1114
  ) as response:
1112
1115
  chat["metadata"] = metadata
1113
- return self.to_response(await response_json(response), chat, started_at)
1116
+ return self.to_response(await response_json(response), chat, started_at, context=context)
1114
1117
 
1115
1118
 
1116
1119
  class MistralProvider(OpenAiCompatible):
@@ -1282,6 +1285,9 @@ def api_providers():
1282
1285
 
1283
1286
 
1284
1287
  def to_error_message(e):
1288
+ # check if has 'message' attribute
1289
+ if hasattr(e, "message"):
1290
+ return e.message
1285
1291
  return str(e)
1286
1292
 
1287
1293
 
@@ -1375,21 +1381,7 @@ async def g_chat_completion(chat, context=None):
1375
1381
  accumulated_cost = 0.0
1376
1382
 
1377
1383
  # Inject global tools if present
1378
- current_chat = chat.copy()
1379
- if g_app.tool_definitions:
1380
- only_tools_str = context.get("tools", "all")
1381
- include_all_tools = only_tools_str == "all"
1382
- only_tools = only_tools_str.split(",")
1383
-
1384
- if include_all_tools or len(only_tools) > 0:
1385
- if "tools" not in current_chat:
1386
- current_chat["tools"] = []
1387
-
1388
- existing_tools = {t["function"]["name"] for t in current_chat["tools"]}
1389
- for tool_def in g_app.tool_definitions:
1390
- name = tool_def["function"]["name"]
1391
- if name not in existing_tools and (include_all_tools or name in only_tools):
1392
- current_chat["tools"].append(tool_def)
1384
+ current_chat = g_app.create_chat_with_tools(chat, use_tools=context.get("tools", "all"))
1393
1385
 
1394
1386
  # Apply pre-chat filters ONCE
1395
1387
  context["chat"] = current_chat
@@ -1405,7 +1397,7 @@ async def g_chat_completion(chat, context=None):
1405
1397
  if should_cancel_thread(context):
1406
1398
  return
1407
1399
 
1408
- response = await provider.chat(current_chat)
1400
+ response = await provider.chat(current_chat, context=context)
1409
1401
 
1410
1402
  if should_cancel_thread(context):
1411
1403
  return
@@ -1427,8 +1419,9 @@ async def g_chat_completion(chat, context=None):
1427
1419
  choice = response.get("choices", [])[0] if response.get("choices") else {}
1428
1420
  message = choice.get("message", {})
1429
1421
  tool_calls = message.get("tool_calls")
1422
+ supports_tool_calls = model_info.get("tool_call", False)
1430
1423
 
1431
- if tool_calls:
1424
+ if tool_calls and supports_tool_calls:
1432
1425
  # Append the assistant's message with tool calls to history
1433
1426
  if "messages" not in current_chat:
1434
1427
  current_chat["messages"] = []
@@ -2179,6 +2172,13 @@ def text_from_file(filename):
2179
2172
  return None
2180
2173
 
2181
2174
 
2175
+ def json_from_file(filename):
2176
+ if os.path.exists(filename):
2177
+ with open(filename, encoding="utf-8") as f:
2178
+ return json.load(f)
2179
+ return None
2180
+
2181
+
2182
2182
  async def text_from_resource_or_url(filename):
2183
2183
  text = text_from_resource(filename)
2184
2184
  if not text:
@@ -2472,6 +2472,27 @@ class AppExtensions:
2472
2472
  _dbg(f"exit({exit_code})")
2473
2473
  sys.exit(exit_code)
2474
2474
 
2475
+ def create_chat_with_tools(self, chat, use_tools="all"):
2476
+ # Inject global tools if present
2477
+ current_chat = chat.copy()
2478
+ tools = current_chat.get("tools")
2479
+ if tools is None:
2480
+ tools = current_chat["tools"] = []
2481
+ if self.tool_definitions and len(tools) == 0:
2482
+ include_all_tools = use_tools == "all"
2483
+ only_tools_list = use_tools.split(",")
2484
+
2485
+ if include_all_tools or len(only_tools_list) > 0:
2486
+ if "tools" not in current_chat:
2487
+ current_chat["tools"] = []
2488
+
2489
+ existing_tools = {t["function"]["name"] for t in current_chat["tools"]}
2490
+ for tool_def in self.tool_definitions:
2491
+ name = tool_def["function"]["name"]
2492
+ if name not in existing_tools and (include_all_tools or name in only_tools_list):
2493
+ current_chat["tools"].append(tool_def)
2494
+ return current_chat
2495
+
2475
2496
 
2476
2497
  def handler_name(handler):
2477
2498
  if hasattr(handler, "__name__"):
@@ -2496,6 +2517,7 @@ class ExtensionContext:
2496
2517
  self.verbose = g_verbose
2497
2518
  self.aspect_ratios = app.aspect_ratios
2498
2519
  self.request_args = app.request_args
2520
+ self.disabled = False
2499
2521
 
2500
2522
  def chat_to_prompt(self, chat):
2501
2523
  return chat_to_prompt(chat)
@@ -2521,6 +2543,9 @@ class ExtensionContext:
2521
2543
  def text_from_file(self, path):
2522
2544
  return text_from_file(path)
2523
2545
 
2546
+ def json_from_file(self, path):
2547
+ return json_from_file(path)
2548
+
2524
2549
  def log(self, message):
2525
2550
  if self.verbose:
2526
2551
  print(f"[{self.name}] {message}", flush=True)
@@ -2626,6 +2651,9 @@ class ExtensionContext:
2626
2651
  def get_cache_path(self, path=""):
2627
2652
  return get_cache_path(path)
2628
2653
 
2654
+ def get_file_mime_type(self, filename):
2655
+ return get_file_mime_type(filename)
2656
+
2629
2657
  def chat_request(self, template=None, text=None, model=None, system_prompt=None):
2630
2658
  return self.app.chat_request(template=template, text=text, model=model, system_prompt=system_prompt)
2631
2659
 
@@ -2668,6 +2696,9 @@ class ExtensionContext:
2668
2696
  def to_content(self, result):
2669
2697
  return to_content(result)
2670
2698
 
2699
+ def create_chat_with_tools(self, chat, use_tools="all"):
2700
+ return self.app.create_chat_with_tools(chat, use_tools)
2701
+
2671
2702
 
2672
2703
  def get_extensions_path():
2673
2704
  return os.getenv("LLMS_EXTENSIONS_DIR", os.path.join(Path.home(), ".llms", "extensions"))
@@ -2787,6 +2818,10 @@ def install_extensions():
2787
2818
  else:
2788
2819
  _dbg(f"Extension {init_file} not found")
2789
2820
 
2821
+ if ctx.disabled:
2822
+ _log(f"Extension {item} was disabled")
2823
+ continue
2824
+
2790
2825
  # if ui folder exists, serve as static files at /ext/{item}/
2791
2826
  ui_path = os.path.join(item_path, "ui")
2792
2827
  if os.path.exists(ui_path):
@@ -3366,7 +3401,7 @@ def main():
3366
3401
  # if file and its .info.json already exists, return it
3367
3402
  info_path = os.path.splitext(full_path)[0] + ".info.json"
3368
3403
  if os.path.exists(full_path) and os.path.exists(info_path):
3369
- return web.json_response(json.load(open(info_path)))
3404
+ return web.json_response(json_from_file(info_path))
3370
3405
 
3371
3406
  os.makedirs(os.path.dirname(full_path), exist_ok=True)
3372
3407
 
@@ -3415,9 +3450,9 @@ def main():
3415
3450
  async def cache_handler(request):
3416
3451
  path = request.match_info["tail"]
3417
3452
  full_path = get_cache_path(path)
3453
+ info_path = os.path.splitext(full_path)[0] + ".info.json"
3418
3454
 
3419
3455
  if "info" in request.query:
3420
- info_path = os.path.splitext(full_path)[0] + ".info.json"
3421
3456
  if not os.path.exists(info_path):
3422
3457
  return web.Response(text="404: Not Found", status=404)
3423
3458
 
@@ -3446,11 +3481,23 @@ def main():
3446
3481
  except Exception:
3447
3482
  return web.Response(text="403: Forbidden", status=403)
3448
3483
 
3449
- with open(full_path, "rb") as f:
3450
- content = f.read()
3451
-
3452
3484
  mimetype = get_file_mime_type(full_path)
3453
- return web.Response(body=content, content_type=mimetype)
3485
+ if "download" in request.query:
3486
+ # download file as an attachment
3487
+ info = json_from_file(info_path) or {}
3488
+ mimetype = info.get("type", mimetype)
3489
+ filename = info.get("name") or os.path.basename(full_path)
3490
+ mtime = info.get("date", os.path.getmtime(full_path))
3491
+ mdate = datetime.fromtimestamp(mtime).isoformat()
3492
+ return web.FileResponse(
3493
+ full_path,
3494
+ headers={
3495
+ "Content-Disposition": f'attachment; filename="{filename}"; modification-date="{mdate}"',
3496
+ "Content-Type": mimetype,
3497
+ },
3498
+ )
3499
+ else:
3500
+ return web.FileResponse(full_path, headers={"Content-Type": mimetype})
3454
3501
 
3455
3502
  app.router.add_get("/~cache/{tail:.*}", cache_handler)
3456
3503