fix: address selector contract, promo pricing, idempotency, and registry metadata review

- Require 'model' in the input schema and accept 'model_id' as a
  selector-compatible alias (tts_selector exposes model_id); add a
  selector-routing regression test
- Document s2.1-pro-free as promotional (free through end of July 2026,
  Fair Use, no SLA, possible request retention, commercial-use
  restrictions) in PROVIDERS.md and the Layer 3 skill; estimate_cost()
  falls back to the paid s2.1-pro rate after the promo window
- Normalize voice_id/reference_id and model_id/model aliases before
  computing the idempotency key, and include all output-affecting inputs
  (bitrate, sample_rate, temperature, top_p, repetition_penalty, latency,
  prosody, normalize, chunk_length) with API defaults applied
- Declare env:FISH_AUDIO_API_KEY in dependencies so registry metadata
  reports the requirement
- Add fish_audio to the TTS provider set in the phase3 registry contract
  test
This commit is contained in:
Tomofumi Yagi
2026-07-22 19:00:46 +09:00
parent b29e238e8e
commit 990d7f9a2c
5 changed files with 242 additions and 17 deletions

View File

@@ -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

View File

@@ -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.
---

View File

@@ -683,6 +683,7 @@ class TestCapabilityMetadata:
"dashscope",
"doubao",
"elevenlabs",
"fish_audio",
"google_tts",
"kling_official",
"openai",

View File

@@ -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
# ----------------------------------------------------------------------

View File

@@ -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)}."
),
)