From 843fec8591ebe655f972475efd7880de1ca005e3 Mon Sep 17 00:00:00 2001 From: Lengyue Date: Thu, 29 Oct 2026 05:13:40 +0000 Subject: [PATCH 1/4] feat(types): accept the free s2.1-pro-free model in Model New accounts start with no API credit, and s2.1-pro-free is the model they can call for free. It already works at runtime (the SDK forwards the model header unchanged), but type checkers rejected it because the Model Literal did not list it. Add it, with a unit test that it is sent as the header. Co-Authored-By: Claude Opus 5.5 --- src/fishaudio/types/shared.py | 2 +- tests/unit/test_tts.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/fishaudio/types/shared.py b/src/fishaudio/types/shared.py index a160e65..1eceabf 100644 --- a/src/fishaudio/types/shared.py +++ b/src/fishaudio/types/shared.py @@ -22,7 +22,7 @@ class PaginatedResponse(BaseModel, Generic[T]): # Model types -Model = Literal["s1", "s2-pro", "s2.1-pro"] +Model = Literal["s1", "s2-pro", "s2.1-pro", "s2.1-pro-free"] # Deprecated models DEPRECATED_MODELS = {"speech-1.5", "speech-1.6"} diff --git a/tests/unit/test_tts.py b/tests/unit/test_tts.py index f6075a5..7a336c8 100644 --- a/tests/unit/test_tts.py +++ b/tests/unit/test_tts.py @@ -87,6 +87,18 @@ def test_convert_basic(self, tts_client, mock_client_wrapper): call_args = mock_client_wrapper.request.call_args assert call_args[1]["headers"]["model"] == "s2.1-pro" + def test_convert_with_free_model(self, tts_client, mock_client_wrapper): + """Test the free s2.1-pro-free model is sent as the model header.""" + mock_response = Mock() + mock_response.iter_bytes.return_value = iter([b"audio"]) + mock_client_wrapper.request.return_value = mock_response + + audio = tts_client.convert(text="Hello world", model="s2.1-pro-free") + + assert audio == b"audio" + call_args = mock_client_wrapper.request.call_args + assert call_args[1]["headers"]["model"] == "s2.1-pro-free" + def test_convert_with_reference_id(self, tts_client, mock_client_wrapper): """Test TTS with reference voice ID.""" mock_response = Mock() From b762074db7bac85680754bc49972878dd5589267 Mon Sep 17 00:00:00 2001 From: Lengyue Date: Thu, 29 Oct 2026 05:13:40 +0000 Subject: [PATCH 2/4] docs(readme): show the free model and tag the API-key link with UTM params - "Selecting a model" also shows model="s2.1-pro-free", which works on a new account before any API credit is added. - The fish.audio API-key link carries utm_source=github, utm_medium=sdk-readme and utm_content=python, so signups from the README can be attributed. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f732655..9b1e963 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ pip install fish-audio-sdk[utils] ## Authentication -Get your API key from [fish.audio/app/api-keys](https://fish.audio/app/api-keys): +Get your API key from [fish.audio/app/api-keys](https://fish.audio/app/api-keys/?utm_source=github&utm_medium=sdk-readme&utm_content=python): ```bash export FISH_API_KEY=your_api_key_here @@ -93,6 +93,12 @@ production_audio = client.tts.convert( text="Production speech", model="s2.1-pro", ) + +# Free to use, including on a new account with no API credit +free_audio = client.tts.convert( + text="Free speech", + model="s2.1-pro-free", +) ``` **With custom voice:** From b90a207bf51a8d9d8928d1c1f80710204bfbf204 Mon Sep 17 00:00:00 2001 From: Lengyue Date: Fri, 30 Oct 2026 07:00:17 +0000 Subject: [PATCH 3/4] feat(types): accept drama-3 in Model; README Drama 3 example (FISH-1664) Co-Authored-By: Claude Opus 5.5 --- README.md | 6 ++++++ src/fishaudio/types/shared.py | 2 +- tests/unit/test_tts.py | 12 ++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9b1e963..557c75d 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,12 @@ free_audio = client.tts.convert( text="Free speech", model="s2.1-pro-free", ) + +# Drama 3: our most expressive model (uses API credit) +drama_audio = client.tts.convert( + text="Expressive speech", + model="drama-3", +) ``` **With custom voice:** diff --git a/src/fishaudio/types/shared.py b/src/fishaudio/types/shared.py index 1eceabf..0628f9f 100644 --- a/src/fishaudio/types/shared.py +++ b/src/fishaudio/types/shared.py @@ -22,7 +22,7 @@ class PaginatedResponse(BaseModel, Generic[T]): # Model types -Model = Literal["s1", "s2-pro", "s2.1-pro", "s2.1-pro-free"] +Model = Literal["s1", "s2-pro", "s2.1-pro", "s2.1-pro-free", "drama-3"] # Deprecated models DEPRECATED_MODELS = {"speech-1.5", "speech-1.6"} diff --git a/tests/unit/test_tts.py b/tests/unit/test_tts.py index 7a336c8..7cabc48 100644 --- a/tests/unit/test_tts.py +++ b/tests/unit/test_tts.py @@ -99,6 +99,18 @@ def test_convert_with_free_model(self, tts_client, mock_client_wrapper): call_args = mock_client_wrapper.request.call_args assert call_args[1]["headers"]["model"] == "s2.1-pro-free" + def test_convert_with_drama_3(self, tts_client, mock_client_wrapper): + """Test the drama-3 model is sent as the model header.""" + mock_response = Mock() + mock_response.iter_bytes.return_value = iter([b"audio"]) + mock_client_wrapper.request.return_value = mock_response + + audio = tts_client.convert(text="Hello world", model="drama-3") + + assert audio == b"audio" + call_args = mock_client_wrapper.request.call_args + assert call_args[1]["headers"]["model"] == "drama-3" + def test_convert_with_reference_id(self, tts_client, mock_client_wrapper): """Test TTS with reference voice ID.""" mock_response = Mock() From f9c2420c19698c95d9e04fee5dc52015c20751d3 Mon Sep 17 00:00:00 2001 From: Lengyue Date: Fri, 30 Oct 2026 07:46:37 +0000 Subject: [PATCH 4/4] fix(tts): send opus_bitrate in bps as the API expects Since platform-api #625 (2026-05) the TTS API takes opus_bitrate in bps (24000/32000/48000/64000, -1000 = auto). The SDK still sent kbps, so every format="opus" request failed with HTTP 400 "Invalid opus bitrate 32". TTSConfig/TTSRequest keep the public kbps values; the request serializer converts them. Co-Authored-By: Claude Opus 5.5 --- src/fishaudio/types/tts.py | 7 ++++++- tests/unit/test_types.py | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/fishaudio/types/tts.py b/src/fishaudio/types/tts.py index a6bfbc3..8799cbd 100644 --- a/src/fishaudio/types/tts.py +++ b/src/fishaudio/types/tts.py @@ -2,7 +2,7 @@ from typing import Annotated, Literal, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_serializer from .shared import AudioFormat, LatencyMode @@ -157,6 +157,11 @@ class TTSRequest(BaseModel): condition_on_previous_chunks: bool = True early_stop_threshold: float = 1.0 + @field_serializer("opus_bitrate") + def _opus_bitrate_bps(self, value: int) -> int: + # The API takes opus_bitrate in bps (24000, 32000, ...); -1000 means auto. + return value * 1000 if value > 0 else value + # WebSocket event types for streaming TTS class StartEvent(BaseModel): diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index cfc8446..d6f760a 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -173,3 +173,11 @@ def test_voice_with_unlist_visibility(self, sample_voice_response): sample_voice_response["visibility"] = "unlist" voice = Voice.model_validate(sample_voice_response) assert voice.visibility == "unlist" + + +def test_tts_request_sends_opus_bitrate_in_bps(): + assert TTSRequest(text="hi", format="opus").model_dump()["opus_bitrate"] == 32000 + assert TTSRequest(text="hi", opus_bitrate=64).model_dump()["opus_bitrate"] == 64000 + assert ( + TTSRequest(text="hi", opus_bitrate=-1000).model_dump()["opus_bitrate"] == -1000 + )