mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-16 21:51:24 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -683,6 +683,7 @@ class TestCapabilityMetadata:
|
||||
"dashscope",
|
||||
"doubao",
|
||||
"elevenlabs",
|
||||
"fish_audio",
|
||||
"google_tts",
|
||||
"kling_official",
|
||||
"openai",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@@ -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)}."
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user