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/__init__.py +11 -1
- lm_deluge/agent.py +0 -0
- lm_deluge/api_requests/anthropic.py +90 -58
- lm_deluge/api_requests/base.py +63 -180
- lm_deluge/api_requests/bedrock.py +34 -10
- lm_deluge/api_requests/common.py +2 -1
- lm_deluge/api_requests/mistral.py +6 -15
- lm_deluge/api_requests/openai.py +342 -50
- lm_deluge/api_requests/response.py +153 -0
- lm_deluge/batches.py +498 -0
- lm_deluge/client.py +354 -636
- lm_deluge/computer_use/anthropic_tools.py +75 -0
- lm_deluge/{sampling_params.py → config.py} +12 -4
- lm_deluge/embed.py +17 -11
- lm_deluge/file.py +149 -0
- lm_deluge/models.py +33 -0
- lm_deluge/prompt.py +156 -15
- lm_deluge/rerank.py +18 -12
- lm_deluge/tool.py +11 -1
- lm_deluge/tracker.py +214 -2
- lm_deluge/util/json.py +18 -1
- {lm_deluge-0.0.12.dist-info → lm_deluge-0.0.14.dist-info}/METADATA +8 -5
- lm_deluge-0.0.14.dist-info/RECORD +44 -0
- {lm_deluge-0.0.12.dist-info → lm_deluge-0.0.14.dist-info}/WHEEL +1 -1
- lm_deluge-0.0.12.dist-info/RECORD +0 -39
- {lm_deluge-0.0.12.dist-info → lm_deluge-0.0.14.dist-info}/licenses/LICENSE +0 -0
- {lm_deluge-0.0.12.dist-info → lm_deluge-0.0.14.dist-info}/top_level.txt +0 -0
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
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
|
|
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
|
-
|
|
226
|
-
|
|
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
|