lm-deluge 0.0.56__py3-none-any.whl → 0.0.69__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 (38) hide show
  1. lm_deluge/__init__.py +12 -1
  2. lm_deluge/api_requests/anthropic.py +12 -1
  3. lm_deluge/api_requests/base.py +87 -5
  4. lm_deluge/api_requests/bedrock.py +3 -4
  5. lm_deluge/api_requests/chat_reasoning.py +4 -0
  6. lm_deluge/api_requests/gemini.py +7 -6
  7. lm_deluge/api_requests/mistral.py +8 -9
  8. lm_deluge/api_requests/openai.py +179 -124
  9. lm_deluge/batches.py +25 -9
  10. lm_deluge/client.py +280 -67
  11. lm_deluge/config.py +1 -1
  12. lm_deluge/file.py +382 -13
  13. lm_deluge/mock_openai.py +482 -0
  14. lm_deluge/models/__init__.py +12 -8
  15. lm_deluge/models/anthropic.py +12 -20
  16. lm_deluge/models/bedrock.py +0 -14
  17. lm_deluge/models/cohere.py +0 -16
  18. lm_deluge/models/google.py +0 -20
  19. lm_deluge/models/grok.py +48 -4
  20. lm_deluge/models/groq.py +2 -2
  21. lm_deluge/models/kimi.py +34 -0
  22. lm_deluge/models/meta.py +0 -8
  23. lm_deluge/models/minimax.py +10 -0
  24. lm_deluge/models/openai.py +28 -34
  25. lm_deluge/models/openrouter.py +64 -1
  26. lm_deluge/models/together.py +0 -16
  27. lm_deluge/prompt.py +138 -29
  28. lm_deluge/request_context.py +9 -11
  29. lm_deluge/tool.py +395 -19
  30. lm_deluge/tracker.py +11 -5
  31. lm_deluge/warnings.py +46 -0
  32. {lm_deluge-0.0.56.dist-info → lm_deluge-0.0.69.dist-info}/METADATA +3 -1
  33. {lm_deluge-0.0.56.dist-info → lm_deluge-0.0.69.dist-info}/RECORD +36 -33
  34. lm_deluge/agent.py +0 -0
  35. lm_deluge/gemini_limits.py +0 -65
  36. {lm_deluge-0.0.56.dist-info → lm_deluge-0.0.69.dist-info}/WHEEL +0 -0
  37. {lm_deluge-0.0.56.dist-info → lm_deluge-0.0.69.dist-info}/licenses/LICENSE +0 -0
  38. {lm_deluge-0.0.56.dist-info → lm_deluge-0.0.69.dist-info}/top_level.txt +0 -0
lm_deluge/file.py CHANGED
@@ -1,22 +1,35 @@
1
- from functools import cached_property
2
- import os
3
- import io
4
- import requests
5
1
  import base64
2
+ import io
6
3
  import mimetypes
7
- import xxhash
4
+ import os
8
5
  from dataclasses import dataclass, field
6
+ from functools import cached_property
9
7
  from pathlib import Path
8
+ from typing import Literal
9
+
10
+ import requests
11
+ import xxhash
10
12
 
11
13
 
12
14
  @dataclass
13
15
  class File:
14
16
  # raw bytes, pathlike, http url, base64 data url, or file_id
15
- data: bytes | io.BytesIO | Path | str
17
+ data: bytes | io.BytesIO | Path | str | None
16
18
  media_type: str | None = None # inferred if None
19
+ type: str = field(init=False, default="file")
20
+ is_remote: bool = False
21
+ remote_provider: Literal["openai", "anthropic", "google"] | None = None
17
22
  filename: str | None = None # optional filename for uploads
18
23
  file_id: str | None = None # for OpenAI file uploads or Anthropic file API
19
- type: str = field(init=False, default="file")
24
+
25
+ def __post_init__(self):
26
+ if self.is_remote:
27
+ if self.remote_provider is None:
28
+ raise ValueError("remote_provider must be specified")
29
+ if self.file_id is None:
30
+ raise ValueError("file_id must be specified for remote files")
31
+ if self.file_id and not self.is_remote:
32
+ print("Warning: File ID specified by file not labeled as remote.")
20
33
 
21
34
  # helpers -----------------------------------------------------------------
22
35
  def _bytes(self) -> bytes:
@@ -75,17 +88,342 @@ class File:
75
88
  @cached_property
76
89
  def fingerprint(self) -> str:
77
90
  # Hash the file contents for fingerprinting
91
+ if self.is_remote:
92
+ # For remote files, use provider:file_id for interpretability
93
+ return f"{self.remote_provider}:{self.file_id}"
78
94
  file_bytes = self._bytes()
79
95
  return xxhash.xxh64(file_bytes).hexdigest()
80
96
 
81
97
  @cached_property
82
98
  def size(self) -> int:
83
99
  """Return file size in bytes."""
100
+ if self.is_remote:
101
+ # For remote files, we don't have the bytes available
102
+ return 0
84
103
  return len(self._bytes())
85
104
 
105
+ async def as_remote(
106
+ self, provider: Literal["openai", "anthropic", "google"]
107
+ ) -> "File":
108
+ """Upload file to provider's file API and return new File with file_id.
109
+
110
+ Args:
111
+ provider: The provider to upload to ("openai", "anthropic", or "google")
112
+
113
+ Returns:
114
+ A new File object with file_id set and is_remote=True
115
+
116
+ Raises:
117
+ ValueError: If provider is unsupported or API key is missing
118
+ RuntimeError: If upload fails
119
+ """
120
+ if self.is_remote:
121
+ # If already remote with same provider, return self
122
+ if self.remote_provider == provider:
123
+ return self
124
+ # Otherwise raise error about cross-provider incompatibility
125
+ raise ValueError(
126
+ f"File is already uploaded to {self.remote_provider}. "
127
+ f"Cannot re-upload to {provider}."
128
+ )
129
+
130
+ if provider == "openai":
131
+ return await self._upload_to_openai()
132
+ elif provider == "anthropic":
133
+ return await self._upload_to_anthropic()
134
+ elif provider == "google":
135
+ return await self._upload_to_google()
136
+ else:
137
+ raise ValueError(f"Unsupported provider: {provider}")
138
+
139
+ async def _upload_to_openai(self) -> "File":
140
+ """Upload file to OpenAI's Files API."""
141
+ import aiohttp
142
+
143
+ api_key = os.environ.get("OPENAI_API_KEY")
144
+ if not api_key:
145
+ raise ValueError("OPENAI_API_KEY environment variable must be set")
146
+
147
+ url = "https://api.openai.com/v1/files"
148
+ headers = {"Authorization": f"Bearer {api_key}"}
149
+
150
+ # Get file bytes and metadata
151
+ file_bytes = self._bytes()
152
+ filename = self._filename()
153
+
154
+ # Create multipart form data
155
+ data = aiohttp.FormData()
156
+ data.add_field("purpose", "assistants")
157
+ data.add_field(
158
+ "file",
159
+ file_bytes,
160
+ filename=filename,
161
+ content_type=self._mime(),
162
+ )
163
+
164
+ try:
165
+ async with aiohttp.ClientSession() as session:
166
+ async with session.post(url, headers=headers, data=data) as response:
167
+ if response.status != 200:
168
+ text = await response.text()
169
+ raise RuntimeError(f"Failed to upload file to OpenAI: {text}")
170
+
171
+ response_data = await response.json()
172
+ file_id = response_data["id"]
173
+
174
+ # Return new File object with file_id
175
+ return File(
176
+ data=None,
177
+ media_type=self.media_type,
178
+ is_remote=True,
179
+ remote_provider="openai",
180
+ filename=filename,
181
+ file_id=file_id,
182
+ )
183
+ except aiohttp.ClientError as e:
184
+ raise RuntimeError(f"Failed to upload file to OpenAI: {e}")
185
+
186
+ async def _upload_to_anthropic(self) -> "File":
187
+ """Upload file to Anthropic's Files API."""
188
+ import aiohttp
189
+
190
+ api_key = os.environ.get("ANTHROPIC_API_KEY")
191
+ if not api_key:
192
+ raise ValueError("ANTHROPIC_API_KEY environment variable must be set")
193
+
194
+ url = "https://api.anthropic.com/v1/files"
195
+ headers = {
196
+ "x-api-key": api_key,
197
+ "anthropic-version": "2023-06-01",
198
+ "anthropic-beta": "files-api-2025-04-14",
199
+ }
200
+
201
+ # Get file bytes and metadata
202
+ file_bytes = self._bytes()
203
+ filename = self._filename()
204
+
205
+ # Create multipart form data
206
+ data = aiohttp.FormData()
207
+ data.add_field(
208
+ "file",
209
+ file_bytes,
210
+ filename=filename,
211
+ content_type=self._mime(),
212
+ )
213
+
214
+ try:
215
+ async with aiohttp.ClientSession() as session:
216
+ async with session.post(url, headers=headers, data=data) as response:
217
+ if response.status != 200:
218
+ text = await response.text()
219
+ raise RuntimeError(
220
+ f"Failed to upload file to Anthropic: {text}"
221
+ )
222
+
223
+ response_data = await response.json()
224
+ file_id = response_data["id"]
225
+
226
+ # Return new File object with file_id
227
+ return File(
228
+ data=None,
229
+ media_type=self.media_type,
230
+ is_remote=True,
231
+ remote_provider="anthropic",
232
+ filename=filename,
233
+ file_id=file_id,
234
+ )
235
+ except aiohttp.ClientError as e:
236
+ raise RuntimeError(f"Failed to upload file to Anthropic: {e}")
237
+
238
+ async def _upload_to_google(self) -> "File":
239
+ """Upload file to Google Gemini Files API."""
240
+ import json
241
+
242
+ import aiohttp
243
+
244
+ api_key = os.environ.get("GEMINI_API_KEY")
245
+ if not api_key:
246
+ raise ValueError("GEMINI_API_KEY environment variable must be set")
247
+
248
+ # Google uses a different URL structure with the API key as a parameter
249
+ url = f"https://generativelanguage.googleapis.com/upload/v1beta/files?key={api_key}"
250
+
251
+ # Get file bytes and metadata
252
+ file_bytes = self._bytes()
253
+ filename = self._filename()
254
+ mime_type = self._mime()
255
+
256
+ # Google expects a multipart request with metadata and file data
257
+ # Using the resumable upload protocol
258
+ headers = {
259
+ "X-Goog-Upload-Protocol": "multipart",
260
+ }
261
+
262
+ # Create multipart form data with metadata and file
263
+ data = aiohttp.FormData()
264
+
265
+ # Add metadata part as JSON
266
+ metadata = {"file": {"display_name": filename}}
267
+ data.add_field(
268
+ "metadata",
269
+ json.dumps(metadata),
270
+ content_type="application/json",
271
+ )
272
+
273
+ # Add file data part
274
+ data.add_field(
275
+ "file",
276
+ file_bytes,
277
+ filename=filename,
278
+ content_type=mime_type,
279
+ )
280
+
281
+ try:
282
+ async with aiohttp.ClientSession() as session:
283
+ async with session.post(url, headers=headers, data=data) as response:
284
+ if response.status not in [200, 201]:
285
+ text = await response.text()
286
+ raise RuntimeError(f"Failed to upload file to Google: {text}")
287
+
288
+ response_data = await response.json()
289
+ # Google returns a file object with a 'name' field like 'files/abc123'
290
+ file_uri = response_data.get("file", {}).get(
291
+ "uri"
292
+ ) or response_data.get("name")
293
+ if not file_uri:
294
+ raise RuntimeError(
295
+ f"No file URI in Google response: {response_data}"
296
+ )
297
+
298
+ # Return new File object with file_id (using the file URI)
299
+ return File(
300
+ data=None,
301
+ media_type=self.media_type,
302
+ is_remote=True,
303
+ remote_provider="google",
304
+ filename=filename,
305
+ file_id=file_uri,
306
+ )
307
+ except aiohttp.ClientError as e:
308
+ raise RuntimeError(f"Failed to upload file to Google: {e}")
309
+
310
+ async def delete(self) -> bool:
311
+ """Delete the uploaded file from the remote provider.
312
+
313
+ Returns:
314
+ True if deletion was successful, False otherwise
315
+
316
+ Raises:
317
+ ValueError: If file is not a remote file or provider is unsupported
318
+ RuntimeError: If deletion fails
319
+ """
320
+ if not self.is_remote:
321
+ raise ValueError(
322
+ "Cannot delete a non-remote file. Only remote files can be deleted."
323
+ )
324
+
325
+ if not self.file_id:
326
+ raise ValueError("Cannot delete file without file_id")
327
+
328
+ if self.remote_provider == "openai":
329
+ return await self._delete_from_openai()
330
+ elif self.remote_provider == "anthropic":
331
+ return await self._delete_from_anthropic()
332
+ elif self.remote_provider == "google":
333
+ return await self._delete_from_google()
334
+ else:
335
+ raise ValueError(f"Unsupported provider: {self.remote_provider}")
336
+
337
+ async def _delete_from_openai(self) -> bool:
338
+ """Delete file from OpenAI's Files API."""
339
+ import aiohttp
340
+
341
+ api_key = os.environ.get("OPENAI_API_KEY")
342
+ if not api_key:
343
+ raise ValueError("OPENAI_API_KEY environment variable must be set")
344
+
345
+ url = f"https://api.openai.com/v1/files/{self.file_id}"
346
+ headers = {"Authorization": f"Bearer {api_key}"}
347
+
348
+ try:
349
+ async with aiohttp.ClientSession() as session:
350
+ async with session.delete(url, headers=headers) as response:
351
+ if response.status == 200:
352
+ return True
353
+ else:
354
+ text = await response.text()
355
+ raise RuntimeError(f"Failed to delete file from OpenAI: {text}")
356
+ except aiohttp.ClientError as e:
357
+ raise RuntimeError(f"Failed to delete file from OpenAI: {e}")
358
+
359
+ async def _delete_from_anthropic(self) -> bool:
360
+ """Delete file from Anthropic's Files API."""
361
+ import aiohttp
362
+
363
+ api_key = os.environ.get("ANTHROPIC_API_KEY")
364
+ if not api_key:
365
+ raise ValueError("ANTHROPIC_API_KEY environment variable must be set")
366
+
367
+ url = f"https://api.anthropic.com/v1/files/{self.file_id}"
368
+ headers = {
369
+ "x-api-key": api_key,
370
+ "anthropic-version": "2023-06-01",
371
+ "anthropic-beta": "files-api-2025-04-14",
372
+ }
373
+
374
+ try:
375
+ async with aiohttp.ClientSession() as session:
376
+ async with session.delete(url, headers=headers) as response:
377
+ if response.status == 200:
378
+ return True
379
+ else:
380
+ text = await response.text()
381
+ raise RuntimeError(
382
+ f"Failed to delete file from Anthropic: {text}"
383
+ )
384
+ except aiohttp.ClientError as e:
385
+ raise RuntimeError(f"Failed to delete file from Anthropic: {e}")
386
+
387
+ async def _delete_from_google(self) -> bool:
388
+ """Delete file from Google Gemini Files API."""
389
+ import aiohttp
390
+
391
+ api_key = os.environ.get("GEMINI_API_KEY")
392
+ if not api_key:
393
+ raise ValueError("GEMINI_API_KEY environment variable must be set")
394
+
395
+ # Google file_id is the full URI like "https://generativelanguage.googleapis.com/v1beta/files/abc123"
396
+ # We need to extract just the file name part for the delete endpoint
397
+ assert self.file_id, "can't delete file with no file id"
398
+ if self.file_id.startswith("https://"):
399
+ # Extract the path after the domain
400
+ file_name = self.file_id.split("/v1beta/")[-1]
401
+ else:
402
+ file_name = self.file_id
403
+
404
+ url = f"https://generativelanguage.googleapis.com/v1beta/{file_name}?key={api_key}"
405
+
406
+ try:
407
+ async with aiohttp.ClientSession() as session:
408
+ async with session.delete(url) as response:
409
+ if response.status in [200, 204]:
410
+ return True
411
+ else:
412
+ text = await response.text()
413
+ raise RuntimeError(f"Failed to delete file from Google: {text}")
414
+ except aiohttp.ClientError as e:
415
+ raise RuntimeError(f"Failed to delete file from Google: {e}")
416
+
86
417
  # ── provider-specific emission ────────────────────────────────────────────
87
418
  def oa_chat(self) -> dict:
88
419
  """For OpenAI Chat Completions - file content as base64 or file_id."""
420
+ # Validate provider compatibility
421
+ if self.is_remote and self.remote_provider != "openai":
422
+ raise ValueError(
423
+ f"Cannot emit file uploaded to {self.remote_provider} as OpenAI format. "
424
+ f"File must be uploaded to OpenAI or provided as raw data."
425
+ )
426
+
89
427
  if self.file_id:
90
428
  return {
91
429
  "type": "file",
@@ -104,6 +442,13 @@ class File:
104
442
 
105
443
  def oa_resp(self) -> dict:
106
444
  """For OpenAI Responses API - file content as base64 or file_id."""
445
+ # Validate provider compatibility
446
+ if self.is_remote and self.remote_provider != "openai":
447
+ raise ValueError(
448
+ f"Cannot emit file uploaded to {self.remote_provider} as OpenAI format. "
449
+ f"File must be uploaded to OpenAI or provided as raw data."
450
+ )
451
+
107
452
  if self.file_id:
108
453
  return {
109
454
  "type": "input_file",
@@ -118,6 +463,13 @@ class File:
118
463
 
119
464
  def anthropic(self) -> dict:
120
465
  """For Anthropic Messages API - file content as base64 or file_id."""
466
+ # Validate provider compatibility
467
+ if self.is_remote and self.remote_provider != "anthropic":
468
+ raise ValueError(
469
+ f"Cannot emit file uploaded to {self.remote_provider} as Anthropic format. "
470
+ f"File must be uploaded to Anthropic or provided as raw data."
471
+ )
472
+
121
473
  if self.file_id:
122
474
  return {
123
475
  "type": "document",
@@ -145,13 +497,30 @@ class File:
145
497
  return filename, content, media_type
146
498
 
147
499
  def gemini(self) -> dict:
148
- """For Gemini API - files are provided as inline data."""
149
- return {
150
- "inlineData": {
151
- "mimeType": self._mime(),
152
- "data": self._base64(include_header=False),
500
+ """For Gemini API - files are provided as inline data or file URI."""
501
+ # Validate provider compatibility
502
+ if self.is_remote and self.remote_provider != "google":
503
+ raise ValueError(
504
+ f"Cannot emit file uploaded to {self.remote_provider} as Google format. "
505
+ f"File must be uploaded to Google or provided as raw data."
506
+ )
507
+
508
+ if self.file_id:
509
+ # Use file URI for uploaded files
510
+ return {
511
+ "fileData": {
512
+ "mimeType": self._mime(),
513
+ "fileUri": self.file_id,
514
+ }
515
+ }
516
+ else:
517
+ # Use inline data for non-uploaded files
518
+ return {
519
+ "inlineData": {
520
+ "mimeType": self._mime(),
521
+ "data": self._base64(include_header=False),
522
+ }
153
523
  }
154
- }
155
524
 
156
525
  def mistral(self) -> dict:
157
526
  """For Mistral API - not yet supported."""