Skip to content

Commit c3310f8

Browse files
authored
Merge pull request #4 from ofspectrum/sync-sdk-v1.1.5
Sync SDK v1.1.5 from neo
2 parents 2c32efc + 9bce2df commit c3310f8

13 files changed

Lines changed: 776 additions & 116 deletions

File tree

‎AGENT_GUIDE.md‎

Lines changed: 616 additions & 0 deletions
Large diffs are not rendered by default.

‎README.md‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,20 @@ else:
4242

4343
# Check your quota.
4444
quota = client.quotas.get_encode_quota()
45-
print(f"Remaining encode quota: {quota.remaining}/{quota.quota_limit} seconds")
45+
print(f"Remaining encode quota: {quota.remaining}/{quota.limit} seconds")
4646
```
4747

48+
## Building With AI Coding Agents
49+
50+
If you are building an app or internal tool with this SDK, see [AGENT_GUIDE.md](./AGENT_GUIDE.md). It explains token modeling patterns, notebook/provenance guidance, security notes, test flows, and includes a copyable prompt for AI coding agents such as Codex.
51+
52+
## Test Audio
53+
54+
Synthetic WAV files for encode/decode smoke tests are available in [`examples/audio`](./examples/audio). They contain no third-party audio and are intended for local SDK verification.
55+
4856
## Token Management
4957

50-
Standard tokens are the simplest option. Pro tokens support workflow-specific verification-key configuration. Enterprise token configuration is available for eligible enterprise accounts or existing enterprise tokens.
58+
Standard tokens are the simplest option. Pro tokens support workflow-specific verification-key configuration.
5159

5260
```python
5361
import os
@@ -72,7 +80,7 @@ token = client.tokens.update(
7280
name="New Name",
7381
)
7482

75-
# Update a Pro or eligible Enterprise token verification key.
83+
# Update a Pro token verification key.
7684
token = client.tokens.update(
7785
token_id="token-uuid",
7886
public_key=verification_key,
@@ -132,6 +140,15 @@ print(f"Encoded {result.audio_duration:.2f}s of PCM")
132140

133141
Attach notes and media files to tokens. Private notebooks require a credential, and limits depend on your account and token configuration.
134142

143+
Notebook limits:
144+
145+
| Token Type | Public Notebooks | Private Notebooks |
146+
|------------|------------------|-------------------|
147+
| `standard` | 1 | 0 |
148+
| `pro` | 1 | 1 |
149+
150+
If the limit is reached, the SDK raises a `ValidationError` with a customer-facing message.
151+
135152
```python
136153
notebook = client.notebooks.create(
137154
token_id=token.id,
@@ -160,10 +177,10 @@ notebooks = client.notebooks.list(token_id=token.id)
160177

161178
```python
162179
quota = client.quotas.get_encode_quota()
163-
print(f"Remaining encode quota: {quota.remaining}")
180+
print(f"Remaining encode quota: {quota.remaining}/{quota.limit}")
164181

165182
decode_quota = client.quotas.get_decode_quota()
166-
print(f"Remaining decode quota: {decode_quota.remaining}")
183+
print(f"Remaining decode quota: {decode_quota.remaining}/{decode_quota.limit}")
167184

168185
if client.quotas.check_encode_available(duration_seconds=300):
169186
result = client.audio.encode(audio="input.mp3", token_id=token.id)
@@ -186,7 +203,7 @@ try:
186203
except RateLimitError as e:
187204
print(f"Rate limited. Retry after {e.retry_after} seconds")
188205
except QuotaExceededError as e:
189-
print(f"Quota exceeded for {e.service}")
206+
print(e.message)
190207
except WatermarkExistsError:
191208
print("Audio already has a watermark")
192209
except AuthenticationError:
938 KB
Binary file not shown.
1.1 MB
Binary file not shown.

‎ofspectrum/__init__.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@
2626
2727
# Check quota
2828
quota = client.quotas.get_encode_quota()
29-
print(f"Remaining: {quota.remaining}/{quota.quota_limit}")
29+
print(f"Remaining: {quota.remaining}/{quota.limit}")
3030
"""
3131

32-
__version__ = "1.1.4"
32+
__version__ = "1.1.5"
3333
__author__ = "OfSpectrum"
3434

3535
from .client import OfSpectrum, AsyncOfSpectrum

‎ofspectrum/exceptions.py‎

Lines changed: 73 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,71 @@ def __init__(self, message: str = "Network error", **kwargs):
167167
}
168168

169169

170+
def _raise_direct_error(
171+
error_code: str,
172+
message: str,
173+
status_code: int,
174+
details: Optional[Dict[str, Any]] = None,
175+
):
176+
"""Raise SDK exceptions for legacy/direct API error payloads."""
177+
details = details or {}
178+
179+
# Map common legacy error codes used by tokens_router and other endpoints.
180+
if error_code == "QuotaExceeded":
181+
raise QuotaExceededError(
182+
message="Token quota exceeded. Please upgrade your plan or contact support.",
183+
code=error_code,
184+
status_code=status_code or 429,
185+
details={},
186+
)
187+
elif error_code in ("QuotaMissing", "QuotaCheckFailed"):
188+
raise QuotaExceededError(
189+
message="Token quota is temporarily unavailable. Please try again later or contact support.",
190+
code=error_code,
191+
status_code=status_code or 500,
192+
details={},
193+
)
194+
elif error_code in ("InsufficientBalance", "insufficient_balance") or status_code == 402:
195+
raise QuotaExceededError(
196+
message="Insufficient balance. Please add funds or choose a subscription.",
197+
code=error_code,
198+
status_code=status_code or 402,
199+
details={},
200+
)
201+
elif error_code == "Unauthorized":
202+
raise AuthenticationError(message=message, code=error_code, status_code=status_code or 403, details=details)
203+
elif error_code in ("DuplicateName", "ValidationError"):
204+
raise ValidationError(message=message, code=error_code, status_code=status_code or 400, details=details)
205+
elif error_code == "Missing required fields" or error_code == "InvalidField":
206+
raise ValidationError(message=message, code=error_code, status_code=status_code or 400, details=details)
207+
elif error_code == "UnableToGenerate":
208+
raise OfSpectrumError(message=message, code=error_code, status_code=status_code or 500, details=details)
209+
else:
210+
raise OfSpectrumError(message=message, code=error_code, status_code=status_code or 500, details=details)
211+
212+
213+
def _raise_detail_error(message: str, status_code: int):
214+
"""Raise sanitized SDK exceptions for FastAPI detail strings."""
215+
if message == "Only one public notebook allowed per token":
216+
raise ValidationError(
217+
message="This token already has a public notebook. Each token supports one public notebook.",
218+
code="NotebookLimit",
219+
status_code=status_code or 400,
220+
details={},
221+
)
222+
if message == "Private notebook limit reached for this token":
223+
raise ValidationError(
224+
message=(
225+
"Private notebook limit reached for this token. Standard tokens do not support "
226+
"private notebooks; Pro tokens support one private notebook."
227+
),
228+
code="NotebookLimit",
229+
status_code=status_code or 400,
230+
details={},
231+
)
232+
raise OfSpectrumError(message=message, status_code=status_code)
233+
234+
170235
def raise_for_error(response_data, status_code: int):
171236
"""
172237
Parse API error response and raise appropriate exception.
@@ -187,29 +252,20 @@ def raise_for_error(response_data, status_code: int):
187252
if "error" in response_data and isinstance(response_data.get("error"), str):
188253
error_code = response_data.get("error")
189254
message = response_data.get("message", error_code)
190-
191-
# Map common error codes
192-
if error_code == "QuotaExceeded":
193-
raise QuotaExceededError(message=message, status_code=status_code or 429)
194-
elif error_code == "QuotaMissing" or error_code == "QuotaCheckFailed":
195-
raise QuotaExceededError(message=message, status_code=status_code or 500)
196-
elif error_code == "Unauthorized":
197-
raise AuthenticationError(message=message, status_code=status_code or 403)
198-
elif error_code == "DuplicateName":
199-
raise ValidationError(message=message, status_code=status_code or 400)
200-
elif error_code == "Missing required fields" or error_code == "InvalidField":
201-
raise ValidationError(message=message, status_code=status_code or 400)
202-
elif error_code == "UnableToGenerate":
203-
raise OfSpectrumError(message=message, code=error_code, status_code=status_code or 500)
204-
else:
205-
raise OfSpectrumError(message=message, code=error_code, status_code=status_code or 500)
255+
_raise_direct_error(error_code, message, status_code, response_data)
206256

207257
if response_data.get("status") != "error":
208258
# Also check for FastAPI validation errors (detail field)
209259
if "detail" in response_data and status_code >= 400:
210260
detail = response_data.get("detail")
211261
if isinstance(detail, str):
212-
raise OfSpectrumError(message=detail, status_code=status_code)
262+
_raise_detail_error(detail, status_code)
263+
elif isinstance(detail, dict):
264+
error_code = detail.get("error") or detail.get("code")
265+
message = detail.get("message") or detail.get("detail") or error_code or "API request failed"
266+
if isinstance(error_code, str):
267+
_raise_direct_error(error_code, message, status_code, detail)
268+
raise OfSpectrumError(message=str(message), status_code=status_code, details=detail)
213269
elif isinstance(detail, list):
214270
# FastAPI validation error format
215271
messages = [f"{d.get('loc', ['?'])[-1]}: {d.get('msg', '?')}" for d in detail]

‎ofspectrum/models/quota.py‎

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,59 +3,58 @@
33
"""
44

55
from dataclasses import dataclass, field
6-
from typing import Optional, List, Literal
6+
from typing import Optional, List
77

88

99
@dataclass
1010
class Quota:
1111
"""Represents a service quota"""
1212

13-
service_name: str
14-
quota_type: Literal["request_limit", "duration_limit"]
15-
quota_limit: int
16-
current_usage: int
13+
limit: int
14+
used: int
1715
reset_at: Optional[str] = None
16+
_service: str = field(default="", repr=False)
17+
_kind: str = field(default="", repr=False)
1818

1919
@property
2020
def remaining(self) -> int:
2121
"""Get remaining quota"""
22-
return max(0, self.quota_limit - self.current_usage)
22+
return max(0, self.limit - self.used)
2323

2424
@property
2525
def used_percentage(self) -> float:
2626
"""Get percentage of quota used"""
27-
if self.quota_limit == 0:
27+
if self.limit == 0:
2828
return 0.0
29-
return (self.current_usage / self.quota_limit) * 100
29+
return (self.used / self.limit) * 100
3030

3131
@property
3232
def is_exceeded(self) -> bool:
3333
"""Check if quota is exceeded"""
34-
return self.current_usage >= self.quota_limit
34+
return self.used >= self.limit
3535

3636
@classmethod
3737
def from_dict(cls, data: dict) -> "Quota":
3838
"""Create Quota from API response dict.
3939
40-
Handles both snake_case (from /quotas/all) and camelCase (from /quota) formats.
40+
Handles API response formats.
4141
"""
42-
# Handle both snake_case and camelCase field names
43-
service_name = data.get("service_name") or data.get("serviceName", "")
44-
quota_type = data.get("quota_type") or data.get("quotaType", "request_limit")
45-
quota_limit = data.get("quota_limit") or data.get("quotaLimit", 0)
46-
current_usage = data.get("current_usage") or data.get("currentUsage", 0)
42+
service = data.get("service_name") or data.get("serviceName", "")
43+
kind = data.get("quota_type") or data.get("quotaType", "request_limit")
44+
limit = data.get("quota_limit") or data.get("quotaLimit", 0)
45+
used = data.get("current_usage") or data.get("currentUsage", 0)
4746
reset_at = data.get("reset_at") or data.get("resetDate") or data.get("reset_date")
4847

4948
return cls(
50-
service_name=service_name,
51-
quota_type=quota_type,
52-
quota_limit=int(quota_limit) if quota_limit else 0,
53-
current_usage=int(current_usage) if current_usage else 0,
49+
limit=int(limit) if limit else 0,
50+
used=int(used) if used else 0,
5451
reset_at=reset_at,
52+
_service=service,
53+
_kind=kind,
5554
)
5655

5756
def __str__(self) -> str:
58-
return f"{self.service_name}: {self.current_usage}/{self.quota_limit} ({self.quota_type})"
57+
return f"remaining: {self.remaining}"
5958

6059

6160
@dataclass
@@ -64,20 +63,20 @@ class QuotaList:
6463

6564
quotas: List[Quota] = field(default_factory=list)
6665

67-
def get(self, service_name: str) -> Optional[Quota]:
66+
def _get_by_service(self, service: str) -> Optional[Quota]:
6867
"""Get quota for a specific service"""
6968
for quota in self.quotas:
70-
if quota.service_name == service_name:
69+
if quota._service == service:
7170
return quota
7271
return None
7372

7473
def get_encode_quota(self) -> Optional[Quota]:
7574
"""Get AudioWatermarkEncode quota"""
76-
return self.get("AudioWatermarkEncode")
75+
return self._get_by_service("AudioWatermarkEncode")
7776

7877
def get_decode_quota(self) -> Optional[Quota]:
7978
"""Get AudioWatermarkDecode quota"""
80-
return self.get("AudioWatermarkDecode")
79+
return self._get_by_service("AudioWatermarkDecode")
8180

8281
@classmethod
8382
def from_list(cls, data: List[dict]) -> "QuotaList":

‎ofspectrum/models/token.py‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,14 @@ class TokenCreateParams:
3838
"""Parameters for creating a new token"""
3939

4040
name: str
41-
token_type: Literal["standard", "pro", "enterprise"] = "standard"
41+
token_type: Literal["standard", "pro"] = "standard"
4242
public_key: Optional[int] = None
43-
enterprise_verification: bool = False
4443

4544
def to_dict(self) -> dict:
4645
"""Convert to API request dict"""
4746
data = {
4847
"name": self.name,
4948
"token_type": self.token_type,
50-
"enterprise_verification": self.enterprise_verification,
5149
}
5250
if self.public_key is not None:
5351
data["public_key"] = self.public_key

‎ofspectrum/resources/notebooks.py‎

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def list(self, token_id: str) -> List[Notebook]:
3232
data = response.json()
3333
raise_for_error(data, response.status_code)
3434

35-
# Backend returns a direct list
35+
# API returns a direct list
3636
notes_data = data if isinstance(data, list) else data.get("data", {}).get("notes", [])
3737
return [Notebook.from_dict(n) for n in notes_data]
3838

@@ -47,15 +47,14 @@ def get(self, note_id: str) -> Notebook:
4747
Notebook object
4848
4949
Note:
50-
The backend doesn't have a single-note GET endpoint.
51-
This uses PATCH with empty body to get the note (returns current state).
50+
This returns the current notebook state when available.
5251
"""
53-
# Use PATCH with empty body - backend returns the updated note
52+
# Use an empty update request to return the current note state.
5453
response = self._patch(f"/watermark-notes/{note_id}", data={})
5554
data = response.json()
5655
raise_for_error(data, response.status_code)
5756

58-
# Backend returns the note directly or wrapped in data
57+
# API returns the note directly or wrapped in data
5958
note_data = data if isinstance(data, dict) and "id" in data else data.get("data", {})
6059
return Notebook.from_dict(note_data)
6160

@@ -75,7 +74,7 @@ def create(
7574
note_name: Notebook name/title
7675
text_content: Notebook content (markdown supported)
7776
is_public: Whether the notebook is publicly visible (default: True)
78-
credential_val: Credential for private notes (default: "123" if not provided)
77+
credential_val: Optional credential for private notes
7978
8079
Returns:
8180
Newly created Notebook object
@@ -100,7 +99,7 @@ def create(
10099
data = response.json()
101100
raise_for_error(data, response.status_code)
102101

103-
# Backend returns the note directly
102+
# API returns the note directly
104103
note_data = data if isinstance(data, dict) and "id" in data else data.get("data", {})
105104
return Notebook.from_dict(note_data)
106105

@@ -141,7 +140,7 @@ def update(
141140
data = response.json()
142141
raise_for_error(data, response.status_code)
143142

144-
# Backend returns the note directly
143+
# API returns the note directly
145144
note_data = data if isinstance(data, dict) and "id" in data else data.get("data", {})
146145
return Notebook.from_dict(note_data)
147146

@@ -230,7 +229,7 @@ def upload_media(
230229
resp_data = response.json()
231230
raise_for_error(resp_data, response.status_code)
232231

233-
# Backend returns the media record directly
232+
# API returns the media record directly
234233
return resp_data if isinstance(resp_data, dict) else resp_data.get("data", {})
235234

236235
def delete_media(self, media_id: str) -> bool:
@@ -270,7 +269,7 @@ def get_media_url(self, media_id: str) -> str:
270269
data = response.json()
271270
raise_for_error(data, response.status_code)
272271

273-
# Backend returns {"url": "..."} or {"data": {"url": "..."}}
272+
# API returns {"url": "..."} or {"data": {"url": "..."}}
274273
if isinstance(data, dict):
275274
return data.get("url", "") or data.get("data", {}).get("url", "")
276275
return ""

0 commit comments

Comments
 (0)