# Fish Audio S2.1 Pro

Canonical page: https://agentsky.dev/models/fish_audio/s2.1-pro
JSON record: https://gateway.agentsky.dev/v1/endpoints/fish_audio/s2.1-pro

Fish Audio S2.1 Pro is a audio model from Fish Audio, callable through the AgentSky API as fish_audio/s2.1-pro and billed per byte ($0.00003).

Converts text into speech using either a saved voice referenced by id or a voice cloned on the fly from reference audio and its transcript. This is the current recommended model, at the same price per byte as the other paid tiers. Output encoding is selectable across compressed and uncompressed containers with bitrate control. A latency mode trades time-to-first-audio against quality, and a chunk length governs how the text is segmented for synthesis, which keeps prosody consistent across long passages. Text normalisation expands numbers, dates, and abbreviations into the way they are spoken.

The audio, returned inline with its media type and size in bytes; with `timestamps` or `stream`, also the spoken spans with their start and end times. Billing counts the bytes of text submitted.

## Facts

- **Id:** `fish_audio/s2.1-pro`
- **Provider:** Fish Audio
- **Kind:** audio model
- **Billed per:** byte
- **Timeout:** 5 min
- **Max request:** 15 MB
- **Max audio:** 10 MB
- **Character limit:** text up to 100,000

## Call it

`POST https://gateway.agentsky.dev/v1/run` with `{"provider": "fish_audio", "endpoint": "s2.1-pro", "input": {…}}` and `Authorization: Bearer $AGENTSKY_API_KEY`.

### Input fields

| Field | Type | Required | Description |
|---|---|---|---|
| `text` | string | yes | What to say. Max: 100000. |
| `reference_id` | string | no | The id of a public library voice, or of a voice this account saved with voice-create. |
| `reference_audio_base64` | base64 | no | Audio carrying a voice to clone inline. Supply it together with reference_text; the reference is used only for this request. |
| `reference_text` | string | no | The exact words spoken in reference_audio_base64. Required whenever inline reference audio is supplied. |
| `format` | string | no | Audio container to return. Allowed: wav, pcm, mp3, opus. Default: "mp3". |
| `mp3_bitrate` | integer | no | Bitrate when returning MP3. Allowed: 64, 128, 192. Default: 128. |
| `chunk_length` | integer | no | How much text to synthesise at a time. Default: 300. Min: 100. Max: 300. |
| `latency` | string | no | Trade responsiveness against quality. Allowed: low, balanced, normal. Default: "normal". |
| `normalize` | boolean | no | Expand numbers, dates, and abbreviations before speaking them. Default: true. |
| `timestamps` | boolean | no | Also return when each span of text is spoken, as absolute start and end times in seconds. Default: false. |
| `stream` | boolean | no | Respond with server-sent events: a `fish_audio.audio_chunk` event per audio chunk as it is synthesised, then `run.completed` with the complete audio and its timestamps. Default: false. |

## Example

### curl

```bash
# Set once per job; reuse this key and the identical body after a timeout.
: "${AGENTSKY_IDEMPOTENCY_KEY:?Set a unique, saved key for this job}"
curl --fail-with-body https://gateway.agentsky.dev/v1/run \
  -H "authorization: Bearer $AGENTSKY_API_KEY" \
  -H "content-type: application/json" \
  -H "Idempotency-Key: $AGENTSKY_IDEMPOTENCY_KEY" \
  -d '{"provider":"fish_audio","endpoint":"s2.1-pro","input":{"text":"Hello, and welcome back to the show.","format":"mp3","mp3_bitrate":128,"latency":"normal"}}'
# If status is READY/RUNNING, poll GET /v1/runs/{runId} with the same API key.
```

### JavaScript

```js
// Node.js 20+. Save one unique key per job; reuse it with the identical body.
const key = process.env.AGENTSKY_IDEMPOTENCY_KEY;
if (!key || !process.env.AGENTSKY_API_KEY) {
  throw new Error("Set AGENTSKY_API_KEY and a saved AGENTSKY_IDEMPOTENCY_KEY first");
}
const base = "https://gateway.agentsky.dev";
const headers = {
  authorization: `Bearer ${process.env.AGENTSKY_API_KEY}`,
  "content-type": "application/json",
  "Idempotency-Key": key,
};
const body = JSON.stringify({
  "provider": "fish_audio",
  "endpoint": "s2.1-pro",
  "input": {
    "text": "Hello, and welcome back to the show.",
    "format": "mp3",
    "mp3_bitrate": 128,
    "latency": "normal"
  }
});
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function request(path, options = {}) {
  for (let attempt = 0; attempt < 3; attempt++) {
    let response;
    let data;
    try {
      response = await fetch(base + path, {
        ...options, headers, signal: AbortSignal.timeout(180_000),
      });
      const text = await response.text();
      try { data = JSON.parse(text); } catch { /* Proxy timeouts may return HTML. */ }
    } catch (error) {
      if (attempt === 2) throw error; // Resume with the same key and body.
      await sleep(2000);
      continue;
    }
    // Failed runs can have non-2xx HTTP status. Never regenerate them automatically.
    if (data?.runId && data?.status) return data;
    if (response.ok && data) throw new Error("Unexpected response: missing runId/status");
    if (attempt < 2 && (response.status >= 500 || response.status === 408 || response.status === 429)) {
      await sleep(2000);
      continue; // POST retries keep the original key and body.
    }
    throw new Error(`HTTP ${response.status}: ${JSON.stringify(data ?? "Non-JSON response; retry with the same key and body")}`);
  }
}

let run = await request("/v1/run", { method: "POST", body });
console.log("Save runId:", run.runId);
const deadline = Date.now() + 30 * 60_000;
while (["READY", "RUNNING"].includes(run.status)) {
  if (Date.now() >= deadline) {
    throw new Error(`Still pending: poll /v1/runs/${run.runId} later; do not create a new job`);
  }
  await sleep(5000);
  run = await request(`/v1/runs/${encodeURIComponent(run.runId)}`);
}
console.log("Status:", run.status, "Charge:", run.price?.amount);
if (run.status !== "COMPLETED") {
  throw new Error(`Run ${run.runId}: ${JSON.stringify(run.error ?? run.status)}`);
}
console.log(run.output); // Output shape depends on the endpoint.
// READY/RUNNING price is provisional. Use the terminal receipt for reconciliation.
```

## Pricing

$0.00003 per byte

Read the final customer charge from price.amount.value after the run finishes. A RUNNING response is not the final bill.

## Questions

### What does Fish Audio S2.1 Pro do?

Converts text into speech using either a saved voice referenced by id or a voice cloned on the fly from reference audio and its transcript. This is the current recommended model, at the same price per byte as the other paid tiers. Output encoding is selectable across compressed and uncompressed containers with bitrate control. A latency mode trades time-to-first-audio against quality, and a chunk length governs how the text is segmented for synthesis, which keeps prosody consistent across long passages. Text normalisation expands numbers, dates, and abbreviations into the way they are spoken.

### How much does Fish Audio S2.1 Pro cost?

$0.00003 per byte, using your plan when signed in. The customer charge is returned as price.amount.value with its currency; READY/RUNNING values are provisional.

### What does Fish Audio S2.1 Pro take?

text is required; the rest of the fields are optional. Send them in the input object of a POST to /v1/run.

### What are the limits on Fish Audio S2.1 Pro?

The catalog lists an execution timeout of 5 min. A request can return READY or RUNNING before completion; poll GET /v1/runs/{runId} with the same API key until it reaches a terminal status. An HTTP timeout does not prove the generation stopped.

### How do I retry safely and check the final charge?

Save a unique Idempotency-Key for each job. After a network error or HTML timeout, resend the identical POST body with the same key and API key to recover the existing run, then poll its runId. A new key can start another billable generation. An HTTP error or timeout does not establish the run outcome or final charge. Pending runs can still settle; check the terminal price.amount and billing state, and retain the runId for reconciliation. Do not automatically create a new job after a terminal failure.
