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.
- openweights/__init__.py +3 -0
- openweights/client/__init__.py +176 -0
- openweights/client/cache_on_disk.py +34 -0
- openweights/client/chat.py +135 -0
- openweights/client/custom_job.py +96 -0
- openweights/client/events.py +42 -0
- openweights/client/files.py +106 -0
- openweights/client/jobs.py +293 -0
- openweights/client/run.py +189 -0
- openweights/client/temporary_api.py +231 -0
- openweights/cluster/.gitignore +1 -0
- openweights/cluster/README.md +32 -0
- openweights/cluster/org_manager.py +366 -0
- openweights/cluster/start_runpod.py +211 -0
- openweights/cluster/supervisor.py +177 -0
- openweights/dashboard/README.md +77 -0
- openweights/dashboard/backend/database.py +432 -0
- openweights/dashboard/backend/main.py +285 -0
- openweights/dashboard/backend/models.py +69 -0
- openweights/dashboard/backend/static/assets/ow.svg +22 -0
- openweights/dashboard/backend/static/assets/vite.svg +1 -0
- openweights/dashboard/backend/utils.py +143 -0
- openweights/dashboard/deploy.sh +41 -0
- openweights/dashboard/frontend/.gitignore +24 -0
- openweights/dashboard/frontend/README.md +50 -0
- openweights/dashboard/frontend/eslint.config.js +28 -0
- openweights/dashboard/frontend/index.html +13 -0
- openweights/dashboard/frontend/package-lock.json +7839 -0
- openweights/dashboard/frontend/package.json +40 -0
- openweights/dashboard/frontend/public/ow.svg +22 -0
- openweights/dashboard/frontend/public/vite.svg +1 -0
- openweights/dashboard/frontend/src/App.css +47 -0
- openweights/dashboard/frontend/src/App.tsx +300 -0
- openweights/dashboard/frontend/src/api.ts +292 -0
- openweights/dashboard/frontend/src/assets/react.svg +1 -0
- openweights/dashboard/frontend/src/components/Auth/Auth.tsx +231 -0
- openweights/dashboard/frontend/src/components/DetailViews/FileContent.tsx +40 -0
- openweights/dashboard/frontend/src/components/DetailViews/JobDetailView.tsx +213 -0
- openweights/dashboard/frontend/src/components/DetailViews/MetricsDisplay.tsx +90 -0
- openweights/dashboard/frontend/src/components/DetailViews/MetricsPlots.tsx +179 -0
- openweights/dashboard/frontend/src/components/DetailViews/OutputsDisplay.tsx +62 -0
- openweights/dashboard/frontend/src/components/DetailViews/RunDetailView.tsx +147 -0
- openweights/dashboard/frontend/src/components/DetailViews/WorkerDetailView.tsx +257 -0
- openweights/dashboard/frontend/src/components/DetailViews/index.ts +3 -0
- openweights/dashboard/frontend/src/components/DetailViews.tsx +1 -0
- openweights/dashboard/frontend/src/components/JobsListView.tsx +134 -0
- openweights/dashboard/frontend/src/components/JobsView.tsx +366 -0
- openweights/dashboard/frontend/src/components/Organizations/OrganizationDetail.tsx +592 -0
- openweights/dashboard/frontend/src/components/Organizations/OrganizationList.tsx +227 -0
- openweights/dashboard/frontend/src/components/Organizations/OrganizationSwitcher.tsx +39 -0
- openweights/dashboard/frontend/src/components/Organizations/OrganizationsList.tsx +168 -0
- openweights/dashboard/frontend/src/components/Organizations/TokensTab.tsx +261 -0
- openweights/dashboard/frontend/src/components/RefreshButton.tsx +33 -0
- openweights/dashboard/frontend/src/components/RunsListView.tsx +134 -0
- openweights/dashboard/frontend/src/components/RunsView.tsx +332 -0
- openweights/dashboard/frontend/src/components/StatusCheckboxes.tsx +62 -0
- openweights/dashboard/frontend/src/components/TokenView.tsx +245 -0
- openweights/dashboard/frontend/src/components/ViewToggle.tsx +33 -0
- openweights/dashboard/frontend/src/components/WorkersListView.tsx +140 -0
- openweights/dashboard/frontend/src/components/WorkersView.tsx +357 -0
- openweights/dashboard/frontend/src/contexts/AuthContext.tsx +103 -0
- openweights/dashboard/frontend/src/contexts/OrganizationContext.tsx +92 -0
- openweights/dashboard/frontend/src/index.css +59 -0
- openweights/dashboard/frontend/src/main.tsx +10 -0
- openweights/dashboard/frontend/src/supabaseClient.ts +11 -0
- openweights/dashboard/frontend/src/types/supabase.ts +63 -0
- openweights/dashboard/frontend/src/types.ts +64 -0
- openweights/dashboard/frontend/src/vite-env.d.ts +1 -0
- openweights/dashboard/frontend/tsconfig.app.json +26 -0
- openweights/dashboard/frontend/tsconfig.json +7 -0
- openweights/dashboard/frontend/tsconfig.node.json +24 -0
- openweights/dashboard/frontend/vite.config.ts +8 -0
- openweights/dashboard/runpod-startup.sh +107 -0
- openweights/dashboard/screenshots/job_details.png +0 -0
- openweights/dashboard/screenshots/jobs_view.png +0 -0
- openweights/dashboard/screenshots/run_details.png +0 -0
- openweights/dashboard/screenshots/workers.png +0 -0
- openweights/utils.py +250 -0
- openweights/validate.py +276 -0
- openweights/worker/__init__.py +0 -0
- openweights/worker/dpo_ft.py +67 -0
- openweights/worker/gpu_health_check.py +111 -0
- openweights/worker/inference.py +140 -0
- openweights/worker/main.py +435 -0
- openweights/worker/orpo_ft.py +64 -0
- openweights/worker/sft.py +109 -0
- openweights/worker/training.py +94 -0
- openweights/worker/utils.py +88 -0
- openweights-0.1.0.dist-info/METADATA +139 -0
- openweights-0.1.0.dist-info/RECORD +92 -0
- openweights-0.1.0.dist-info/WHEEL +4 -0
- openweights-0.1.0.dist-info/licenses/LICENSE.txt +21 -0
openweights/__init__.py
ADDED
|
@@ -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
|