From 7d5bbec3b1044e8b5845083dc7cc842e0e1093ce Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 31 Aug 2026 11:59:28 +0000 Subject: [PATCH 1/2] Sync SDK v1.3.1 from neo repository --- ofspectrum/resources/audio.py | 98 ++++++++++++++++++++++------------- tests/test_audio.py | 20 +++++++ 2 files changed, 82 insertions(+), 36 deletions(-) diff --git a/ofspectrum/resources/audio.py b/ofspectrum/resources/audio.py index cc14e44..347c516 100644 --- a/ofspectrum/resources/audio.py +++ b/ofspectrum/resources/audio.py @@ -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], @@ -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, @@ -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, } ) ) @@ -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, @@ -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) @@ -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 ( @@ -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, @@ -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. @@ -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"), @@ -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: @@ -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", "") @@ -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, @@ -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: @@ -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, @@ -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], @@ -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 @@ -1320,6 +1339,10 @@ 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: @@ -1327,7 +1350,7 @@ def _encode_channel(index: int): 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, @@ -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, @@ -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: @@ -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 = [] @@ -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, diff --git a/tests/test_audio.py b/tests/test_audio.py index b35fea1..8205a44 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -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): From ecedcb87569bb35282da69924e40d36364bf9f1b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 4 Sep 2026 15:06:53 +0000 Subject: [PATCH 2/2] Sync SDK v1.3.1 from neo repository --- AGENT_GUIDE.md | 12 ++++++------ CHANGELOG.md | 4 ++++ README.md | 10 +++++----- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/AGENT_GUIDE.md b/AGENT_GUIDE.md index 3a2f8ba..f98f144 100644 --- a/AGENT_GUIDE.md +++ b/AGENT_GUIDE.md @@ -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. @@ -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. @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a7eb7f..db54263 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 2c768e4..f1fe576 100644 --- a/README.md +++ b/README.md @@ -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