Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions AGENT_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,16 +237,16 @@ Current public token types:

| Type | Notebook Contract | Permanent Account Capacity | Use When |
|------|-------------------|----------------------------|----------|
| Standard | One public notebook; no new private notebooks (zero) | Permanent 1 GiB | The app needs neither a custom verification key nor a new private notebook. |
| Pro | One public notebook; five private notebooks | Permanent 6 GiB | The workflow needs a configurable `public_key` or any new private notebook. |
| Enterprise | One public notebook; ten private notebooks | Permanent 11 GiB | Admin-managed Enterprise workflows. Public SDK callers cannot create this type. |
| Standard | One public notebook; no new private notebooks (zero) | Permanent 100 MiB | The app needs neither a custom verification key nor a new private notebook. |
| Pro | One public notebook; five private notebooks | Permanent 100 MiB | The workflow needs a configurable `public_key` or any new private notebook. |
| Enterprise | One public notebook; ten private notebooks | Permanent 100 MiB | Admin-managed Enterprise workflows. Public SDK callers cannot create this type. |

Recommended behavior:

- Create Standard tokens by default.
- Create Pro tokens when the customer needs a configurable verification key or any new private notebook.
- Existing tokens can be upgraded from Standard to Pro, but cannot be downgraded.
- A Standard-to-Pro upgrade replaces that token's 1 GiB entitlement with 6 GiB; it does not produce 7 GiB.
- A Standard-to-Pro upgrade replaces the token type; storage entitlement stays 100 MiB and does not stack.
- Permanent capacity remains with the account after token retirement.
- A token type upgrade may consume quota or incur a billing charge.
- Store token IDs in the customer app database.
Expand Down Expand Up @@ -513,7 +513,7 @@ Additional constraints:
- Private notebook credentials must be unique under the same token.
- Private notebook credentials are optional at the SDK level, but apps that need credential-gated private metadata should explicitly pass `credential_val`.
- Notebook media is limited to detected image, audio, and video content. Each notebook accepts at most 500 current files, each file may be up to 100 MiB, and notebook text is limited to 10 MiB of UTF-8 data.
- Media consumes account capacity. Each acquired Standard, Pro, or Enterprise token contributes a permanent 1, 6, or 11 GiB entitlement respectively; available capacity may also include legacy credit and paid whole-GiB blocks.
- Media consumes account capacity. Each acquired Standard, Pro, or Enterprise token contributes a permanent 100 MiB entitlement; available capacity may also include legacy credit and paid whole-GiB blocks.
- Standard tokens cannot create new private notebooks. Existing Standard private notebooks are grandfathered and remain editable or deletable, but cannot be replaced after deletion.
- Pro tokens support five private notebooks, and Enterprise tokens support ten.
- If a token already has a public notebook, update the existing notebook instead of creating another one.
Expand Down Expand Up @@ -913,7 +913,7 @@ Recommended runtime flow:
- API keys are created in the OfSpectrum dashboard, not through the SDK.
- Token IDs, notebook IDs, media IDs, and current revisions should be stored in the customer app database.
- Standard tokens are the safest default for new workflows.
- Standard, Pro, and Enterprise tokens contribute permanent 1, 6, and 11 GiB account capacity respectively.
- Standard, Pro, and Enterprise tokens each contribute a permanent 100 MiB account capacity.
- Pro tokens are needed when the workflow requires a configurable `public_key` or up to five private notebooks; Enterprise creation is Admin-managed.
- Account defaults and storage charge authorization are configured in the Web Console, not through an API key.
- A staged save begins one session and reuses its `save_session_id` across all files and the commit.
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## Unreleased

- Permanent token storage entitlement is 100 MiB for Standard, Pro, and Enterprise. Paid whole-GiB overage blocks are unchanged.

## 1.3.1 - 2026-08-24

- `open_stream_pool(..., keepalive_interval_seconds=120)` heartbeats every pooled connection so idle Neo model sessions are not retired.
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,14 @@ account:

| Token Type | Notebook Contract | Permanent Account Capacity |
|------------|-------------------|----------------------------|
| Standard | One public notebook; no new private notebooks (zero) | Permanent 1 GiB |
| Pro | One public notebook; five private notebooks | Permanent 6 GiB |
| Enterprise | One public notebook; ten private notebooks | Permanent 11 GiB |
| Standard | One public notebook; no new private notebooks (zero) | Permanent 100 MiB |
| Pro | One public notebook; five private notebooks | Permanent 100 MiB |
| Enterprise | One public notebook; ten private notebooks | Permanent 100 MiB |

Public SDK callers can create Standard and Pro tokens. Enterprise creation is
Admin-managed. Retiring a token does not remove its permanent capacity, and a
Standard-to-Pro upgrade replaces that token's 1 GiB entitlement with 6 GiB; it
does not add them together.
Standard-to-Pro upgrade replaces the token type; storage stays 100 MiB and
does not stack.

```python
import os
Expand Down
98 changes: 62 additions & 36 deletions ofspectrum/resources/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@
from .base import BaseResource


def _token_id_from_stream_events(requested: Optional[str], events: Optional[list]) -> str:
requested_id = (requested or "").strip()
for event in reversed(events or []):
if not isinstance(event, dict) or event.get("type") != "done":
continue
value = event.get("token_id")
if isinstance(value, str) and value.strip():
return value.strip()
return requested_id


def _start_deadline_watchdog(
websocket: Any,
deadline: Optional[float],
Expand Down Expand Up @@ -388,7 +399,7 @@ def _run_operation(
)
result = StreamingEncodeResult(
encoded_pcm=b"".join(encoded_chunks),
token_id=self._token_id,
token_id=_token_id_from_stream_events(self._token_id, events),
sample_rate=self._sample_rate,
channels=self._channels,
events=events,
Expand Down Expand Up @@ -559,18 +570,20 @@ def _ensure_connection(self, slot: _StreamPoolSlot, deadline: Optional[float]) -
code="StreamingPoolProtocol",
)

config_payload = {
"sample_rate": self._sample_rate,
"channels": self._channels,
"strength": self._strength,
"smooth": self._smooth,
"verify_and_reencode": self._verify_and_reencode,
}
if self._token_id:
config_payload["token_id"] = self._token_id
websocket.send(
json.dumps(
{
"type": "config",
"config": {
"token_id": self._token_id,
"sample_rate": self._sample_rate,
"channels": self._channels,
"strength": self._strength,
"smooth": self._smooth,
"verify_and_reencode": self._verify_and_reencode,
},
"config": config_payload,
}
)
)
Expand Down Expand Up @@ -831,7 +844,7 @@ def _auto_stream_pool(

def open_stream_pool(
self,
token_id: str,
token_id: Optional[str] = None,
*,
connections: int = 2,
sample_rate: int = CANONICAL_SAMPLE_RATE,
Expand All @@ -854,8 +867,9 @@ def open_stream_pool(
the pool and completing ``admitted`` / ``ready`` warms the connection;
do not send dummy audio just to warm up. ``None`` disables keepalive.
"""
if not token_id:
raise ValueError("token_id is required")
if token_id is not None and (not isinstance(token_id, str) or not token_id.strip()):
raise ValueError("token_id must be a non-empty string when provided")
token_id = token_id.strip() if token_id else ""
if (
isinstance(connections, bool)
or not isinstance(connections, int)
Expand Down Expand Up @@ -912,13 +926,13 @@ def _validate_encode_input(
cls,
audio: Union[str, Path, BinaryIO],
*,
token_id: str,
token_id: Optional[str],
strength: float,
interval: Optional[float],
timeout: float,
) -> None:
if not token_id:
raise ValueError("token_id is required")
if token_id is not None and (not isinstance(token_id, str) or not token_id.strip()):
raise ValueError("token_id must be a non-empty string when provided")
if isinstance(strength, bool) or not math.isfinite(strength) or not 0.1 <= strength <= 2.0:
raise ValueError("strength must be between 0.1 and 2.0")
if interval is not None and (
Expand Down Expand Up @@ -960,7 +974,7 @@ def _websocket_url(self, path: str) -> str:
def encode(
self,
audio: Union[str, Path, BinaryIO],
token_id: str,
token_id: Optional[str] = None,
*,
strength: float = 1.0,
smooth: bool = True,
Expand All @@ -979,7 +993,7 @@ def encode(

Args:
audio: Audio file path or file-like object
token_id: Watermark token ID to use
token_id: Watermark token ID to use. Omit to use the account default token.
strength: Watermark strength (0.1-2.0, default 1.0)
smooth: Smoothness control (default True). Same meaning on file encode and streaming encode.
interval: Optional interval between watermarks. Omit it to use the service default; 0 is explicit.
Expand Down Expand Up @@ -1018,7 +1032,6 @@ def encode(

# Prepare form data for the product API.
form_data = {
"token_id": token_id,
"strength": str(strength),
"smooth": self._form_bool(smooth, "smooth"),
"save_file": self._form_bool(save_file, "save_file"),
Expand All @@ -1033,6 +1046,8 @@ def encode(
save_file=save_file,
),
}
if token_id:
form_data["token_id"] = token_id
if interval is not None:
form_data["interval"] = str(interval)
if original_filename:
Expand Down Expand Up @@ -1109,7 +1124,7 @@ def encode(
if duration <= 0:
raise OfSpectrumError(message="Encoding response did not include a positive audio duration")
returned_token_id = response.headers.get("X-Token-Id", token_id)
if returned_token_id != token_id:
if token_id and returned_token_id and returned_token_id != token_id:
raise OfSpectrumError(message="Encoding response token did not match the request")
content_disp = response.headers.get("Content-Disposition", "")

Expand Down Expand Up @@ -1205,7 +1220,7 @@ def decode(
def stream_encode(
self,
audio: Union[str, Path, BinaryIO, bytes],
token_id: str,
token_id: Optional[str] = None,
*,
strength: float = 1.0,
smooth: bool = True,
Expand Down Expand Up @@ -1237,8 +1252,9 @@ def stream_encode(
persistent stream pool instead of opening a new WebSocket each time.
"""
source = read_audio_bytes(audio)
if not token_id:
raise ValueError("token_id is required")
if token_id is not None and (not isinstance(token_id, str) or not token_id.strip()):
raise ValueError("token_id must be a non-empty string when provided")
token_id = token_id.strip() if token_id else ""
if isinstance(strength, bool) or not math.isfinite(strength) or not 0.1 <= strength <= 2.0:
raise ValueError("strength must be between 0.1 and 2.0")
if isinstance(interval, bool) or not math.isfinite(interval) or interval < 0.0:
Expand All @@ -1262,6 +1278,7 @@ def stream_encode(

pcm, info = decode_canonical_interleaved_pcm(source)
channel_count = max(1, int(info.channels))
resolved_token_id = token_id
if interval == 0.0:
pool = self._auto_stream_pool(
token_id=token_id,
Expand All @@ -1276,6 +1293,7 @@ def stream_encode(
encoded_pcm = streamed.encoded_pcm
quality_warning = streamed.quality_warning
duration = streamed.audio_duration
resolved_token_id = streamed.token_id or token_id
elif channel_count == 1:
streamed = self.stream_encode_pcm(
[pcm],
Expand All @@ -1291,6 +1309,7 @@ def stream_encode(
encoded_pcm = streamed.encoded_pcm
quality_warning = streamed.quality_warning
duration = streamed.audio_duration
resolved_token_id = streamed.token_id or token_id
else:
channel_pcm = split_interleaved_pcm_f32le(pcm, channel_count)
results = [None] * channel_count
Expand Down Expand Up @@ -1320,14 +1339,18 @@ def _encode_channel(index: int):
)
quality_warning = any(item.quality_warning for item in results)
duration = max(item.audio_duration for item in results)
resolved_token_id = next(
(item.token_id for item in results if item and item.token_id),
token_id,
)
rebuilt = rebuild_encoded_media(encoded_pcm, info)
duration = int(round(duration))
if duration <= 0 and info.duration_seconds > 0:
duration = max(1, int(round(info.duration_seconds)))
return EncodeResult.from_bytes(
audio_bytes=rebuilt,
audio_duration=duration,
token_id=token_id,
token_id=resolved_token_id,
file_name=suggested_filename(info),
content_type=info.content_type,
quality_warning=quality_warning,
Expand All @@ -1336,7 +1359,7 @@ def _encode_channel(index: int):
def stream_encode_pcm(
self,
pcm_chunks: Iterable[bytes],
token_id: str,
token_id: Optional[str] = None,
*,
sample_rate: int = 48000,
channels: int = 1,
Expand All @@ -1359,8 +1382,9 @@ def stream_encode_pcm(
Yield chunks as they become available so encoding can start before
the full file is ready.
"""
if not token_id:
raise ValueError("token_id is required")
if token_id is not None and (not isinstance(token_id, str) or not token_id.strip()):
raise ValueError("token_id must be a non-empty string when provided")
token_id = token_id.strip() if token_id else ""
if sample_rate <= 0:
raise ValueError("sample_rate must be positive")
if channels <= 0 or channels > 8:
Expand All @@ -1386,17 +1410,19 @@ def stream_encode_pcm(

url = self._websocket_url("/audio/watermark/ws/encode")
headers = {"Authorization": f"Bearer {self._client._api_key}"}
stream_config = {
"sample_rate": sample_rate,
"channels": channels,
"strength": strength,
"smooth": smooth,
"interval": interval,
"verify_and_reencode": verify_and_reencode,
}
if token_id:
stream_config["token_id"] = token_id
config = {
"type": "config",
"config": {
"token_id": token_id,
"sample_rate": sample_rate,
"channels": channels,
"strength": strength,
"smooth": smooth,
"interval": interval,
"verify_and_reencode": verify_and_reencode,
},
"config": stream_config,
}
encoded_chunks = []
events = []
Expand Down Expand Up @@ -1490,7 +1516,7 @@ def drain_output(*, block: bool) -> bool:

return StreamingEncodeResult(
encoded_pcm=b"".join(encoded_chunks),
token_id=token_id,
token_id=_token_id_from_stream_events(token_id, events),
sample_rate=sample_rate,
channels=channels,
events=events,
Expand Down
20 changes: 20 additions & 0 deletions tests/test_audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,26 @@ def test_stream_encode_returns_only_after_done(monkeypatch):
assert result.encoded_pcm == b"encoded"
assert result.events[-1]["type"] == "done"
assert result.quality_warning is True
assert result.token_id == "token-1"


def test_stream_encode_omitted_token_uses_done_event_token_id(monkeypatch):
fake = _FakeWebSocket(
[
b"encoded",
'{"type":"done","token_id":"default-token"}',
]
)
monkeypatch.setattr(websockets.sync.client, "connect", lambda *_args, **_kwargs: fake)
client = OfSpectrum(api_key="test-key")
try:
result = client.audio.stream_encode_pcm([b"pcm"])
finally:
client.close()

config = json.loads(fake.sent[0])
assert "token_id" not in config["config"]
assert result.token_id == "default-token"


def test_stream_encode_disables_keepalive_heartbeat_timeout(monkeypatch):
Expand Down