openweights 0.1.0__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 (92) hide show
  1. openweights/__init__.py +3 -0
  2. openweights/client/__init__.py +176 -0
  3. openweights/client/cache_on_disk.py +34 -0
  4. openweights/client/chat.py +135 -0
  5. openweights/client/custom_job.py +96 -0
  6. openweights/client/events.py +42 -0
  7. openweights/client/files.py +106 -0
  8. openweights/client/jobs.py +293 -0
  9. openweights/client/run.py +189 -0
  10. openweights/client/temporary_api.py +231 -0
  11. openweights/cluster/.gitignore +1 -0
  12. openweights/cluster/README.md +32 -0
  13. openweights/cluster/org_manager.py +366 -0
  14. openweights/cluster/start_runpod.py +211 -0
  15. openweights/cluster/supervisor.py +177 -0
  16. openweights/dashboard/README.md +77 -0
  17. openweights/dashboard/backend/database.py +432 -0
  18. openweights/dashboard/backend/main.py +285 -0
  19. openweights/dashboard/backend/models.py +69 -0
  20. openweights/dashboard/backend/static/assets/ow.svg +22 -0
  21. openweights/dashboard/backend/static/assets/vite.svg +1 -0
  22. openweights/dashboard/backend/utils.py +143 -0
  23. openweights/dashboard/deploy.sh +41 -0
  24. openweights/dashboard/frontend/.gitignore +24 -0
  25. openweights/dashboard/frontend/README.md +50 -0
  26. openweights/dashboard/frontend/eslint.config.js +28 -0
  27. openweights/dashboard/frontend/index.html +13 -0
  28. openweights/dashboard/frontend/package-lock.json +7839 -0
  29. openweights/dashboard/frontend/package.json +40 -0
  30. openweights/dashboard/frontend/public/ow.svg +22 -0
  31. openweights/dashboard/frontend/public/vite.svg +1 -0
  32. openweights/dashboard/frontend/src/App.css +47 -0
  33. openweights/dashboard/frontend/src/App.tsx +300 -0
  34. openweights/dashboard/frontend/src/api.ts +292 -0
  35. openweights/dashboard/frontend/src/assets/react.svg +1 -0
  36. openweights/dashboard/frontend/src/components/Auth/Auth.tsx +231 -0
  37. openweights/dashboard/frontend/src/components/DetailViews/FileContent.tsx +40 -0
  38. openweights/dashboard/frontend/src/components/DetailViews/JobDetailView.tsx +213 -0
  39. openweights/dashboard/frontend/src/components/DetailViews/MetricsDisplay.tsx +90 -0
  40. openweights/dashboard/frontend/src/components/DetailViews/MetricsPlots.tsx +179 -0
  41. openweights/dashboard/frontend/src/components/DetailViews/OutputsDisplay.tsx +62 -0
  42. openweights/dashboard/frontend/src/components/DetailViews/RunDetailView.tsx +147 -0
  43. openweights/dashboard/frontend/src/components/DetailViews/WorkerDetailView.tsx +257 -0
  44. openweights/dashboard/frontend/src/components/DetailViews/index.ts +3 -0
  45. openweights/dashboard/frontend/src/components/DetailViews.tsx +1 -0
  46. openweights/dashboard/frontend/src/components/JobsListView.tsx +134 -0
  47. openweights/dashboard/frontend/src/components/JobsView.tsx +366 -0
  48. openweights/dashboard/frontend/src/components/Organizations/OrganizationDetail.tsx +592 -0
  49. openweights/dashboard/frontend/src/components/Organizations/OrganizationList.tsx +227 -0
  50. openweights/dashboard/frontend/src/components/Organizations/OrganizationSwitcher.tsx +39 -0
  51. openweights/dashboard/frontend/src/components/Organizations/OrganizationsList.tsx +168 -0
  52. openweights/dashboard/frontend/src/components/Organizations/TokensTab.tsx +261 -0
  53. openweights/dashboard/frontend/src/components/RefreshButton.tsx +33 -0
  54. openweights/dashboard/frontend/src/components/RunsListView.tsx +134 -0
  55. openweights/dashboard/frontend/src/components/RunsView.tsx +332 -0
  56. openweights/dashboard/frontend/src/components/StatusCheckboxes.tsx +62 -0
  57. openweights/dashboard/frontend/src/components/TokenView.tsx +245 -0
  58. openweights/dashboard/frontend/src/components/ViewToggle.tsx +33 -0
  59. openweights/dashboard/frontend/src/components/WorkersListView.tsx +140 -0
  60. openweights/dashboard/frontend/src/components/WorkersView.tsx +357 -0
  61. openweights/dashboard/frontend/src/contexts/AuthContext.tsx +103 -0
  62. openweights/dashboard/frontend/src/contexts/OrganizationContext.tsx +92 -0
  63. openweights/dashboard/frontend/src/index.css +59 -0
  64. openweights/dashboard/frontend/src/main.tsx +10 -0
  65. openweights/dashboard/frontend/src/supabaseClient.ts +11 -0
  66. openweights/dashboard/frontend/src/types/supabase.ts +63 -0
  67. openweights/dashboard/frontend/src/types.ts +64 -0
  68. openweights/dashboard/frontend/src/vite-env.d.ts +1 -0
  69. openweights/dashboard/frontend/tsconfig.app.json +26 -0
  70. openweights/dashboard/frontend/tsconfig.json +7 -0
  71. openweights/dashboard/frontend/tsconfig.node.json +24 -0
  72. openweights/dashboard/frontend/vite.config.ts +8 -0
  73. openweights/dashboard/runpod-startup.sh +107 -0
  74. openweights/dashboard/screenshots/job_details.png +0 -0
  75. openweights/dashboard/screenshots/jobs_view.png +0 -0
  76. openweights/dashboard/screenshots/run_details.png +0 -0
  77. openweights/dashboard/screenshots/workers.png +0 -0
  78. openweights/utils.py +250 -0
  79. openweights/validate.py +276 -0
  80. openweights/worker/__init__.py +0 -0
  81. openweights/worker/dpo_ft.py +67 -0
  82. openweights/worker/gpu_health_check.py +111 -0
  83. openweights/worker/inference.py +140 -0
  84. openweights/worker/main.py +435 -0
  85. openweights/worker/orpo_ft.py +64 -0
  86. openweights/worker/sft.py +109 -0
  87. openweights/worker/training.py +94 -0
  88. openweights/worker/utils.py +88 -0
  89. openweights-0.1.0.dist-info/METADATA +139 -0
  90. openweights-0.1.0.dist-info/RECORD +92 -0
  91. openweights-0.1.0.dist-info/WHEEL +4 -0
  92. openweights-0.1.0.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1,3 @@
1
+ from .client import OpenWeights
2
+
3
+ __all__ = ['OpenWeights']
@@ -0,0 +1,176 @@
1
+ import asyncio
2
+ import atexit
3
+ import json
4
+ from typing import Optional, BinaryIO, Dict, Any, List, Union
5
+ import os
6
+ import sys
7
+ from postgrest.exceptions import APIError
8
+ import hashlib
9
+ from datetime import datetime
10
+ from openai import OpenAI, AsyncOpenAI
11
+ import backoff
12
+ import time
13
+ from supabase import create_client, Client
14
+ from supabase.lib.client_options import ClientOptions
15
+
16
+ from openweights.validate import validate_messages, validate_preference_dataset, TrainingConfig, InferenceConfig, ApiConfig
17
+ from openweights.client.files import Files
18
+ from openweights.client.jobs import FineTuningJobs, InferenceJobs, Deployments, Jobs
19
+ from openweights.client.run import Run, Runs
20
+ from openweights.client.events import Events
21
+ from openweights.client.temporary_api import TemporaryApi, group_models_or_adapters_by_model, get_lora_rank
22
+ from openweights.client.chat import ChatCompletions, AsyncChatCompletions
23
+ from openweights.client.custom_job import CustomJob
24
+
25
+
26
+ def create_authenticated_client(supabase_url: str, supabase_anon_key: str, auth_token: Optional[str] = None):
27
+ """Create a Supabase client with authentication.
28
+
29
+ Args:
30
+ supabase_url: Supabase project URL
31
+ supabase_anon_key: Supabase anon key
32
+ auth_token: Session token from Supabase auth (optional)
33
+ api_key: OpenWeights API key starting with 'ow_' (optional)
34
+ """
35
+ headers = {}
36
+ if auth_token:
37
+ headers["Authorization"] = f"Bearer {auth_token}"
38
+ else:
39
+ raise ValueError("No auth_token provided")
40
+
41
+ options = ClientOptions(
42
+ schema="public",
43
+ headers=headers,
44
+ auto_refresh_token=False,
45
+ persist_session=False
46
+ )
47
+
48
+ return create_client(supabase_url, supabase_anon_key, options)
49
+
50
+
51
+ class OpenWeights:
52
+ def __init__(self,
53
+ supabase_url: Optional[str] = None,
54
+ supabase_key: Optional[str] = None,
55
+ auth_token: Optional[str] = None,
56
+ organization_id: Optional[str] = None,
57
+ use_async: bool = False,
58
+ deploy_kwargs: Dict[str, Any] = {'max_model_len': 2048}):
59
+ """Initialize OpenWeights client
60
+
61
+ Args:
62
+ supabase_url: Supabase project URL (or SUPABASE_URL env var)
63
+ supabase_key: Supabase anon key (or SUPABASE_ANON_KEY env var)
64
+ auth_token: Authentication token (or OPENWEIGHTS_API_KEY env var)
65
+ Can be either a session token or a service account JWT token
66
+ """
67
+ self.supabase_url = supabase_url or os.environ.get('SUPABASE_URL', 'https://taofkfabrhpgtohaikst.supabase.co')
68
+ self.supabase_key = supabase_key or os.environ.get('SUPABASE_ANON_KEY', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InRhb2ZrZmFicmhwZ3RvaGFpa3N0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzE5MjkyMjcsImV4cCI6MjA0NzUwNTIyN30.KRufleTgprt16mfm0_91YjKIFZAne1-IW8buMxWVMeE')
69
+ self.auth_token = auth_token or os.getenv('OPENWEIGHTS_API_KEY')
70
+ self.deploy_kwargs = deploy_kwargs
71
+
72
+ if not self.supabase_url or not self.supabase_key:
73
+ raise ValueError("Supabase URL and key must be provided either as arguments or environment variables")
74
+
75
+ if not self.auth_token:
76
+ raise ValueError("Authentication token must be provided either as argument or OPENWEIGHTS_API_KEY environment variable")
77
+
78
+ self._supabase = create_authenticated_client(
79
+ self.supabase_url,
80
+ self.supabase_key,
81
+ self.auth_token
82
+ )
83
+
84
+ # Get organization ID from token
85
+ self.organization_id = organization_id or self.get_organization_id()
86
+ self.org_name = self.get_organization_name()
87
+ print("Connected to org: ", self.org_name)
88
+ self.set_hf_org_env()
89
+
90
+ # Initialize components with organization ID
91
+ self.files = Files(self._supabase, self.organization_id)
92
+ self.fine_tuning = FineTuningJobs(self._supabase, self.organization_id)
93
+ self.inference = InferenceJobs(self._supabase, self.organization_id)
94
+ self.jobs = Jobs(self._supabase, self.organization_id)
95
+ self.deployments = Deployments(self._supabase, self.organization_id)
96
+ self.runs = Runs(self._supabase)
97
+ self.events = Events(self._supabase)
98
+ self.async_chat = AsyncChatCompletions(self, deploy_kwargs=self.deploy_kwargs)
99
+ self.sync_chat = ChatCompletions(self, deploy_kwargs=self.deploy_kwargs)
100
+ self.chat = self.async_chat if use_async else self.sync_chat
101
+
102
+ self._current_run = None
103
+
104
+ @backoff.on_exception(backoff.constant, Exception, interval=1, max_time=60, max_tries=60, on_backoff=lambda details: print(f"Retrying... {details['exception']}"))
105
+ def get_organization_id(self) -> str:
106
+ """Get the organization ID associated with the current token"""
107
+ result = self._supabase.rpc('get_organization_from_token').execute()
108
+ if not result.data:
109
+ raise ValueError("Could not determine organization ID from token")
110
+ return result.data
111
+
112
+ @backoff.on_exception(backoff.constant, Exception, interval=1, max_time=60, max_tries=60, on_backoff=lambda details: print(f"Retrying... {details['exception']}"))
113
+ def get_organization_name(self):
114
+ """Get the organization ID associated with the current token"""
115
+ result = self._supabase.table('organizations')\
116
+ .select('*')\
117
+ .eq('id', self.organization_id)\
118
+ .single().execute()
119
+ return result.data['name']
120
+
121
+ @backoff.on_exception(backoff.constant, Exception, interval=1, max_time=60, max_tries=60, on_backoff=lambda details: print(f"Retrying... {details['exception']}"))
122
+ def set_hf_org_env(self):
123
+ """Get organization secrets from the database."""
124
+ if os.environ.get('HF_ORG'):
125
+ return
126
+ result = self._supabase.table('organization_secrets')\
127
+ .select('value')\
128
+ .eq('organization_id', self.organization_id)\
129
+ .eq('name', 'HF_ORG')\
130
+ .single().execute()
131
+ if not result.data:
132
+ raise ValueError("Could not determine organization ID from token")
133
+ os.environ['HF_ORG'] = result.data['value']
134
+ print(f"Set HF_ORG to {os.environ['HF_ORG']}")
135
+
136
+ @property
137
+ def run(self):
138
+ if not self._current_run:
139
+ self._current_run = Run(self._supabase, organization_id=self.organization_id)
140
+ return self._current_run
141
+
142
+ def deploy(self, model: str, lora_adapters: List[str] = None, max_lora_rank: str = 'guess', max_model_len: int = 2048, api_key: str = os.environ.get('OW_DEFAULT_API_KEY'), requires_vram_gb: str = 'guess', max_num_seqs: int = 100) -> TemporaryApi:
143
+ if lora_adapters is None:
144
+ lora_adapters = []
145
+ """Deploy a model on OpenWeights"""
146
+ if api_key is None:
147
+ api_key = self.auth_token
148
+ if lora_adapters and max_lora_rank == 'guess':
149
+ max_lora_rank = max(get_lora_rank(a) for a in lora_adapters)
150
+ else:
151
+ max_lora_rank = 16
152
+ job = self.deployments.create(
153
+ model=model, max_model_len=max_model_len, api_key=api_key, requires_vram_gb=requires_vram_gb,
154
+ lora_adapters=lora_adapters, max_lora_rank=max_lora_rank, max_num_seqs=max_num_seqs)
155
+ return TemporaryApi(self, job['id'])
156
+
157
+ def multi_deploy(self, models: List[str], max_model_len: Union[int,str] = 2048, api_key: str = os.environ.get('OW_DEFAULT_API_KEY'), requires_vram_gb: Union[int,str] = 'guess', max_num_seqs: int = 100, base_model_override: Optional[str] = None) -> Dict[str, TemporaryApi]:
158
+ """Deploy multiple models - creates on server for each base model, and deploys all lora adapters on of the same base model together"""
159
+ assert isinstance(models, list), "models must be a list"
160
+ lora_groups = group_models_or_adapters_by_model(models)
161
+ apis = {}
162
+ for model, lora_adapters in lora_groups.items():
163
+ if base_model_override is not None:
164
+ model = base_model_override
165
+ print(f"Deploying {model} with {len(lora_adapters)} lora adapters")
166
+ api = self.deploy(model, lora_adapters=lora_adapters, max_model_len=max_model_len, api_key=api_key, requires_vram_gb=requires_vram_gb, max_num_seqs=max_num_seqs)
167
+ for model_id in [model] + lora_adapters:
168
+ apis[model_id] = api
169
+ return apis
170
+
171
+ def register(self, name: str):
172
+ """Decorator to register a custom job class"""
173
+ def register_custom_job(cls):
174
+ obj = cls(self)
175
+ setattr(self, name, obj)
176
+ return register_custom_job
@@ -0,0 +1,34 @@
1
+ import hashlib
2
+ import json
3
+ import os
4
+ from functools import wraps
5
+
6
+ import diskcache as dc
7
+
8
+ # Ensure the cache directory exists
9
+ cache_dir = os.path.join(os.path.dirname(__file__), ".llm-cache")
10
+ os.makedirs(cache_dir, exist_ok=True)
11
+
12
+ # Use FanoutCache with multiple shards and a short timeout
13
+ cache = dc.FanoutCache(cache_dir, shards=64, timeout=1)
14
+
15
+ def cache_on_disk(required_kwargs=[]):
16
+ def decorator(function):
17
+ @wraps(function)
18
+ async def wrapper(*args, **kwargs):
19
+ # Only cache if all required kwargs are present
20
+ if not all(k in kwargs for k in required_kwargs):
21
+ return await function(*args, **kwargs)
22
+
23
+ # Serialize args/kwargs into a JSON string and hash it
24
+ serialized = json.dumps({"args": args, "kwargs": kwargs}, sort_keys=True)
25
+ key = hashlib.sha256(serialized.encode()).hexdigest()
26
+
27
+ if key in cache:
28
+ return cache[key]
29
+
30
+ result = await function(*args, **kwargs)
31
+ cache[key] = result
32
+ return result
33
+ return wrapper
34
+ return decorator
@@ -0,0 +1,135 @@
1
+ from collections import defaultdict
2
+ import openai
3
+ import asyncio
4
+ from openweights.client.cache_on_disk import cache_on_disk
5
+ import backoff
6
+
7
+ APIS = {}
8
+ DEPLOYMENT_QUEUE = []
9
+ STARTING = []
10
+
11
+
12
+ class AsyncChatCompletions:
13
+ """This class is a wrapper around the OpenAI Chat API that handles deployment of models,
14
+ request caching (when seeds are provided), and rate limiting.
15
+
16
+ Args:
17
+ ow: OpenWeights client
18
+ deploy_kwargs: kwargs to pass to ow.multi_deploy
19
+ request_timeout: timeout for requests in seconds
20
+ per_token_timeout: computes a timeout based on max_tokens * per_token_timeout for each request
21
+
22
+ When both timeouts are set, the maximum of the two is used.
23
+ """
24
+ def __init__(self, ow, deploy_kwargs={}, request_timeout=5, per_token_timeout=1):
25
+ self.ow = ow
26
+ self.completions = self
27
+ self.deploy_kwargs = deploy_kwargs
28
+ self.request_timeout = request_timeout
29
+ self.per_token_timeout = per_token_timeout
30
+
31
+ async def create(self, model: str, **kwargs):
32
+ @cache_on_disk(required_kwargs=['seed'])
33
+ async def cached_create(model, **kwargs):
34
+ return await self._create(model, **kwargs)
35
+ return await cached_create(model, **kwargs)
36
+
37
+ async def _create(self, model, **kwargs):
38
+ api = await self._get_api(model)
39
+ async with api.sem:
40
+ return await self._create_with_backoff(api, model, **kwargs)
41
+
42
+ @backoff.on_exception(
43
+ wait_gen=backoff.expo,
44
+ exception=(
45
+ openai.RateLimitError,
46
+ openai.APIConnectionError,
47
+ openai.APITimeoutError
48
+ ),
49
+ max_value=60,
50
+ factor=1.5,
51
+ max_tries=10
52
+ )
53
+ async def _create_with_backoff(self, api, model, **kwargs):
54
+ timeout = kwargs.pop('timeout', None) or max(
55
+ self.request_timeout,
56
+ kwargs.get('max_tokens', 1) * self.per_token_timeout,
57
+ )
58
+ return await api.async_client.chat.completions.create(model=model, timeout=timeout, **kwargs)
59
+
60
+ async def _get_api(self, model):
61
+ """If the model is not yet deployed, we add it to a queue of to-be-deployed models and wait for 5 seconds
62
+ to group them at once, which is more efficient if the multiple lora finetunes of the same model are deployed."""
63
+ if model in APIS:
64
+ return APIS[model]
65
+ if looks_like_openai(model):
66
+ return OpenAiApi()
67
+ if model not in DEPLOYMENT_QUEUE and model not in STARTING:
68
+ print(f"Adding {model} to deployment queue")
69
+ DEPLOYMENT_QUEUE.append(model)
70
+ # Create a task to deploy the model in 5 seconds
71
+ asyncio.create_task(self._wait_and_deploy_queue())
72
+ # Poll until model is deployed
73
+ while model not in APIS:
74
+ await asyncio.sleep(1)
75
+ return APIS[model]
76
+
77
+ async def _wait_and_deploy_queue(self, seconds_to_wait=5):
78
+ for _ in range(seconds_to_wait):
79
+ await asyncio.sleep(1)
80
+ if len(DEPLOYMENT_QUEUE) == 0:
81
+ return
82
+ # Move all models from the queue to a list of models to be deployed, such that DEPLOYMENT_QUEUE is empty after this
83
+ models_to_deploy = DEPLOYMENT_QUEUE.copy()
84
+ DEPLOYMENT_QUEUE.clear()
85
+ STARTING.extend(models_to_deploy)
86
+ print(f"Deploying {models_to_deploy}")
87
+ # Deploy the models
88
+ apis = self.ow.multi_deploy(models_to_deploy, **self.deploy_kwargs)
89
+ # Wait for apis to be up and move each model to APIS as soon as its API is ready
90
+ print(f"Waiting for {models_to_deploy} to be up")
91
+ api_to_models = defaultdict(list)
92
+ for model, api in apis.items():
93
+ api_to_models[api].append(model)
94
+
95
+ async def handle_api(api, models):
96
+ await api.async_up()
97
+ for model in models:
98
+ APIS[model] = api
99
+ STARTING.remove(model)
100
+
101
+ await asyncio.gather(*[handle_api(api, models) for api, models in api_to_models.items()])
102
+
103
+ def kill(self, model_id):
104
+ api = APIS.pop(model_id, None)
105
+ if api is not None and api not in APIS.values():
106
+ api.down()
107
+ self.ow.jobs.cancel(api.job_id)
108
+
109
+
110
+
111
+ class ChatCompletions(AsyncChatCompletions):
112
+ def create(self, **kwargs):
113
+ assert kwargs.get('stream', False) is False, "ow.chat.completions.create does only support stream=True in async mode"
114
+ response = asyncio.run(super().create(**kwargs))
115
+ return response
116
+
117
+ from openweights.client.temporary_api import TemporaryApi
118
+ import openai
119
+
120
+ def looks_like_openai(model):
121
+ return any(model.lower().startswith(i) for i in ['gpt', 'o1', 'o3'])
122
+
123
+ class OpenAiApi(TemporaryApi):
124
+ def __init__(self, concurrents=10, base_url=None, api_key=None, models=[]):
125
+ self.concurrents = concurrents
126
+ self.sem = asyncio.Semaphore(concurrents)
127
+ if base_url is None:
128
+ self.async_client = openai.AsyncOpenAI()
129
+ self.sync_client = openai.OpenAI()
130
+ else:
131
+ self.async_client = openai.AsyncOpenAI(base_url=base_url, api_key=api_key)
132
+ self.sync_client = openai.OpenAI(base_url=base_url, api_key=api_key)
133
+
134
+ for model in models:
135
+ APIS[model] = self
@@ -0,0 +1,96 @@
1
+ import os
2
+ import json
3
+ from typing import Dict, Optional, Type, Any
4
+ from pydantic import BaseModel, Field
5
+
6
+ from openweights.client.jobs import BaseJob
7
+
8
+
9
+ class CustomJob(BaseJob):
10
+ """Base class for custom jobs that can be run on OpenWeights."""
11
+ mount: Dict[str, str] = {} # source path -> target path mapping
12
+ params: Type[BaseModel] = BaseModel # Pydantic model for parameter validation
13
+ base_image: str = 'nielsrolf/ow-inference' # Base Docker image to use
14
+ requires_vram_gb: int = 24 # Required VRAM in GB
15
+
16
+ def __init__(self, client):
17
+ """Initialize the custom job.
18
+ `client` should be an instance of `openweights.OpenWeights`."""
19
+ self.client = client
20
+
21
+ @property
22
+ def _supabase(self):
23
+ return self.client._supabase
24
+
25
+ @property
26
+ def _org_id(self):
27
+ return self.client.organization_id
28
+
29
+ def get_entrypoint(self, validated_params: BaseModel) -> str:
30
+ """Get the entrypoint command for the job.
31
+
32
+ Args:
33
+ validated_params: The validated parameters as a Pydantic model instance
34
+
35
+ Returns:
36
+ The command to run as a string
37
+ """
38
+ raise NotImplementedError("Subclasses must implement get_entrypoint")
39
+
40
+ def _upload_mounted_files(self) -> Dict[str, str]:
41
+ """Upload all mounted files and return mapping of target paths to file IDs."""
42
+ uploaded_files = {}
43
+
44
+ for source_path, target_path in self.mount.items():
45
+ # Handle both files and directories
46
+ if os.path.isfile(source_path):
47
+ with open(source_path, 'rb') as f:
48
+ file_response = self.client.files.create(f, purpose='custom_job_file')
49
+ uploaded_files[target_path] = file_response['id']
50
+ elif os.path.isdir(source_path):
51
+ # For directories, upload each file maintaining the structure
52
+ for root, _, files in os.walk(source_path):
53
+ for file in files:
54
+ full_path = os.path.join(root, file)
55
+ rel_path = os.path.relpath(full_path, source_path)
56
+ target_file_path = os.path.join(target_path, rel_path)
57
+
58
+ with open(full_path, 'rb') as f:
59
+ file_response = self.client.files.create(f, purpose='custom_job_file')
60
+ uploaded_files[target_file_path] = file_response['id']
61
+ else:
62
+ raise ValueError(f"Mount source path does not exist: {source_path}")
63
+
64
+ return uploaded_files
65
+
66
+ def create(self, **params) -> Dict[str, Any]:
67
+ """Create and submit a custom job.
68
+
69
+ Args:
70
+ **params: Parameters for the job, will be validated against self.params
71
+
72
+ Returns:
73
+ The created job object
74
+ """
75
+ # Validate parameters
76
+ validated_params = self.params(**params)
77
+
78
+ # Upload mounted files
79
+ mounted_files = self._upload_mounted_files()
80
+
81
+ # Get entrypoint command
82
+ entrypoint = self.get_entrypoint(validated_params)
83
+
84
+ # Create job
85
+ job_data = {
86
+ 'type': 'custom',
87
+ 'image': self.base_image,
88
+ 'requires_vram_gb': params.get('requires_vram_gb', self.requires_vram_gb),
89
+ 'script': entrypoint,
90
+ 'params': {
91
+ 'validated_params': validated_params.model_dump(),
92
+ 'mounted_files': mounted_files
93
+ }
94
+ }
95
+
96
+ return self.client.jobs.create(**job_data)
@@ -0,0 +1,42 @@
1
+ from typing import Optional, Dict, Any, List
2
+
3
+
4
+ class Events():
5
+ def __init__(self, supabase):
6
+ self._supabase = supabase
7
+
8
+ def list(self, job_id: Optional[str]=None, run_id: Optional[str]=None):
9
+ """List events by job_id or run_id, sorted by created_at in ascending order"""
10
+ if run_id:
11
+ query = self._supabase.table('events').select('*').eq('run_id', run_id).order('created_at', desc=False)
12
+ elif job_id:
13
+ # First get all runs for this job
14
+ runs_result = self._supabase.table('runs').select('id').eq('job_id', job_id).execute()
15
+ run_ids = [run['id'] for run in runs_result.data]
16
+ # Then get all events for these runs
17
+ query = self._supabase.table('events').select('*').in_('run_id', run_ids).order('created_at', desc=False)
18
+ else:
19
+ raise ValueError("Either job_id or run_id must be provided")
20
+
21
+ result = query.execute()
22
+ return result.data
23
+
24
+ def latest(self, fields: List[str], job_id: Optional[str]=None, run_id: Optional[str]=None) -> Dict[str, Any]:
25
+ """Get a list of events and return a dict with the latest value for each field"""
26
+ events = self.list(job_id=job_id, run_id=run_id)
27
+ latest_values = {}
28
+ if fields == '*':
29
+ latest_values = {}
30
+ for event in events:
31
+ for key, value in event['data'].items():
32
+ if value is not None:
33
+ latest_values[key] = value
34
+ else:
35
+ events = events[::-1] # Reverse order to get latest events first
36
+ while len(fields) > 0 and len(events) > 0:
37
+ event = events.pop()
38
+ for field in fields:
39
+ if field in event['data']:
40
+ latest_values[field] = event['data'][field]
41
+ fields.remove(field)
42
+ return latest_values
@@ -0,0 +1,106 @@
1
+ from typing import Optional, BinaryIO, Dict, Any, List, Union
2
+ import os
3
+ import hashlib
4
+ from datetime import datetime
5
+ from supabase import Client
6
+ import backoff
7
+
8
+ from openweights.validate import validate_messages, validate_preference_dataset
9
+
10
+
11
+ class Files:
12
+ def __init__(self, supabase: Client, organization_id: str):
13
+ self._supabase = supabase
14
+ self._org_id = organization_id
15
+
16
+ def _calculate_file_hash(self, file: BinaryIO) -> str:
17
+ """Calculate SHA-256 hash of file content"""
18
+ sha256_hash = hashlib.sha256()
19
+ for byte_block in iter(lambda: file.read(4096), b""):
20
+ sha256_hash.update(byte_block)
21
+ # Add the org ID to the hash to ensure uniqueness
22
+ sha256_hash.update(self._org_id.encode())
23
+ file.seek(0) # Reset file pointer
24
+ return f"file-{sha256_hash.hexdigest()[:12]}"
25
+
26
+ def _get_storage_path(self, file_id: str) -> str:
27
+ """Get the organization-specific storage path for a file"""
28
+ try:
29
+ result = self._supabase.rpc(
30
+ 'get_organization_storage_path',
31
+ {'org_id': self._org_id, 'filename': file_id}
32
+ ).execute()
33
+ return result.data
34
+ except Exception as e:
35
+ # Fallback if RPC fails
36
+ return f"organizations/{self._org_id}/{file_id}"
37
+
38
+ @backoff.on_exception(backoff.constant, Exception, interval=1, max_time=60, max_tries=60, on_backoff=lambda details: print(f"Retrying... {details['exception']}"))
39
+ def create(self, file: BinaryIO, purpose: str) -> Dict[str, Any]:
40
+ """Upload a file and create a database entry"""
41
+ file_id = f"{purpose}:{self._calculate_file_hash(file)}"
42
+
43
+ # If the file already exists, return the existing file
44
+ try:
45
+ existing_file = self._supabase.table('files').select('*').eq('id', file_id).single().execute().data
46
+ if existing_file:
47
+ return existing_file
48
+ except:
49
+ pass # File doesn't exist yet, continue with creation
50
+
51
+ # Validate file content
52
+ if not self.validate(file, purpose):
53
+ raise ValueError("File content is not valid")
54
+
55
+ file_size = os.fstat(file.fileno()).st_size
56
+ filename = getattr(file, 'name', 'unknown')
57
+
58
+ # Get organization-specific storage path
59
+ storage_path = self._get_storage_path(file_id)
60
+
61
+ # Store file in Supabase Storage with organization path
62
+ self._supabase.storage.from_('files').upload(
63
+ path=storage_path,
64
+ file=file
65
+ )
66
+
67
+ # Create database entry
68
+ data = {
69
+ 'id': file_id,
70
+ 'filename': filename,
71
+ 'purpose': purpose,
72
+ 'bytes': file_size
73
+ }
74
+
75
+ result = self._supabase.table('files').insert(data).execute()
76
+
77
+ return {
78
+ 'id': file_id,
79
+ 'object': 'file',
80
+ 'bytes': file_size,
81
+ 'created_at': datetime.now().timestamp(),
82
+ 'filename': filename,
83
+ 'purpose': purpose,
84
+ }
85
+
86
+ @backoff.on_exception(backoff.constant, Exception, interval=1, max_time=60, max_tries=60, on_backoff=lambda details: print(f"Retrying... {details['exception']}"))
87
+ def content(self, file_id: str) -> bytes:
88
+ """Get file content"""
89
+ storage_path = self._get_storage_path(file_id)
90
+ return self._supabase.storage.from_('files').download(storage_path)
91
+
92
+ def validate(self, file: BinaryIO, purpose: str) -> bool:
93
+ """Validate file content"""
94
+ if purpose in ['conversations']:
95
+ content = file.read().decode('utf-8')
96
+ return validate_messages(content)
97
+ elif purpose == 'preference':
98
+ content = file.read().decode('utf-8')
99
+ return validate_preference_dataset(content)
100
+ else:
101
+ return True
102
+
103
+ @backoff.on_exception(backoff.constant, Exception, interval=1, max_time=60, max_tries=60, on_backoff=lambda details: print(f"Retrying... {details['exception']}"))
104
+ def get_by_id(self, file_id: str) -> Dict[str, Any]:
105
+ """Get file details by ID"""
106
+ return self._supabase.table('files').select('*').eq('id', file_id).single().execute().data