lm-deluge 0.0.12__py3-none-any.whl → 0.0.14__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.

Potentially problematic release.


This version of lm-deluge might be problematic. Click here for more details.

lm_deluge/prompt.py CHANGED
@@ -1,12 +1,15 @@
1
1
  import io
2
2
  import json
3
- import tiktoken
4
- import xxhash
5
3
  from dataclasses import dataclass, field
6
4
  from pathlib import Path
7
- from typing import Literal
8
- from lm_deluge.models import APIModel
5
+ from typing import Literal, Sequence
6
+
7
+ import tiktoken
8
+ import xxhash
9
+
10
+ from lm_deluge.file import File
9
11
  from lm_deluge.image import Image
12
+ from lm_deluge.models import APIModel
10
13
 
11
14
  CachePattern = Literal[
12
15
  "tools_only",
@@ -99,24 +102,58 @@ class ToolCall:
99
102
  @dataclass(slots=True)
100
103
  class ToolResult:
101
104
  tool_call_id: str # references the ToolCall.id
102
- result: str # tool execution result
105
+ result: (
106
+ str | dict | list[dict]
107
+ ) # tool execution result - can be string or list for images
103
108
  type: str = field(init=False, default="tool_result")
104
109
 
105
110
  @property
106
111
  def fingerprint(self) -> str:
107
- return xxhash.xxh64(f"{self.tool_call_id}:{self.result}".encode()).hexdigest()
112
+ result_str = (
113
+ json.dumps(self.result, sort_keys=True)
114
+ if isinstance(self.result, list) or isinstance(self.result, dict)
115
+ else str(self.result)
116
+ )
117
+ return xxhash.xxh64(f"{self.tool_call_id}:{result_str}".encode()).hexdigest()
108
118
 
109
119
  # ── provider-specific emission ────────────────────────────────────────────
110
120
  def oa_chat(
111
121
  self,
112
122
  ) -> dict: # OpenAI Chat Completions - tool results are separate messages
113
- return {"tool_call_id": self.tool_call_id, "content": self.result}
123
+ content = (
124
+ json.dumps(self.result) if isinstance(self.result, list) else self.result
125
+ )
126
+ return {"tool_call_id": self.tool_call_id, "content": content}
114
127
 
115
128
  def oa_resp(self) -> dict: # OpenAI Responses
129
+ # Check if this is a computer use output (special case)
130
+ if isinstance(self.result, dict) and self.result.get("_computer_use_output"):
131
+ # This is a computer use output, emit it properly
132
+ output_data = self.result.copy()
133
+ output_data.pop("_computer_use_output") # Remove marker
134
+
135
+ result = {
136
+ "type": "computer_call_output",
137
+ "call_id": self.tool_call_id,
138
+ "output": output_data.get("output", {}),
139
+ }
140
+
141
+ # Add acknowledged safety checks if present
142
+ if "acknowledged_safety_checks" in output_data:
143
+ result["acknowledged_safety_checks"] = output_data[
144
+ "acknowledged_safety_checks"
145
+ ]
146
+
147
+ return result
148
+
149
+ # Regular function result
150
+ result = (
151
+ json.dumps(self.result) if isinstance(self.result, list) else self.result
152
+ )
116
153
  return {
117
154
  "type": "function_result",
118
155
  "call_id": self.tool_call_id,
119
- "result": self.result,
156
+ "result": result,
120
157
  }
121
158
 
122
159
  def anthropic(self) -> dict: # Anthropic Messages
@@ -169,7 +206,7 @@ class Thinking:
169
206
  return {"type": "text", "text": f"[Thinking: {self.content}]"}
170
207
 
171
208
 
172
- Part = Text | Image | ToolCall | ToolResult | Thinking
209
+ Part = Text | Image | File | ToolCall | ToolResult | Thinking
173
210
 
174
211
 
175
212
  ###############################################################################
@@ -212,6 +249,11 @@ class Message:
212
249
  """Get all image parts with proper typing."""
213
250
  return [part for part in self.parts if part.type == "image"] # type: ignore
214
251
 
252
+ @property
253
+ def files(self) -> list[File]:
254
+ """Get all file parts with proper typing."""
255
+ return [part for part in self.parts if part.type == "file"] # type: ignore
256
+
215
257
  @property
216
258
  def thinking_parts(self) -> list["Thinking"]:
217
259
  """Get all thinking parts with proper typing."""
@@ -228,6 +270,9 @@ class Message:
228
270
  elif isinstance(p, Image): # Image – redact the bytes, keep a hint
229
271
  w, h = p.size
230
272
  content_blocks.append({"type": "image", "tag": f"<Image ({w}×{h})>"})
273
+ elif isinstance(p, File): # File – redact the bytes, keep a hint
274
+ size = p.size
275
+ content_blocks.append({"type": "file", "tag": f"<File ({size} bytes)>"})
231
276
  elif isinstance(p, ToolCall):
232
277
  content_blocks.append(
233
278
  {
@@ -262,6 +307,9 @@ class Message:
262
307
  elif p["type"] == "image":
263
308
  # We only stored a placeholder tag, so keep that placeholder.
264
309
  parts.append(Image(p["tag"], detail="low"))
310
+ elif p["type"] == "file":
311
+ # We only stored a placeholder tag, so keep that placeholder.
312
+ parts.append(File(p["tag"]))
265
313
  elif p["type"] == "tool_call":
266
314
  parts.append(
267
315
  ToolCall(id=p["id"], name=p["name"], arguments=p["arguments"])
@@ -306,6 +354,20 @@ class Message:
306
354
  self.parts.append(img)
307
355
  return self
308
356
 
357
+ def add_file(
358
+ self,
359
+ data: bytes | str | Path | io.BytesIO,
360
+ *,
361
+ media_type: str | None = None,
362
+ filename: str | None = None,
363
+ ) -> "Message":
364
+ """
365
+ Append a file block and return self for chaining.
366
+ """
367
+ file = File(data, media_type=media_type, filename=filename)
368
+ self.parts.append(file)
369
+ return self
370
+
309
371
  def add_tool_call(self, id: str, name: str, arguments: dict) -> "Message":
310
372
  """Append a tool call block and return self for chaining."""
311
373
  self.parts.append(ToolCall(id=id, name=name, arguments=arguments))
@@ -328,12 +390,15 @@ class Message:
328
390
  text: str | None = None,
329
391
  *,
330
392
  image: str | bytes | Path | io.BytesIO | None = None,
393
+ file: str | bytes | Path | io.BytesIO | None = None,
331
394
  ) -> "Message":
332
395
  res = cls("user", [])
333
396
  if text is not None:
334
397
  res.add_text(text)
335
398
  if image is not None:
336
399
  res.add_image(image)
400
+ if file is not None:
401
+ res.add_file(file)
337
402
  return res
338
403
 
339
404
  @classmethod
@@ -369,6 +434,19 @@ class Message:
369
434
  part_list.append(Text(item["text"]))
370
435
  elif item["type"] == "image_url":
371
436
  part_list.append(Image(data=item["image_url"]["url"]))
437
+ elif item["type"] == "file":
438
+ file_data = item["file"]
439
+ if "file_id" in file_data:
440
+ # Handle file ID reference (not implemented yet)
441
+ part_list.append(File(data=file_data["file_id"]))
442
+ elif "file_data" in file_data:
443
+ # Handle base64 file data
444
+ part_list.append(
445
+ File(
446
+ data=file_data["file_data"],
447
+ filename=file_data.get("filename"),
448
+ )
449
+ )
372
450
  parts = part_list
373
451
 
374
452
  # Handle tool calls (assistant messages)
@@ -428,6 +506,14 @@ class Message:
428
506
 
429
507
  def oa_resp(self) -> dict:
430
508
  content = [p.oa_resp() for p in self.parts]
509
+ # For OpenAI Responses API, handle tool results specially
510
+ if self.role == "tool" or (
511
+ self.role == "user" and any(isinstance(p, ToolResult) for p in self.parts)
512
+ ):
513
+ # Tool results are returned directly, not wrapped in a message
514
+ # This handles computer_call_output when stored as ToolResult
515
+ if len(self.parts) == 1 and isinstance(self.parts[0], ToolResult):
516
+ return self.parts[0].oa_resp()
431
517
  return {"role": self.role, "content": content}
432
518
 
433
519
  def anthropic(self) -> dict:
@@ -469,11 +555,17 @@ class Conversation:
469
555
 
470
556
  @classmethod
471
557
  def user(
472
- cls, text: str, *, image: bytes | str | Path | None = None
558
+ cls,
559
+ text: str,
560
+ *,
561
+ image: bytes | str | Path | None = None,
562
+ file: bytes | str | Path | None = None,
473
563
  ) -> "Conversation":
474
- msg = (
475
- Message.user(text) if image is None else Message.user(text).add_image(image)
476
- )
564
+ msg = Message.user(text)
565
+ if image is not None:
566
+ msg.add_image(image)
567
+ if file is not None:
568
+ msg.add_file(file)
477
569
  return cls([msg])
478
570
 
479
571
  @classmethod
@@ -522,7 +614,37 @@ class Conversation:
522
614
 
523
615
  def to_openai_responses(self) -> dict:
524
616
  # OpenAI Responses = single “input” array, role must be user/assistant
525
- return {"input": [m.oa_resp() for m in self.messages if m.role != "system"]}
617
+ input_items = []
618
+
619
+ for m in self.messages:
620
+ if m.role == "system":
621
+ continue
622
+ elif m.role == "assistant":
623
+ # For assistant messages, extract computer calls as separate items
624
+ text_parts = []
625
+ for p in m.parts:
626
+ if isinstance(p, ToolCall) and p.name.startswith("_computer_"):
627
+ # Computer calls become separate items in the input array
628
+ action_type = p.name.replace("_computer_", "")
629
+ input_items.append(
630
+ {
631
+ "type": "computer_call",
632
+ "call_id": p.id,
633
+ "action": {"type": action_type, **p.arguments},
634
+ }
635
+ )
636
+ elif isinstance(p, Text):
637
+ text_parts.append({"type": "output_text", "text": p.text})
638
+ # TODO: Handle other part types as needed
639
+
640
+ # Add message if it has text content
641
+ if text_parts:
642
+ input_items.append({"role": m.role, "content": text_parts})
643
+ else:
644
+ # User and tool messages use normal format
645
+ input_items.append(m.oa_resp())
646
+
647
+ return {"input": input_items}
526
648
 
527
649
  def to_anthropic(
528
650
  self, cache_pattern: CachePattern | None = None
@@ -605,6 +727,9 @@ class Conversation:
605
727
  if isinstance(part, Image):
606
728
  # Force conversion to bytes if not already
607
729
  part.data = part._bytes()
730
+ elif isinstance(part, File):
731
+ # Force conversion to bytes if not already
732
+ part.data = part._bytes()
608
733
  return self
609
734
 
610
735
  def _add_cache_control_to_message(self, message: dict) -> None:
@@ -693,6 +818,11 @@ class Conversation:
693
818
  content_blocks.append(
694
819
  {"type": "image", "tag": f"<Image ({w}×{h})>"}
695
820
  )
821
+ elif isinstance(p, File): # File – redact the bytes, keep a hint
822
+ size = p.size
823
+ content_blocks.append(
824
+ {"type": "file", "tag": f"<File ({size} bytes)>"}
825
+ )
696
826
  elif isinstance(p, ToolCall):
697
827
  content_blocks.append(
698
828
  {
@@ -723,7 +853,7 @@ class Conversation:
723
853
 
724
854
  for m in payload.get("messages", []):
725
855
  role: Role = m["role"] # 'system' | 'user' | 'assistant'
726
- parts: list[Text | Image | ToolCall | ToolResult | Thinking] = []
856
+ parts: list[Part] = []
727
857
 
728
858
  for p in m["content"]:
729
859
  if p["type"] == "text":
@@ -732,6 +862,9 @@ class Conversation:
732
862
  # We only stored a placeholder tag, so keep that placeholder.
733
863
  # You could raise instead if real image bytes are required.
734
864
  parts.append(Image(p["tag"], detail="low"))
865
+ elif p["type"] == "file":
866
+ # We only stored a placeholder tag, so keep that placeholder.
867
+ parts.append(File(p["tag"]))
735
868
  elif p["type"] == "tool_call":
736
869
  parts.append(
737
870
  ToolCall(id=p["id"], name=p["name"], arguments=p["arguments"])
@@ -750,6 +883,14 @@ class Conversation:
750
883
  return cls(msgs)
751
884
 
752
885
 
886
+ def prompts_to_conversations(prompts: Sequence[str | list[dict] | Conversation]):
887
+ if any(isinstance(x, list) for x in prompts):
888
+ raise ValueError("can't convert list[dict] to conversation yet")
889
+ return [ # type: ignore
890
+ Conversation.user(p) if isinstance(p, str) else p for p in prompts
891
+ ]
892
+
893
+
753
894
  ###############################################################################
754
895
  # --------------------------------------------------------------------------- #
755
896
  # Basic usage examples #
lm_deluge/rerank.py CHANGED
@@ -1,10 +1,12 @@
1
1
  ### specific utility for cohere rerank api
2
- import os
3
- import aiohttp
4
- from tqdm.auto import tqdm
5
2
  import asyncio
3
+ import os
6
4
  import time
7
5
  from dataclasses import dataclass
6
+
7
+ import aiohttp
8
+ from tqdm.auto import tqdm
9
+
8
10
  from .tracker import StatusTracker
9
11
 
10
12
  registry = [
@@ -25,7 +27,6 @@ class RerankingRequest:
25
27
  top_k: int,
26
28
  attempts_left: int,
27
29
  status_tracker: StatusTracker,
28
- retry_queue: asyncio.Queue,
29
30
  request_timeout: int,
30
31
  pbar: tqdm | None = None,
31
32
  ):
@@ -36,7 +37,6 @@ class RerankingRequest:
36
37
  self.top_k = top_k
37
38
  self.attempts_left = attempts_left
38
39
  self.status_tracker = status_tracker
39
- self.retry_queue = retry_queue
40
40
  self.request_timeout = request_timeout
41
41
  self.pbar = pbar
42
42
  self.result = []
@@ -63,7 +63,8 @@ class RerankingRequest:
63
63
  print(error_to_print)
64
64
  if self.attempts_left > 0:
65
65
  self.attempts_left -= 1
66
- self.retry_queue.put_nowait(self)
66
+ assert self.status_tracker.retry_queue
67
+ self.status_tracker.retry_queue.put_nowait(self)
67
68
  return
68
69
  else:
69
70
  print(f"Task {self.task_id} out of tries.")
@@ -203,8 +204,12 @@ async def rerank_parallel_async(
203
204
  seconds_to_sleep_each_loop = 0.003 # so concurrent tasks can run
204
205
 
205
206
  # initialize trackers
206
- retry_queue = asyncio.Queue()
207
- status_tracker = StatusTracker()
207
+ # retry_queue = asyncio.Queue()
208
+ status_tracker = StatusTracker(
209
+ max_tokens_per_minute=10_000_000,
210
+ max_requests_per_minute=max_requests_per_minute,
211
+ max_concurrent_requests=1_000,
212
+ )
208
213
  next_request = None # variable to hold the next request to call
209
214
 
210
215
  # initialize available capacity counts
@@ -222,8 +227,10 @@ async def rerank_parallel_async(
222
227
  while True:
223
228
  # get next request (if one is not already waiting for capacity)
224
229
  if next_request is None:
225
- if not retry_queue.empty():
226
- next_request = retry_queue.get_nowait()
230
+ assert status_tracker.retry_queue
231
+
232
+ if not status_tracker.retry_queue.empty():
233
+ next_request = status_tracker.retry_queue.get_nowait()
227
234
  print(f"Retrying request {next_request.task_id}.")
228
235
  elif prompts_not_finished:
229
236
  try:
@@ -237,7 +244,6 @@ async def rerank_parallel_async(
237
244
  top_k=top_k,
238
245
  attempts_left=max_attempts,
239
246
  status_tracker=status_tracker,
240
- retry_queue=retry_queue,
241
247
  request_timeout=request_timeout,
242
248
  pbar=progress_bar,
243
249
  )
@@ -246,7 +252,7 @@ async def rerank_parallel_async(
246
252
 
247
253
  except StopIteration:
248
254
  prompts_not_finished = False
249
- print("API requests finished, only retries remain.")
255
+ # print("API requests finished, only retries remain.")
250
256
 
251
257
  # update available capacity
252
258
  current_time = time.time()
lm_deluge/tool.py CHANGED
@@ -4,7 +4,7 @@ import asyncio
4
4
 
5
5
  from fastmcp import Client # pip install fastmcp >= 2.0
6
6
  from mcp.types import Tool as MCPTool
7
- from pydantic import BaseModel, Field
7
+ from pydantic import BaseModel, Field, field_validator
8
8
 
9
9
 
10
10
  async def _load_all_mcp_tools(client: Client) -> list["Tool"]:
@@ -46,6 +46,16 @@ class Tool(BaseModel):
46
46
  # if desired, can provide a callable to run the tool
47
47
  run: Callable | None = None
48
48
 
49
+ @field_validator("name")
50
+ @classmethod
51
+ def validate_name(cls, v: str) -> str:
52
+ if v.startswith("_computer_"):
53
+ raise ValueError(
54
+ f"Tool name '{v}' uses reserved prefix '_computer_'. "
55
+ "This prefix is reserved for computer use actions."
56
+ )
57
+ return v
58
+
49
59
  def _is_async(self) -> bool:
50
60
  return inspect.iscoroutinefunction(self.run)
51
61
 
lm_deluge/tracker.py CHANGED
@@ -1,21 +1,111 @@
1
+ import asyncio
1
2
  import time
2
- from dataclasses import dataclass
3
+ from dataclasses import dataclass, field
4
+
5
+ from rich.console import Console, Group
6
+ from rich.live import Live
7
+ from rich.progress import (
8
+ BarColumn,
9
+ MofNCompleteColumn,
10
+ Progress,
11
+ SpinnerColumn,
12
+ TextColumn,
13
+ )
14
+ from rich.text import Text
15
+ from tqdm import tqdm
16
+
17
+ SECONDS_TO_PAUSE_AFTER_RATE_LIMIT_ERROR = 5
3
18
 
4
19
 
5
20
  @dataclass
6
21
  class StatusTracker:
22
+ max_requests_per_minute: int
23
+ max_tokens_per_minute: int
24
+ max_concurrent_requests: int
7
25
  num_tasks_started: int = 0
8
26
  num_tasks_in_progress: int = 0
9
27
  num_tasks_succeeded: int = 0
10
28
  num_tasks_failed: int = 0
11
29
  num_rate_limit_errors: int = 0
12
30
  time_of_last_rate_limit_error: int | float = 0
13
- total_requests = 0
31
+ total_requests: int = 0
32
+ retry_queue: asyncio.Queue = field(default_factory=asyncio.Queue)
33
+
34
+ # Progress bar configuration
35
+ use_progress_bar: bool = True
36
+ progress_bar_total: int | None = None
37
+ progress_bar_disable: bool = False
38
+ _pbar: tqdm | None = None
39
+
40
+ # Rich display configuration
41
+ use_rich: bool = True
42
+ _rich_console: Console | None = None
43
+ _rich_live: object | None = None
44
+ _rich_progress: object | None = None
45
+ _rich_task_id: object | None = None
46
+ _rich_display_task: asyncio.Task | None = None
47
+ _rich_stop_event: asyncio.Event | None = None
48
+
49
+ def __post_init__(self):
50
+ self.available_request_capacity = self.max_requests_per_minute
51
+ self.available_token_capacity = self.max_tokens_per_minute
52
+ self.last_update_time = time.time() - 1
53
+ self.last_pbar_update_time = time.time() - 1
54
+ self.limiting_factor = None
14
55
 
15
56
  @property
16
57
  def time_since_rate_limit_error(self):
17
58
  return time.time() - self.time_of_last_rate_limit_error
18
59
 
60
+ @property
61
+ def seconds_to_pause(self):
62
+ return max(
63
+ 0,
64
+ SECONDS_TO_PAUSE_AFTER_RATE_LIMIT_ERROR - self.time_since_rate_limit_error,
65
+ )
66
+
67
+ def set_limiting_factor(self, factor):
68
+ self.limiting_factor = factor
69
+
70
+ def check_capacity(self, num_tokens: int, retry: bool = False):
71
+ request_available = self.available_request_capacity >= 1
72
+ tokens_available = self.available_token_capacity >= num_tokens
73
+ concurrent_request_available = (
74
+ self.num_tasks_in_progress < self.max_concurrent_requests
75
+ )
76
+ if request_available and tokens_available and concurrent_request_available:
77
+ self.available_request_capacity -= 1
78
+ self.available_token_capacity -= num_tokens
79
+ if not retry:
80
+ # Only count new tasks, not retries
81
+ self.num_tasks_started += 1
82
+ self.num_tasks_in_progress += 1
83
+ self.set_limiting_factor(None)
84
+ return True
85
+ else:
86
+ # update reason why
87
+ if not request_available:
88
+ self.set_limiting_factor("Requests")
89
+ elif not concurrent_request_available:
90
+ self.set_limiting_factor("Concurrent Requests")
91
+ elif not tokens_available:
92
+ self.set_limiting_factor("Tokens")
93
+
94
+ def update_capacity(self):
95
+ current_time = time.time()
96
+ seconds_since_update = current_time - self.last_update_time
97
+ self.available_request_capacity = min(
98
+ self.available_request_capacity
99
+ + self.max_requests_per_minute * seconds_since_update / 60.0,
100
+ self.max_requests_per_minute,
101
+ )
102
+ self.available_token_capacity = min(
103
+ self.available_token_capacity
104
+ + self.max_tokens_per_minute * seconds_since_update / 60.0,
105
+ self.max_tokens_per_minute,
106
+ )
107
+ self.last_update_time = current_time
108
+
19
109
  def start_task(self, task_id):
20
110
  self.num_tasks_started += 1
21
111
  self.num_tasks_in_progress += 1
@@ -27,12 +117,16 @@ class StatusTracker:
27
117
  def task_succeeded(self, task_id):
28
118
  self.num_tasks_in_progress -= 1
29
119
  self.num_tasks_succeeded += 1
120
+ self.increment_pbar()
30
121
 
31
122
  def task_failed(self, task_id):
32
123
  self.num_tasks_in_progress -= 1
33
124
  self.num_tasks_failed += 1
34
125
 
35
126
  def log_final_status(self):
127
+ # Close progress bar before printing final status
128
+ self.close_progress_bar()
129
+
36
130
  if self.num_tasks_failed > 0:
37
131
  print(
38
132
  f"{self.num_tasks_failed} / {self.num_tasks_started} requests failed."
@@ -41,3 +135,121 @@ class StatusTracker:
41
135
  print(
42
136
  f"{self.num_rate_limit_errors} rate limit errors received. Consider running at a lower rate."
43
137
  )
138
+
139
+ @property
140
+ def pbar(self) -> tqdm | None:
141
+ """Backward compatibility property to access progress bar."""
142
+ return self._pbar
143
+
144
+ def init_progress_bar(self, total: int | None = None, disable: bool | None = None):
145
+ """Initialize progress bar if enabled."""
146
+ if not self.use_progress_bar:
147
+ return
148
+
149
+ if self.use_rich:
150
+ self._init_rich_display(total, disable)
151
+ else:
152
+ # Use provided values or fall back to instance defaults
153
+ pbar_total = total if total is not None else self.progress_bar_total
154
+ pbar_disable = disable if disable is not None else self.progress_bar_disable
155
+ self._pbar = tqdm(total=pbar_total, disable=pbar_disable)
156
+ self.update_pbar()
157
+
158
+ def close_progress_bar(self):
159
+ """Close progress bar if it exists."""
160
+ if self.use_rich and self._rich_stop_event:
161
+ self._close_rich_display()
162
+ elif self._pbar is not None:
163
+ self._pbar.close()
164
+ self._pbar = None
165
+
166
+ def _init_rich_display(self, total: int | None = None, disable: bool | None = None):
167
+ """Initialize Rich display components."""
168
+ if disable:
169
+ return
170
+
171
+ pbar_total = total if total is not None else self.progress_bar_total
172
+ if pbar_total is None:
173
+ pbar_total = 100 # Default fallback
174
+
175
+ self._rich_console = Console()
176
+ self._rich_stop_event = asyncio.Event()
177
+
178
+ # Start the display updater task
179
+ self._rich_display_task = asyncio.create_task(
180
+ self._rich_display_updater(pbar_total)
181
+ )
182
+
183
+ async def _rich_display_updater(self, total: int):
184
+ """Update Rich display independently."""
185
+ if not self._rich_console or self._rich_stop_event is None:
186
+ return
187
+
188
+ # Create progress bar without console so we can use it in Live
189
+ progress = Progress(
190
+ SpinnerColumn(),
191
+ TextColumn("Processing requests..."),
192
+ BarColumn(),
193
+ MofNCompleteColumn(),
194
+ )
195
+ main_task = progress.add_task("requests", total=total)
196
+
197
+ # Use Live to combine progress + text
198
+
199
+ with Live(console=self._rich_console, refresh_per_second=10) as live:
200
+ while not self._rich_stop_event.is_set():
201
+ completed = self.num_tasks_succeeded
202
+ progress.update(main_task, completed=completed)
203
+
204
+ # Create capacity info text
205
+ tokens_info = f"TPM Capacity: {self.available_token_capacity / 1000:.1f}k/{self.max_tokens_per_minute / 1000:.1f}k"
206
+ reqs_info = f"RPM Capacity: {int(self.available_request_capacity)}/{self.max_requests_per_minute}"
207
+ in_progress = f"In Progress: {int(self.num_tasks_in_progress)}"
208
+ capacity_text = Text(f"{in_progress} • {tokens_info} • {reqs_info}")
209
+
210
+ # Group progress bar and text
211
+ display = Group(progress, capacity_text)
212
+ live.update(display)
213
+
214
+ await asyncio.sleep(0.1)
215
+
216
+ def _close_rich_display(self):
217
+ """Clean up Rich display."""
218
+ if self._rich_stop_event:
219
+ self._rich_stop_event.set()
220
+ if self._rich_display_task and not self._rich_display_task.done():
221
+ self._rich_display_task.cancel()
222
+
223
+ self._rich_console = None
224
+ self._rich_live = None
225
+ self._rich_display_task = None
226
+ self._rich_stop_event = None
227
+
228
+ def update_pbar(self, n: int = 0):
229
+ """Update progress bar status and optionally increment.
230
+
231
+ Args:
232
+ n: Number of items to increment (0 means just update postfix)
233
+ """
234
+ current_time = time.time()
235
+ if self._pbar and (current_time - self.last_pbar_update_time > 1):
236
+ self.last_pbar_update_time = current_time
237
+ self._pbar.set_postfix(
238
+ {
239
+ "Token Capacity": f"{self.available_token_capacity / 1_000:.1f}k",
240
+ "Req. Capacity": f"{int(self.available_request_capacity)}",
241
+ "Reqs. in Progress": self.num_tasks_in_progress,
242
+ "Limiting Factor": self.limiting_factor,
243
+ }
244
+ )
245
+
246
+ if n > 0 and self._pbar:
247
+ self._pbar.update(n)
248
+
249
+ def increment_pbar(self):
250
+ """Increment progress bar by 1."""
251
+ if self.use_rich:
252
+ # Rich display is updated automatically by the display updater
253
+ pass
254
+ elif self._pbar:
255
+ self._pbar.update(1)
lm_deluge/util/json.py CHANGED
@@ -1,5 +1,6 @@
1
- import re
2
1
  import json
2
+ import re
3
+
3
4
  import json5
4
5
 
5
6
 
@@ -166,3 +167,19 @@ def load_json(
166
167
  pass
167
168
 
168
169
  raise ValueError(f"Invalid JSON string: {json_string}")
170
+
171
+
172
+ def try_load_json(
173
+ json_string: str | None,
174
+ allow_json5: bool = True,
175
+ allow_partial: bool = False,
176
+ allow_healing: bool = True,
177
+ ):
178
+ """
179
+ Like the above, except it returns None instead of raising an error.
180
+ """
181
+ try:
182
+ return load_json(json_string, allow_json5, allow_partial, allow_healing)
183
+ except Exception as e:
184
+ print(f"Failed to load json: {e}. Returning None.")
185
+ return None