boltz2-python-client 0.2__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.
- boltz2_client/__init__.py +153 -0
- boltz2_client/cli.py +1213 -0
- boltz2_client/client.py +986 -0
- boltz2_client/exceptions.py +213 -0
- boltz2_client/models.py +466 -0
- boltz2_client/models_affinity.py +46 -0
- boltz2_client/utils.py +455 -0
- boltz2_client/virtual_screening.py +608 -0
- boltz2_python_client-0.2.dist-info/METADATA +533 -0
- boltz2_python_client-0.2.dist-info/RECORD +14 -0
- boltz2_python_client-0.2.dist-info/WHEEL +5 -0
- boltz2_python_client-0.2.dist-info/entry_points.txt +2 -0
- boltz2_python_client-0.2.dist-info/licenses/LICENSE +21 -0
- boltz2_python_client-0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# ---------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
|
3
|
+
# ---------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
Custom exceptions for the Boltz-2 Python client.
|
|
8
|
+
|
|
9
|
+
This module defines exception classes that provide specific error handling
|
|
10
|
+
for different types of failures that can occur when using the Boltz-2 API.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from typing import Optional, Dict, Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Boltz2Error(Exception):
|
|
17
|
+
"""Base exception class for all Boltz-2 client errors."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.message = message
|
|
22
|
+
self.details = details or {}
|
|
23
|
+
|
|
24
|
+
def __str__(self) -> str:
|
|
25
|
+
if self.details:
|
|
26
|
+
return f"{self.message} (Details: {self.details})"
|
|
27
|
+
return self.message
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Alias for backward compatibility
|
|
31
|
+
Boltz2ClientError = Boltz2Error
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Boltz2APIError(Boltz2Error):
|
|
35
|
+
"""Exception raised when the API returns an error response."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
message: str,
|
|
40
|
+
status_code: Optional[int] = None,
|
|
41
|
+
response_data: Optional[Dict[str, Any]] = None,
|
|
42
|
+
details: Optional[Dict[str, Any]] = None
|
|
43
|
+
):
|
|
44
|
+
super().__init__(message, details)
|
|
45
|
+
self.status_code = status_code
|
|
46
|
+
self.response_data = response_data or {}
|
|
47
|
+
|
|
48
|
+
def __str__(self) -> str:
|
|
49
|
+
parts = [self.message]
|
|
50
|
+
if self.status_code:
|
|
51
|
+
parts.append(f"Status: {self.status_code}")
|
|
52
|
+
if self.response_data:
|
|
53
|
+
parts.append(f"Response: {self.response_data}")
|
|
54
|
+
if self.details:
|
|
55
|
+
parts.append(f"Details: {self.details}")
|
|
56
|
+
return " | ".join(parts)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Boltz2TimeoutError(Boltz2Error):
|
|
60
|
+
"""Exception raised when a request times out."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, message: str, timeout_seconds: Optional[float] = None):
|
|
63
|
+
super().__init__(message)
|
|
64
|
+
self.timeout_seconds = timeout_seconds
|
|
65
|
+
|
|
66
|
+
def __str__(self) -> str:
|
|
67
|
+
if self.timeout_seconds:
|
|
68
|
+
return f"{self.message} (Timeout: {self.timeout_seconds}s)"
|
|
69
|
+
return self.message
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Boltz2ConnectionError(Boltz2Error):
|
|
73
|
+
"""Exception raised when there are connection issues."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, message: str, endpoint: Optional[str] = None):
|
|
76
|
+
super().__init__(message)
|
|
77
|
+
self.endpoint = endpoint
|
|
78
|
+
|
|
79
|
+
def __str__(self) -> str:
|
|
80
|
+
if self.endpoint:
|
|
81
|
+
return f"{self.message} (Endpoint: {self.endpoint})"
|
|
82
|
+
return self.message
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Boltz2ValidationError(Boltz2Error):
|
|
86
|
+
"""Exception raised when input validation fails."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, message: str, field: Optional[str] = None, value: Optional[Any] = None, details: Optional[Dict[str, Any]] = None):
|
|
89
|
+
super().__init__(message, details)
|
|
90
|
+
self.field = field
|
|
91
|
+
self.value = value
|
|
92
|
+
|
|
93
|
+
def __str__(self) -> str:
|
|
94
|
+
parts = [self.message]
|
|
95
|
+
if self.field:
|
|
96
|
+
parts.append(f"Field: {self.field}")
|
|
97
|
+
if self.value is not None:
|
|
98
|
+
parts.append(f"Value: {self.value}")
|
|
99
|
+
if self.details:
|
|
100
|
+
parts.append(f"Details: {self.details}")
|
|
101
|
+
return " | ".join(parts)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Boltz2AuthenticationError(Boltz2Error):
|
|
105
|
+
"""Exception raised when authentication fails."""
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Boltz2RateLimitError(Boltz2APIError):
|
|
110
|
+
"""Exception raised when rate limits are exceeded."""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
message: str = "Rate limit exceeded",
|
|
115
|
+
retry_after: Optional[int] = None,
|
|
116
|
+
**kwargs
|
|
117
|
+
):
|
|
118
|
+
super().__init__(message, **kwargs)
|
|
119
|
+
self.retry_after = retry_after
|
|
120
|
+
|
|
121
|
+
def __str__(self) -> str:
|
|
122
|
+
base_str = super().__str__()
|
|
123
|
+
if self.retry_after:
|
|
124
|
+
return f"{base_str} | Retry after: {self.retry_after}s"
|
|
125
|
+
return base_str
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class Boltz2ServiceUnavailableError(Boltz2APIError):
|
|
129
|
+
"""Exception raised when the service is unavailable."""
|
|
130
|
+
|
|
131
|
+
def __init__(self, message: str = "Service temporarily unavailable", **kwargs):
|
|
132
|
+
super().__init__(message, **kwargs)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class Boltz2InvalidResponseError(Boltz2Error):
|
|
136
|
+
"""Exception raised when the API returns an invalid or unexpected response."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, message: str, response_content: Optional[str] = None):
|
|
139
|
+
super().__init__(message)
|
|
140
|
+
self.response_content = response_content
|
|
141
|
+
|
|
142
|
+
def __str__(self) -> str:
|
|
143
|
+
if self.response_content:
|
|
144
|
+
# Truncate very long responses
|
|
145
|
+
content = self.response_content[:500]
|
|
146
|
+
if len(self.response_content) > 500:
|
|
147
|
+
content += "..."
|
|
148
|
+
return f"{self.message} | Response: {content}"
|
|
149
|
+
return self.message
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Boltz2ConfigurationError(Boltz2Error):
|
|
153
|
+
"""Exception raised when there are configuration issues."""
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# Convenience function to create appropriate exceptions from HTTP responses
|
|
158
|
+
def create_api_exception(
|
|
159
|
+
status_code: int,
|
|
160
|
+
response_text: str,
|
|
161
|
+
endpoint: Optional[str] = None
|
|
162
|
+
) -> Boltz2APIError:
|
|
163
|
+
"""
|
|
164
|
+
Create an appropriate API exception based on the HTTP status code.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
status_code: HTTP status code
|
|
168
|
+
response_text: Response body text
|
|
169
|
+
endpoint: API endpoint that was called
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Appropriate Boltz2APIError subclass
|
|
173
|
+
"""
|
|
174
|
+
details = {"endpoint": endpoint} if endpoint else {}
|
|
175
|
+
|
|
176
|
+
if status_code == 401:
|
|
177
|
+
return Boltz2AuthenticationError(
|
|
178
|
+
"Authentication failed",
|
|
179
|
+
details=details
|
|
180
|
+
)
|
|
181
|
+
elif status_code == 429:
|
|
182
|
+
return Boltz2RateLimitError(
|
|
183
|
+
"Rate limit exceeded",
|
|
184
|
+
status_code=status_code,
|
|
185
|
+
response_data={"text": response_text},
|
|
186
|
+
details=details
|
|
187
|
+
)
|
|
188
|
+
elif status_code == 503:
|
|
189
|
+
return Boltz2ServiceUnavailableError(
|
|
190
|
+
"Service unavailable",
|
|
191
|
+
status_code=status_code,
|
|
192
|
+
response_data={"text": response_text},
|
|
193
|
+
details=details
|
|
194
|
+
)
|
|
195
|
+
elif 400 <= status_code < 500:
|
|
196
|
+
return Boltz2ValidationError(
|
|
197
|
+
f"Client error: {response_text}",
|
|
198
|
+
details={**details, "status_code": status_code}
|
|
199
|
+
)
|
|
200
|
+
elif 500 <= status_code < 600:
|
|
201
|
+
return Boltz2APIError(
|
|
202
|
+
f"Server error: {response_text}",
|
|
203
|
+
status_code=status_code,
|
|
204
|
+
response_data={"text": response_text},
|
|
205
|
+
details=details
|
|
206
|
+
)
|
|
207
|
+
else:
|
|
208
|
+
return Boltz2APIError(
|
|
209
|
+
f"Unexpected status code {status_code}: {response_text}",
|
|
210
|
+
status_code=status_code,
|
|
211
|
+
response_data={"text": response_text},
|
|
212
|
+
details=details
|
|
213
|
+
)
|
boltz2_client/models.py
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
# ---------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
|
3
|
+
# ---------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
Data models for Boltz-2 API requests and responses.
|
|
8
|
+
|
|
9
|
+
This module defines Pydantic models that represent the data structures
|
|
10
|
+
used by the Boltz-2 API, providing type safety and validation for all
|
|
11
|
+
available parameters and features.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import List, Optional, Dict, Any, Literal, Union
|
|
15
|
+
from pydantic import BaseModel, Field, validator
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
import re
|
|
18
|
+
from .models_affinity import AffinityPrediction
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Modification(BaseModel):
|
|
22
|
+
"""Represents a molecular modification."""
|
|
23
|
+
type: str = Field(..., description="Type of modification")
|
|
24
|
+
position: int = Field(..., description="Position of modification")
|
|
25
|
+
details: Optional[Dict[str, Any]] = Field(None, description="Additional modification details")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AlignmentFileRecord(BaseModel):
|
|
29
|
+
"""Represents a single alignment file record."""
|
|
30
|
+
alignment: str = Field(..., description="Raw alignment file content as string")
|
|
31
|
+
format: Literal["sto", "a3m", "csv", "fasta"] = Field(..., description="Alignment file format")
|
|
32
|
+
rank: int = Field(-1, description="Integer rank to define ordering of alignments")
|
|
33
|
+
|
|
34
|
+
@validator('alignment')
|
|
35
|
+
def validate_alignment_content(cls, v):
|
|
36
|
+
"""Validate alignment content is not empty."""
|
|
37
|
+
if not v.strip():
|
|
38
|
+
raise ValueError("Alignment content cannot be empty")
|
|
39
|
+
return v
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Polymer(BaseModel):
|
|
43
|
+
"""Represents a polymer (protein, DNA, or RNA) in the prediction request."""
|
|
44
|
+
|
|
45
|
+
id: str = Field(..., description="Unique identifier for the polymer (A-Z or 4 alphanumeric chars)")
|
|
46
|
+
molecule_type: Literal["protein", "dna", "rna"] = Field(..., description="Type of molecule")
|
|
47
|
+
sequence: str = Field(..., description="Sequence string")
|
|
48
|
+
cyclic: bool = Field(False, description="Whether the polymer is cyclic")
|
|
49
|
+
modifications: List[Modification] = Field(default_factory=list, description="List of modifications")
|
|
50
|
+
msa: Optional[List[AlignmentFileRecord]] = Field(None, description="Multiple Sequence Alignments")
|
|
51
|
+
|
|
52
|
+
@validator('sequence')
|
|
53
|
+
def validate_sequence(cls, v, values):
|
|
54
|
+
"""Validate sequence based on molecule type."""
|
|
55
|
+
if 'molecule_type' not in values:
|
|
56
|
+
return v
|
|
57
|
+
|
|
58
|
+
molecule_type = values['molecule_type']
|
|
59
|
+
|
|
60
|
+
if molecule_type == "protein":
|
|
61
|
+
# Standard amino acid codes
|
|
62
|
+
valid_chars = set("ACDEFGHIKLMNPQRSTVWY")
|
|
63
|
+
if not all(c.upper() in valid_chars for c in v):
|
|
64
|
+
raise ValueError(f"Invalid amino acid characters in protein sequence: {v}")
|
|
65
|
+
elif molecule_type == "dna":
|
|
66
|
+
# Standard DNA bases
|
|
67
|
+
valid_chars = set("ATCG")
|
|
68
|
+
if not all(c.upper() in valid_chars for c in v):
|
|
69
|
+
raise ValueError(f"Invalid DNA base characters in sequence: {v}")
|
|
70
|
+
elif molecule_type == "rna":
|
|
71
|
+
# Standard RNA bases
|
|
72
|
+
valid_chars = set("AUCG")
|
|
73
|
+
if not all(c.upper() in valid_chars for c in v):
|
|
74
|
+
raise ValueError(f"Invalid RNA base characters in sequence: {v}")
|
|
75
|
+
|
|
76
|
+
return v.upper()
|
|
77
|
+
|
|
78
|
+
@validator('id')
|
|
79
|
+
def validate_id(cls, v):
|
|
80
|
+
"""Validate polymer ID format (single letter A-Z or 4 alphanumeric chars)."""
|
|
81
|
+
if re.match(r'^[A-Z]$', v):
|
|
82
|
+
return v # Single letter A-Z
|
|
83
|
+
elif re.match(r'^[A-Za-z0-9]{4}$', v):
|
|
84
|
+
return v # 4 alphanumeric characters
|
|
85
|
+
else:
|
|
86
|
+
raise ValueError("Polymer ID must be either a single letter (A-Z) or 4 alphanumeric characters")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Ligand(BaseModel):
|
|
90
|
+
"""Represents a ligand in the prediction request."""
|
|
91
|
+
|
|
92
|
+
id: str = Field(..., description="Unique identifier for the ligand")
|
|
93
|
+
smiles: Optional[str] = Field(None, description="SMILES string representation")
|
|
94
|
+
ccd: Optional[str] = Field(None, description="Chemical Component Dictionary (CCD) code")
|
|
95
|
+
predict_affinity: Optional[bool] = Field(False, description="Run affinity prediction for this ligand. Note: only one ligand per request can have this enabled")
|
|
96
|
+
|
|
97
|
+
@validator('smiles')
|
|
98
|
+
def validate_smiles(cls, v):
|
|
99
|
+
"""Basic SMILES validation."""
|
|
100
|
+
if v is not None:
|
|
101
|
+
if not v.strip():
|
|
102
|
+
raise ValueError("SMILES string cannot be empty")
|
|
103
|
+
# Basic character validation - could be enhanced with RDKit
|
|
104
|
+
if any(char in v for char in [' ', '\t', '\n']):
|
|
105
|
+
raise ValueError("SMILES string should not contain whitespace")
|
|
106
|
+
return v.strip()
|
|
107
|
+
return v
|
|
108
|
+
|
|
109
|
+
@validator('ccd')
|
|
110
|
+
def validate_ccd(cls, v):
|
|
111
|
+
"""Basic CCD code validation."""
|
|
112
|
+
if v is not None:
|
|
113
|
+
if not v.strip():
|
|
114
|
+
raise ValueError("CCD code cannot be empty")
|
|
115
|
+
# CCD codes are typically 3-4 character alphanumeric codes
|
|
116
|
+
if not re.match(r'^[A-Za-z0-9]{2,4}$', v.strip()):
|
|
117
|
+
raise ValueError("CCD code must be 2-4 alphanumeric characters")
|
|
118
|
+
return v.strip().upper()
|
|
119
|
+
return v
|
|
120
|
+
|
|
121
|
+
@validator('ccd', always=True)
|
|
122
|
+
def validate_smiles_or_ccd(cls, v, values):
|
|
123
|
+
"""Ensure either SMILES or CCD is provided, but not both."""
|
|
124
|
+
smiles = values.get('smiles')
|
|
125
|
+
if smiles and v:
|
|
126
|
+
raise ValueError("Cannot specify both SMILES and CCD code")
|
|
127
|
+
if not smiles and not v:
|
|
128
|
+
raise ValueError("Must specify either SMILES or CCD code")
|
|
129
|
+
return v
|
|
130
|
+
|
|
131
|
+
@validator('id')
|
|
132
|
+
def validate_id(cls, v):
|
|
133
|
+
"""Validate ligand ID format."""
|
|
134
|
+
if not re.match(r'^[A-Za-z0-9_-]+$', v):
|
|
135
|
+
raise ValueError("Ligand ID must contain only alphanumeric characters, underscores, and hyphens")
|
|
136
|
+
return v
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class Atom(BaseModel):
|
|
140
|
+
"""Represents an atom in constraints."""
|
|
141
|
+
id: Optional[str] = Field(None, description="Polymer/ligand ID (chain identifier)")
|
|
142
|
+
residue_index: int = Field(..., description="Residue index (1-based)")
|
|
143
|
+
atom_name: str = Field(..., description="Atom name (e.g., 'CA', 'SG', 'C22')")
|
|
144
|
+
|
|
145
|
+
@validator('id')
|
|
146
|
+
def validate_id(cls, v):
|
|
147
|
+
"""Validate atom ID format to match server-side validation."""
|
|
148
|
+
if v is not None:
|
|
149
|
+
# Server pattern: ^([A-Z]+|[A-Za-z0-9]{4})$
|
|
150
|
+
# One or more letters (A-Z+) OR exactly 4 alphanumeric characters
|
|
151
|
+
if re.match(r'^[A-Z]+$', v) or re.match(r'^[A-Za-z0-9]{4}$', v):
|
|
152
|
+
return v
|
|
153
|
+
else:
|
|
154
|
+
raise ValueError("Atom ID must be either one or more letters (A-Z) or exactly 4 alphanumeric characters")
|
|
155
|
+
return v
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class PocketConstraint(BaseModel):
|
|
159
|
+
"""Represents a pocket constraint."""
|
|
160
|
+
constraint_type: str = Field("pocket", description="Type of constraint")
|
|
161
|
+
ligand_id: str = Field(..., description="ID of the ligand")
|
|
162
|
+
polymer_id: str = Field(..., description="ID of the polymer")
|
|
163
|
+
residue_ids: List[int] = Field(..., description="List of residue IDs defining the pocket")
|
|
164
|
+
binder: str = Field(..., description="ID of the binding molecule")
|
|
165
|
+
contacts: List[int] = Field(default_factory=list, description="Contact residue indices")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class BondConstraint(BaseModel):
|
|
169
|
+
"""Represents a bond constraint between atoms."""
|
|
170
|
+
constraint_type: str = Field("bond", description="Type of constraint")
|
|
171
|
+
atoms: List[Atom] = Field(..., description="List of atoms involved in the bond (exactly 2)")
|
|
172
|
+
|
|
173
|
+
@validator('atoms')
|
|
174
|
+
def validate_atoms_count(cls, v):
|
|
175
|
+
"""Validate that exactly 2 atoms are specified for a bond."""
|
|
176
|
+
if len(v) != 2:
|
|
177
|
+
raise ValueError("Bond constraint must specify exactly 2 atoms")
|
|
178
|
+
return v
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class PredictionRequest(BaseModel):
|
|
182
|
+
"""Complete prediction request model with all available Boltz-2 parameters."""
|
|
183
|
+
|
|
184
|
+
# Required parameters
|
|
185
|
+
polymers: List[Polymer] = Field(..., description="List of polymers (DNA, RNA, or Protein) - max 5, min 1")
|
|
186
|
+
|
|
187
|
+
# Optional molecular components
|
|
188
|
+
ligands: Optional[List[Ligand]] = Field(None, description="List of ligands - max 5, min 0")
|
|
189
|
+
|
|
190
|
+
# Constraints
|
|
191
|
+
constraints: Optional[List[Union[PocketConstraint, BondConstraint]]] = Field(
|
|
192
|
+
None, description="Optional constraints for the prediction (pocket or bond constraints)"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Diffusion and sampling parameters
|
|
196
|
+
recycling_steps: Optional[int] = Field(
|
|
197
|
+
3, ge=1, le=6,
|
|
198
|
+
description="The number of recycling steps to use for prediction (1-6, default: 3)"
|
|
199
|
+
)
|
|
200
|
+
sampling_steps: Optional[int] = Field(
|
|
201
|
+
50, ge=10, le=1000,
|
|
202
|
+
description="The number of sampling steps to use for prediction (10-1000, default: 50)"
|
|
203
|
+
)
|
|
204
|
+
diffusion_samples: Optional[int] = Field(
|
|
205
|
+
1, ge=1, le=5,
|
|
206
|
+
description="The number of diffusion samples to use for prediction (1-5, default: 1)"
|
|
207
|
+
)
|
|
208
|
+
step_scale: Optional[float] = Field(
|
|
209
|
+
1.638, ge=0.5, le=5.0,
|
|
210
|
+
description="Step size related to temperature of diffusion sampling. Lower = higher diversity (0.5-5.0, default: 1.638)"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Advanced parameters
|
|
214
|
+
without_potentials: Optional[bool] = Field(
|
|
215
|
+
False,
|
|
216
|
+
description="Whether to run without potentials (default: False)"
|
|
217
|
+
)
|
|
218
|
+
output_format: Optional[Literal["mmcif"]] = Field(
|
|
219
|
+
"mmcif",
|
|
220
|
+
description="Output format for structures (default: mmcif)"
|
|
221
|
+
)
|
|
222
|
+
concatenate_msas: Optional[bool] = Field(
|
|
223
|
+
False,
|
|
224
|
+
description="Concatenate Multiple Sequence Alignments for a polymer into one alignment (default: False)"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Affinity prediction parameters
|
|
228
|
+
sampling_steps_affinity: Optional[int] = Field(
|
|
229
|
+
200, ge=10, le=1000,
|
|
230
|
+
description="The number of sampling steps for affinity prediction. Higher values may improve accuracy but increase runtime (10-1000, default: 200)"
|
|
231
|
+
)
|
|
232
|
+
diffusion_samples_affinity: Optional[int] = Field(
|
|
233
|
+
5, ge=1, le=10,
|
|
234
|
+
description="The number of diffusion samples for affinity prediction. Higher values may improve reliability but increase runtime (1-10, default: 5)"
|
|
235
|
+
)
|
|
236
|
+
affinity_mw_correction: Optional[bool] = Field(
|
|
237
|
+
False,
|
|
238
|
+
description="Whether to add Molecular Weight correction to the affinity prediction (default: False)"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
@validator('polymers')
|
|
242
|
+
def validate_polymers_count(cls, v):
|
|
243
|
+
"""Validate polymer count."""
|
|
244
|
+
if len(v) > 5:
|
|
245
|
+
raise ValueError("Maximum 5 polymers allowed")
|
|
246
|
+
if len(v) == 0:
|
|
247
|
+
raise ValueError("At least 1 polymer required")
|
|
248
|
+
return v
|
|
249
|
+
|
|
250
|
+
@validator('ligands')
|
|
251
|
+
def validate_ligands_count(cls, v):
|
|
252
|
+
"""Validate ligand count and affinity prediction constraints."""
|
|
253
|
+
if v is not None:
|
|
254
|
+
if len(v) > 5:
|
|
255
|
+
raise ValueError("Maximum 5 ligands allowed")
|
|
256
|
+
|
|
257
|
+
# Check that only one ligand has predict_affinity=True
|
|
258
|
+
affinity_ligands = [lig for lig in v if getattr(lig, 'predict_affinity', False)]
|
|
259
|
+
if len(affinity_ligands) > 1:
|
|
260
|
+
raise ValueError("Only one ligand per request can have predict_affinity=True")
|
|
261
|
+
return v
|
|
262
|
+
|
|
263
|
+
@validator('constraints')
|
|
264
|
+
def validate_constraints(cls, v):
|
|
265
|
+
"""Validate constraints format."""
|
|
266
|
+
if v is not None:
|
|
267
|
+
for constraint in v:
|
|
268
|
+
if isinstance(constraint, dict):
|
|
269
|
+
constraint_type = constraint.get('constraint_type')
|
|
270
|
+
if constraint_type not in ['pocket', 'bond']:
|
|
271
|
+
raise ValueError(f"Invalid constraint type: {constraint_type}")
|
|
272
|
+
return v
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class StructureData(BaseModel):
|
|
276
|
+
"""Represents structure data in the response."""
|
|
277
|
+
|
|
278
|
+
format: str = Field(..., description="Structure format (e.g., 'mmcif')")
|
|
279
|
+
structure: str = Field(..., description="Structure data content")
|
|
280
|
+
name: Optional[str] = Field(None, description="Structure name")
|
|
281
|
+
source: Optional[str] = Field(None, description="Structure source file")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class PredictionResponse(BaseModel):
|
|
285
|
+
"""Complete prediction response model."""
|
|
286
|
+
|
|
287
|
+
structures: List[StructureData] = Field(..., description="Predicted structures")
|
|
288
|
+
confidence_scores: Optional[List[float]] = Field(None, description="Confidence scores for predictions")
|
|
289
|
+
metrics: Optional[Dict[str, Any]] = Field(None, description="Runtime metrics and statistics")
|
|
290
|
+
|
|
291
|
+
# Affinity prediction results
|
|
292
|
+
affinities: Optional[Dict[str, AffinityPrediction]] = Field(
|
|
293
|
+
None,
|
|
294
|
+
description="Predicted affinity values for ligands (keyed by ligand ID)"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Additional confidence metrics
|
|
298
|
+
ptm_scores: Optional[List[float]] = Field(None, description="Predicted TM score for the complex")
|
|
299
|
+
iptm_scores: Optional[List[float]] = Field(None, description="Predicted TM score when aggregating at interfaces")
|
|
300
|
+
ligand_iptm_scores: Optional[List[float]] = Field(None, description="ipTM but only at protein-ligand interfaces")
|
|
301
|
+
protein_iptm_scores: Optional[List[float]] = Field(None, description="ipTM but only at protein-protein interfaces")
|
|
302
|
+
complex_plddt_scores: Optional[List[float]] = Field(None, description="Average pLDDT score for the complex")
|
|
303
|
+
complex_iplddt_scores: Optional[List[float]] = Field(None, description="Average pLDDT score when upweighting interface tokens")
|
|
304
|
+
complex_pde_scores: Optional[List[float]] = Field(None, description="Average PDE score for the complex")
|
|
305
|
+
complex_ipde_scores: Optional[List[float]] = Field(None, description="Average PDE score when aggregating at interfaces")
|
|
306
|
+
chains_ptm_scores: Optional[List[float]] = Field(None, description="Predicted TM score within each chain")
|
|
307
|
+
pair_chains_iptm_scores: Optional[List[Dict[str, Any]]] = Field(None, description="Predicted TM score between each pair of chains")
|
|
308
|
+
|
|
309
|
+
@validator('structures')
|
|
310
|
+
def validate_structures(cls, v):
|
|
311
|
+
"""Validate structures list."""
|
|
312
|
+
if len(v) == 0:
|
|
313
|
+
raise ValueError("At least one structure must be returned")
|
|
314
|
+
return v
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class HealthStatus(BaseModel):
|
|
318
|
+
"""Health status response model."""
|
|
319
|
+
|
|
320
|
+
status: str = Field(..., description="Health status")
|
|
321
|
+
timestamp: Optional[datetime] = Field(None, description="Status timestamp")
|
|
322
|
+
details: Optional[Dict[str, Any]] = Field(None, description="Additional health details")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class ModelInfo(BaseModel):
|
|
326
|
+
"""Model information."""
|
|
327
|
+
|
|
328
|
+
modelUrl: str = Field(..., description="Model URL")
|
|
329
|
+
shortName: str = Field(..., description="Model short name")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class LicenseInfo(BaseModel):
|
|
333
|
+
"""License information."""
|
|
334
|
+
|
|
335
|
+
name: str = Field(..., description="License name")
|
|
336
|
+
path: str = Field(..., description="License file path")
|
|
337
|
+
sha: str = Field(..., description="License file SHA")
|
|
338
|
+
size: int = Field(..., description="License file size")
|
|
339
|
+
url: str = Field(..., description="License URL")
|
|
340
|
+
type: str = Field(..., description="License type")
|
|
341
|
+
content: str = Field(..., description="License content")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class ServiceMetadata(BaseModel):
|
|
345
|
+
"""Service metadata response model."""
|
|
346
|
+
|
|
347
|
+
assetInfo: List[str] = Field(..., description="Asset information")
|
|
348
|
+
licenseInfo: LicenseInfo = Field(..., description="License information")
|
|
349
|
+
modelInfo: List[ModelInfo] = Field(..., description="Model information")
|
|
350
|
+
repository_override: str = Field(..., description="Repository override")
|
|
351
|
+
version: str = Field(..., description="Service version")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class PredictionJob(BaseModel):
|
|
355
|
+
"""Represents a prediction job for tracking."""
|
|
356
|
+
|
|
357
|
+
job_id: str = Field(..., description="Unique job identifier")
|
|
358
|
+
request: PredictionRequest = Field(..., description="Original request")
|
|
359
|
+
status: Literal["pending", "running", "completed", "failed"] = Field(..., description="Job status")
|
|
360
|
+
created_at: datetime = Field(..., description="Job creation time")
|
|
361
|
+
started_at: Optional[datetime] = Field(None, description="Job start time")
|
|
362
|
+
completed_at: Optional[datetime] = Field(None, description="Job completion time")
|
|
363
|
+
result: Optional[PredictionResponse] = Field(None, description="Job result")
|
|
364
|
+
error: Optional[str] = Field(None, description="Error message if failed")
|
|
365
|
+
progress: Optional[float] = Field(None, ge=0.0, le=1.0, description="Job progress (0-1)")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# Convenience type aliases
|
|
369
|
+
PolymerType = Literal["protein", "dna", "rna"]
|
|
370
|
+
OutputFormat = Literal["mmcif"]
|
|
371
|
+
JobStatus = Literal["pending", "running", "completed", "failed"]
|
|
372
|
+
AlignmentFormat = Literal["sto", "a3m", "csv", "fasta"]
|
|
373
|
+
ConstraintType = Literal["pocket", "bond"]
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# Add YAML configuration models at the end of the file
|
|
377
|
+
|
|
378
|
+
class YAMLProtein(BaseModel):
|
|
379
|
+
"""YAML protein configuration matching official Boltz format."""
|
|
380
|
+
id: str = Field(..., description="Protein identifier")
|
|
381
|
+
sequence: str = Field(..., description="Protein sequence")
|
|
382
|
+
msa: Optional[str] = Field(None, description="Path to MSA file or 'empty'")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class YAMLLigand(BaseModel):
|
|
386
|
+
"""YAML ligand configuration matching official Boltz format."""
|
|
387
|
+
id: str = Field(..., description="Ligand identifier")
|
|
388
|
+
smiles: str = Field(..., description="SMILES string")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class YAMLSequence(BaseModel):
|
|
392
|
+
"""YAML sequence entry (protein or ligand)."""
|
|
393
|
+
protein: Optional[YAMLProtein] = Field(None, description="Protein configuration")
|
|
394
|
+
ligand: Optional[YAMLLigand] = Field(None, description="Ligand configuration")
|
|
395
|
+
|
|
396
|
+
@validator('ligand', always=True)
|
|
397
|
+
def validate_protein_or_ligand(cls, v, values):
|
|
398
|
+
"""Ensure either protein or ligand is specified, but not both."""
|
|
399
|
+
protein = values.get('protein')
|
|
400
|
+
if protein and v:
|
|
401
|
+
raise ValueError("Cannot specify both protein and ligand in the same sequence entry")
|
|
402
|
+
if not protein and not v:
|
|
403
|
+
raise ValueError("Must specify either protein or ligand in sequence entry")
|
|
404
|
+
return v
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class YAMLAffinity(BaseModel):
|
|
408
|
+
"""YAML affinity property configuration."""
|
|
409
|
+
binder: str = Field(..., description="ID of the binding molecule (ligand)")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class YAMLProperties(BaseModel):
|
|
413
|
+
"""YAML properties configuration."""
|
|
414
|
+
affinity: Optional[YAMLAffinity] = Field(None, description="Affinity prediction configuration")
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class YAMLConfig(BaseModel):
|
|
418
|
+
"""Complete YAML configuration matching official Boltz format."""
|
|
419
|
+
version: int = Field(1, description="Configuration version")
|
|
420
|
+
sequences: List[YAMLSequence] = Field(..., description="List of sequences (proteins and ligands)")
|
|
421
|
+
properties: Optional[YAMLProperties] = Field(None, description="Properties to predict")
|
|
422
|
+
|
|
423
|
+
@validator('sequences')
|
|
424
|
+
def validate_sequences(cls, v):
|
|
425
|
+
"""Validate sequences list."""
|
|
426
|
+
if len(v) == 0:
|
|
427
|
+
raise ValueError("At least one sequence must be specified")
|
|
428
|
+
return v
|
|
429
|
+
|
|
430
|
+
def to_prediction_request(self) -> PredictionRequest:
|
|
431
|
+
"""Convert YAML config to PredictionRequest."""
|
|
432
|
+
polymers = []
|
|
433
|
+
ligands = []
|
|
434
|
+
|
|
435
|
+
for seq in self.sequences:
|
|
436
|
+
if seq.protein:
|
|
437
|
+
# Handle MSA
|
|
438
|
+
msa_records = None
|
|
439
|
+
if seq.protein.msa and seq.protein.msa != "empty":
|
|
440
|
+
# For now, we'll handle MSA files separately
|
|
441
|
+
# This would need to be loaded from the file path
|
|
442
|
+
pass
|
|
443
|
+
|
|
444
|
+
polymer = Polymer(
|
|
445
|
+
id=seq.protein.id,
|
|
446
|
+
molecule_type="protein",
|
|
447
|
+
sequence=seq.protein.sequence,
|
|
448
|
+
msa=msa_records
|
|
449
|
+
)
|
|
450
|
+
polymers.append(polymer)
|
|
451
|
+
|
|
452
|
+
elif seq.ligand:
|
|
453
|
+
ligand = Ligand(
|
|
454
|
+
id=seq.ligand.id,
|
|
455
|
+
smiles=seq.ligand.smiles
|
|
456
|
+
)
|
|
457
|
+
ligands.append(ligand)
|
|
458
|
+
|
|
459
|
+
return PredictionRequest(
|
|
460
|
+
polymers=polymers,
|
|
461
|
+
ligands=ligands if ligands else None
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# Convenience type aliases
|
|
466
|
+
YAMLConfigType = YAMLConfig
|