diff --git a/.agents/skills/fish-audio-tts/SKILL.md b/.agents/skills/fish-audio-tts/SKILL.md index a7c1dac3..7c30a18e 100644 --- a/.agents/skills/fish-audio-tts/SKILL.md +++ b/.agents/skills/fish-audio-tts/SKILL.md @@ -26,11 +26,11 @@ The backend model is chosen with the `model` **HTTP header**, not a body field. `model` is **required — there is no default**. Pass one of: - `s2.1-pro` — latest generation. Best quality: inline emotion tags, 80+ languages, multi-speaker. Hero narration. -- `s2.1-pro-free` — free tier of s2.1-pro. Drafts, samples, and validation runs at $0. +- `s2.1-pro-free` — **promotional** free access to s2.1-pro. Drafts, samples, and validation runs at $0 during the promo window only. Per the [fish.audio announcement](https://fish.audio/ar/blog/s2-1-pro-free-api/?articleLocale=en): free through end of July 2026, subject to Fair Use, no SLA/latency guarantee, requests may be retained, and commercial use is restricted. Never route production or client narration through it. - `s2-pro` — first S2 generation. Stable high quality with emotion-tag support. - `s1` — previous flagship. Kept for compatibility with existing integrations. -Billing is **per UTF-8 byte of input text** (not per character). CJK text and emoji cost 3-4x an ASCII character of the same visible length. Approximate: `s1` / `s2-pro` / `s2.1-pro` ≈ $15 per 1M bytes, `s2.1-pro-free` = $0. Verify current pricing at https://fish.audio before large batches. +Billing is **per UTF-8 byte of input text** (not per character). CJK text and emoji cost 3-4x an ASCII character of the same visible length. Approximate: `s1` / `s2-pro` / `s2.1-pro` ≈ $15 per 1M bytes, `s2.1-pro-free` = $0 during the promo window only (the tool's `estimate_cost()` switches to the paid `s2.1-pro` rate after end of July 2026). Verify current pricing at https://fish.audio before large batches. ## Inline emotion tags (S2 models only) @@ -93,7 +93,7 @@ The provider writes the audio to `output_path` and returns `data.output` plus th 1. Generate a 10-15 second sample with the chosen `model` + `reference_id` before a full paid narration. 2. Ask the user to approve voice naturalness, emotion, and pace. 3. Generate the full narration only after approval. -4. For batch/localization variants where cost matters, prototype on `s2.1-pro-free` ($0) and upgrade the final to `s2.1-pro`. +4. For batch/localization variants where cost matters, prototype on `s2.1-pro-free` (promo-window $0; non-commercial drafts only) and upgrade the final to `s2.1-pro`. ## Troubleshooting diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index c19970f8..cd8080f7 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -272,15 +272,17 @@ No subscription — pure pay-as-you-go, no minimum spend. | Model | Best for | |-------|----------| | `s2.1-pro` | Latest flagship — inline emotion tags, 80+ languages, hero narration | -| `s2.1-pro-free` | Free tier of s2.1-pro — drafts, samples, validation runs at $0 | +| `s2.1-pro-free` | Promotional free access to s2.1-pro — drafts, samples, validation runs (see caveats below) | | `s2-pro` | First S2 generation — stable high quality with emotion-tag support | | `s1` | Previous flagship, kept for compatibility (no emotion tags) | +**`s2.1-pro-free` caveats — promotional, not a durable free tier.** Per the [fish.audio announcement](https://fish.audio/ar/blog/s2-1-pro-free-api/?articleLocale=en), free API access runs **through the end of July 2026** and is subject to Fair Use limits, carries **no SLA or latency guarantee**, requests **may be retained** by fish.audio, and **commercial use is restricted**. Don't route client work or production narration through it, and don't plan long-term costs at $0 — `fish_audio_tts.estimate_cost()` falls back to the paid `s2.1-pro` rate after the promotional window ends. + The legacy `speech-1.x` tier and `s1-mini` have been removed from the fish.audio API and are not supported. #### Pricing -Billing is **per UTF-8 byte of input text** (not per character) — CJK text and emoji cost 3-4x an ASCII character of the same visible length. Approximate: `s1` / `s2-pro` / `s2.1-pro` ≈ $15 per 1M bytes; `s2.1-pro-free` is $0. Verify current pricing at [fish.audio](https://fish.audio) before large batches. +Billing is **per UTF-8 byte of input text** (not per character) — CJK text and emoji cost 3-4x an ASCII character of the same visible length. Approximate: `s1` / `s2-pro` / `s2.1-pro` ≈ $15 per 1M bytes; `s2.1-pro-free` is $0 only during the promotional window (through end of July 2026 — see caveats above). Verify current pricing at [fish.audio](https://fish.audio) before large batches. --- diff --git a/tests/contracts/test_phase3_contracts.py b/tests/contracts/test_phase3_contracts.py index 3fa4a3b0..4e9b366b 100644 --- a/tests/contracts/test_phase3_contracts.py +++ b/tests/contracts/test_phase3_contracts.py @@ -683,6 +683,7 @@ class TestCapabilityMetadata: "dashscope", "doubao", "elevenlabs", + "fish_audio", "google_tts", "kling_official", "openai", diff --git a/tests/tools/test_fish_audio_tts.py b/tests/tools/test_fish_audio_tts.py index 0f45b263..4c3985c5 100644 --- a/tests/tools/test_fish_audio_tts.py +++ b/tests/tools/test_fish_audio_tts.py @@ -2,12 +2,14 @@ The real API is never called — requests.post is patched to return synthetic audio bytes. Covers status gating, the required-model contract, the -voice_id -> reference_id alias, request shape, output writing, cost, and -API-key redaction on error. +model_id -> model and voice_id -> reference_id aliases, selector routing, +request shape, output writing, idempotency keys, cost (including the +s2.1-pro-free promotional window), and API-key redaction on error. """ from __future__ import annotations +from datetime import date from unittest.mock import patch import pytest @@ -78,6 +80,73 @@ class TestModelContract: assert result.success is False assert "Unknown fish.audio model" in result.error + def test_schema_declares_model_required(self): + schema = FishAudioTTS.input_schema + assert "model" in schema["required"] + assert "model_id" in schema["properties"] + + def test_model_id_alias_accepted(self, api_key, tmp_path): + out = tmp_path / "alias.mp3" + with patch("requests.post", return_value=_FakeResponse()) as mock_post: + result = FishAudioTTS().execute( + {"text": "hello", "model_id": "s1", "output_path": str(out)} + ) + + assert result.success is True + assert result.data["model"] == "s1" + _, kwargs = mock_post.call_args + assert kwargs["headers"]["model"] == "s1" + + def test_model_takes_precedence_over_model_id(self, api_key, tmp_path): + out = tmp_path / "alias.mp3" + with patch("requests.post", return_value=_FakeResponse()) as mock_post: + FishAudioTTS().execute( + { + "text": "hello", + "model": "s2.1-pro", + "model_id": "s1", + "output_path": str(out), + } + ) + _, kwargs = mock_post.call_args + assert kwargs["headers"]["model"] == "s2.1-pro" + + +# ---------------------------------------------------------------------- +# Selector routing — the shared tts_selector contract +# ---------------------------------------------------------------------- + + +class TestSelectorRouting: + def test_selector_call_with_model_id_and_voice_id_reaches_fish_audio( + self, api_key, tmp_path + ): + """Regression: tts_selector exposes model_id/voice_id, not model/reference_id. + + A normal selector call with preferred_provider=fish_audio must succeed. + """ + from tools.audio.tts_selector import TTSSelector + + out = tmp_path / "selector.mp3" + with patch("requests.post", return_value=_FakeResponse(b"SELECTED")) as mock_post: + result = TTSSelector().execute( + { + "text": "routed through the selector", + "preferred_provider": "fish_audio", + "model_id": "s1", + "voice_id": "voice-xyz", + "output_path": str(out), + } + ) + + assert result.success is True + assert result.data["selected_provider"] == "fish_audio" + assert result.data["model"] == "s1" + assert out.read_bytes() == b"SELECTED" + _, kwargs = mock_post.call_args + assert kwargs["headers"]["model"] == "s1" + assert kwargs["json"]["reference_id"] == "voice-xyz" + # ---------------------------------------------------------------------- # Happy path @@ -181,9 +250,87 @@ class TestCost: ascii_cost = tool.estimate_cost({"text": "abc", "model": "s1"}) assert cjk > ascii_cost - def test_s2_1_pro_free_costs_zero(self): + def test_s2_1_pro_free_costs_zero_during_promo_window(self): tool = FishAudioTTS() - assert tool.estimate_cost({"text": "same text here", "model": "s2.1-pro-free"}) == 0.0 + with patch.object(FishAudioTTS, "_today", return_value=date(2026, 7, 1)): + assert tool.estimate_cost({"text": "same text here", "model": "s2.1-pro-free"}) == 0.0 + + def test_s2_1_pro_free_charged_at_paid_rate_after_promo_window(self): + tool = FishAudioTTS() + with patch.object(FishAudioTTS, "_today", return_value=date(2026, 8, 1)): + free_after = tool.estimate_cost({"text": "same text here", "model": "s2.1-pro-free"}) + paid = tool.estimate_cost({"text": "same text here", "model": "s2.1-pro"}) + assert free_after == paid + assert free_after > 0.0 + + def test_estimate_cost_accepts_model_id_alias(self): + tool = FishAudioTTS() + assert tool.estimate_cost({"text": "abc", "model_id": "s1"}) == tool.estimate_cost( + {"text": "abc", "model": "s1"} + ) + + +# ---------------------------------------------------------------------- +# Idempotency — output-affecting inputs must change the key +# ---------------------------------------------------------------------- + + +class TestIdempotencyKey: + def test_voice_id_and_reference_id_aliases_hash_identically(self): + tool = FishAudioTTS() + base = {"text": "hi", "model": "s1"} + assert tool.idempotency_key({**base, "voice_id": "v1"}) == tool.idempotency_key( + {**base, "reference_id": "v1"} + ) + + def test_different_voices_produce_different_keys(self): + tool = FishAudioTTS() + base = {"text": "hi", "model": "s1"} + assert tool.idempotency_key({**base, "voice_id": "v1"}) != tool.idempotency_key( + {**base, "voice_id": "v2"} + ) + + @pytest.mark.parametrize( + "field,value", + [ + ("temperature", 0.2), + ("top_p", 0.3), + ("repetition_penalty", 1.8), + ("latency", "low"), + ("prosody", {"speed": 1.5}), + ("normalize", False), + ("mp3_bitrate", 192), + ("sample_rate", 24000), + ("chunk_length", 100), + ], + ) + def test_output_affecting_controls_change_the_key(self, field, value): + tool = FishAudioTTS() + base = {"text": "hi", "model": "s1"} + assert tool.idempotency_key(base) != tool.idempotency_key({**base, field: value}) + + def test_omitted_field_matches_explicit_default(self): + tool = FishAudioTTS() + base = {"text": "hi", "model": "s1"} + assert tool.idempotency_key(base) == tool.idempotency_key( + {**base, "temperature": 0.7, "latency": "normal", "format": "mp3"} + ) + + def test_model_id_alias_matches_model(self): + tool = FishAudioTTS() + assert tool.idempotency_key({"text": "hi", "model_id": "s1"}) == tool.idempotency_key( + {"text": "hi", "model": "s1"} + ) + + +# ---------------------------------------------------------------------- +# Registry metadata +# ---------------------------------------------------------------------- + + +class TestRegistryMetadata: + def test_declares_api_key_env_dependency(self): + assert "env:FISH_AUDIO_API_KEY" in FishAudioTTS.dependencies # ---------------------------------------------------------------------- diff --git a/tools/audio/fish_audio_tts.py b/tools/audio/fish_audio_tts.py index e5c802fc..632d8c2c 100644 --- a/tools/audio/fish_audio_tts.py +++ b/tools/audio/fish_audio_tts.py @@ -10,6 +10,7 @@ from __future__ import annotations import os import time +from datetime import date from pathlib import Path from typing import Any @@ -38,7 +39,7 @@ class FishAudioTTS(BaseTool): determinism = Determinism.STOCHASTIC runtime = ToolRuntime.API - dependencies = [] + dependencies = ["env:FISH_AUDIO_API_KEY"] install_instructions = ( "Set FISH_AUDIO_API_KEY to an API key from https://fish.audio/go-api/api-keys/\n" "Create voice models in the fish.audio playground and pass their id as\n" @@ -76,7 +77,7 @@ class FishAudioTTS(BaseTool): input_schema = { "type": "object", - "required": ["text"], + "required": ["text", "model"], "properties": { "text": {"type": "string", "description": "Text to convert to speech"}, "model": { @@ -84,9 +85,18 @@ class FishAudioTTS(BaseTool): "enum": list(_VALID_MODELS), "description": ( "Backend TTS model (sent as the 'model' HTTP header). Required — no " - "default. s2.1-pro = latest flagship (emotion tags, 80+ languages), " - "s2.1-pro-free = free tier for drafts, s2-pro = first S2 generation, " - "s1 = previous flagship kept for compatibility." + "default; may also be supplied via the model_id alias. s2.1-pro = " + "latest flagship (emotion tags, 80+ languages), s2.1-pro-free = free " + "tier for drafts (promotional; see estimate_cost), s2-pro = first S2 " + "generation, s1 = previous flagship kept for compatibility." + ), + }, + "model_id": { + "type": "string", + "enum": list(_VALID_MODELS), + "description": ( + "Alias for model (selector compatibility — tts_selector exposes " + "model_id). Used only when model is absent." ), }, "reference_id": { @@ -179,7 +189,21 @@ class FishAudioTTS(BaseTool): retry_policy = RetryPolicy( max_retries=2, backoff_seconds=2.0, retryable_errors=["rate_limit", "timeout"] ) - idempotency_key_fields = ["text", "model", "reference_id", "format"] + idempotency_key_fields = [ + "text", + "model", + "reference_id", + "format", + "mp3_bitrate", + "sample_rate", + "temperature", + "top_p", + "repetition_penalty", + "latency", + "prosody", + "normalize", + "chunk_length", + ] side_effects = [ "writes audio file to output_path", "calls the fish.audio TTS API", @@ -200,12 +224,59 @@ class FishAudioTTS(BaseTool): "s1": 0.000015, # ~$15 / 1M bytes "s2-pro": 0.000015, # ~$15 / 1M bytes "s2.1-pro": 0.000015, # ~$15 / 1M bytes - "s2.1-pro-free": 0.0, # free tier + "s2.1-pro-free": 0.0, # promotional free tier — see _S21_PRO_FREE_PROMO_END } _DEFAULT_RATE = 0.000015 + # s2.1-pro-free is a promotion, not a durable free tier: free API access runs + # through the end of July 2026, subject to Fair Use, with no SLA/latency + # guarantee, possible request retention, and commercial-use restrictions + # (https://fish.audio/ar/blog/s2-1-pro-free-api/?articleLocale=en). After the + # window, cost planning falls back to the paid s2.1-pro rate. + _S21_PRO_FREE_PROMO_END = date(2026, 7, 31) + + # Effective API defaults for output-affecting inputs. Applied when computing + # the idempotency key so an omitted field and its explicit default hash the + # same (both produce identical audio). + _KEY_DEFAULTS = { + "format": "mp3", + "mp3_bitrate": 128, + "chunk_length": 300, + "normalize": True, + "latency": "normal", + "temperature": 0.7, + "top_p": 0.7, + "repetition_penalty": 1.2, + } + _EXT_MAP = {"mp3": "mp3", "wav": "wav", "pcm": "pcm", "opus": "opus"} + @classmethod + def _normalized_inputs(cls, inputs: dict[str, Any]) -> dict[str, Any]: + """Resolve selector-compatible aliases (model_id -> model, voice_id -> reference_id).""" + normalized = dict(inputs) + if not normalized.get("model") and normalized.get("model_id"): + normalized["model"] = normalized["model_id"] + if not normalized.get("reference_id") and normalized.get("voice_id"): + normalized["reference_id"] = normalized["voice_id"] + return normalized + + @classmethod + def _effective_inputs(cls, inputs: dict[str, Any]) -> dict[str, Any]: + """Alias-normalized inputs with API defaults filled for output-affecting fields.""" + provided = {k: v for k, v in cls._normalized_inputs(inputs).items() if v is not None} + effective = {**cls._KEY_DEFAULTS, **provided} + if effective.get("format") != "mp3": + effective["mp3_bitrate"] = None + return effective + + def idempotency_key(self, inputs: dict[str, Any]) -> str: + return super().idempotency_key(self._effective_inputs(inputs)) + + @staticmethod + def _today() -> date: + return date.today() + def _get_api_key(self) -> str | None: return os.environ.get("FISH_AUDIO_API_KEY") @@ -215,9 +286,12 @@ class FishAudioTTS(BaseTool): return ToolStatus.UNAVAILABLE def estimate_cost(self, inputs: dict[str, Any]) -> float: + inputs = self._normalized_inputs(inputs) byte_count = len(str(inputs.get("text", "")).encode("utf-8")) model = inputs.get("model", "") rate = self._FALLBACK_RATES.get(model, self._DEFAULT_RATE) + if model == "s2.1-pro-free" and self._today() > self._S21_PRO_FREE_PROMO_END: + rate = self._FALLBACK_RATES["s2.1-pro"] return round(byte_count * rate, 4) def execute(self, inputs: dict[str, Any]) -> ToolResult: @@ -228,12 +302,13 @@ class FishAudioTTS(BaseTool): error="No fish.audio API key. " + self.install_instructions, ) + inputs = self._normalized_inputs(inputs) model = inputs.get("model") if not model: return ToolResult( success=False, error=( - "fish_audio_tts requires an explicit 'model'. " + "fish_audio_tts requires an explicit 'model' (or its 'model_id' alias). " f"Valid values: {', '.join(self._VALID_MODELS)}." ), )