# Streamování

> Jak se streamované odpovědi doručují, co obsahuje každý chunk a jak stream číst.

Některé endpointy mohou posílat výsledek průběžně, jak vzniká, místo najednou:
odpověď [chatu](chat.md), když požadavek nastaví `"stream": true`, a
[běh workflow](workflows.md#streamovani-behu). Všechny streamy používají stejnou obálku,
popsanou zde; co obálka nese, závisí na endpointu. Tato stránka popisuje obálku a stream
chatu; události běhu workflow jsou popsány u
[endpointu workflow](workflows.md#streamovani-behu).

## Jak se stream doručuje

Streamovaná odpověď je běžná odpověď HTTP, jejíž tělo přichází po částech:

- Stav `200`, `Content-Type: text/event-stream; charset=utf-8`, chunked transfer
  encoding (bez `Content-Length`).
- Tělo je posloupnost [chunků](#chunky). Každý chunk je jeden objekt JSON a objekty
  jsou zapsány **přímo jeden za druhým, bez oddělovače**: bez nového řádku,
  bez prefixu `data:`.

```text
{"timestamp": 1759406400810, "data": "Unit tests", "error": null}{"timestamp": 1759406400834, "data": " catch", "error": null}{"timestamp": 1759406400851, "data": " regressions", "error": null}
```

Navzdory content type tělo **není** ve formátu Server-Sent Events, takže ho
`EventSource` ani klientské knihovny SSE přečíst nedokážou (a `EventSource` stejně
neumí poslat `POST` s tělem). Čtěte tělo jako proud bajtů a na objekty JSON si ho
rozdělte sami, viz [Čtení streamu](#cteni-streamu). Čtení ze sítě může skončit
uprostřed objektu nebo obsahovat několik objektů, proto vždy používejte buffer.

Chyby zjištěné před zahájením streamu (autentizace, oprávnění, neplatný požadavek,
neznámý model, ...) se vracejí jako běžná chybová odpověď s vlastním stavem HTTP
a tělem `{"detail": "..."}`, viz [Chyby](conventions.md#chyby). Než začnete tělo číst
jako stream, zkontrolujte stavový kód.

## Chunky

| Pole | Typ | Popis |
|---|---|---|
| `timestamp` | časové razítko | Kdy chunk vznikl (milisekundy od epochy). |
| `data` | řetězec, objekt nebo null | Obsah. Jeho tvar závisí na endpointu a u chatu na [`runner_version`](#formaty-streamu-chatu). V chybovém chunku `null`. |
| `error` | [Chyba streamu](#chyby-ve-streamu) nebo null | Nastaveno, když požadavek selhal po zahájení streamu; jinak `null`. |

**Keepalive.** Když zhruba sekundu nic nevzniklo (model přemýšlí, běží nástroj),
server pošle chunk s `data` nastaveným na prázdný řetězec `""`. Tyto chunky
ignorujte; jen brání tomu, aby spojení nebo proxy vypršely. Keepalive se posílá
v každém formátu streamu, takže `data` může být `""` i ve streamech, jejichž obsahem
jsou jinak objekty.

## Chyby ve streamu

Jakmile stream začal, stav HTTP už je `200`. Selhání po tomto okamžiku se pošle jako
poslední chunk s nastaveným `error` a stream skončí.

```json
{"timestamp": 1759406402117, "data": null, "error": {"status": 422, "text": "Validation error", "detail": "not enough user credit"}}
```

| Pole | Typ | Popis |
|---|---|---|
| `status` | celé číslo | Stav HTTP, který by chyba měla jako běžná odpověď, například `422` nebo `500`. |
| `text` | řetězec | Krátký název třídy chyby: `Validation error`, `Forbidden`, `Not found`, `Unauthorized`, `Too many requests`, `Lock error`, `Internal server error`; `Internal Server Error` u neočekávaných selhání. |
| `detail` | řetězec | Chybová zpráva, stejný text, jaký nese v `detail` běžná chybová odpověď. |

Chyby chatu, které mohou přijít tímto způsobem, jsou v
[tabulce chyb chatu](chat.md#chyby) označeny *během*; nejčastější z nich je
vyčerpání kreditů uprostřed odpovědi. Odpověď chatu přerušená chybou se v konverzaci
uchová až do místa, kde se zastavila.

## Konec streamu

Stream končí, když skončí tělo odpovědi HTTP. Žádný závěrečný chunk „konec“ neexistuje:

- tělo skončilo po chunku s nastaveným `error`: požadavek selhal;
- tělo skončilo jinak: požadavek se dokončil.

Ve [streamu událostí](#stream-udalosti-verze-2) chatu označuje událost `done` okamžik,
kdy model dokončil odpověď, ale za konec streamu považujte konec těla.

Pokud váš klient zavře spojení předčasně, server přestane odpověď chatu generovat.
Dosud vygenerovaná část se v konverzaci uchová a volání modelu, která už proběhla,
se účtují. (Běh workflow po odpojení na serveru pokračuje.)

## Formáty streamu chatu

Pole požadavku chatu `runner_version` určuje, co obsahuje `data` ve streamu chatu:

| `runner_version` | `data` | Použijte, když |
|---|---|---|
| `"1"` (výchozí) | Řetězec: další část textu odpovědi. | Potřebujete jen text odpovědi. |
| `"2"` | Objekt [události](#stream-udalosti-verze-2): text, uvažování, činnost nástrojů, stav. | Chcete zobrazovat průběh, uvažování nebo činnost nástrojů odděleně od textu. |

Oba formáty vznikají ze stejného generování a uložená odpověď je stejná; liší se jen
to, co se posílá po síti.

Model bez schopnosti `stream` (viz [Modely a nástroje](models-and-tools.md#schopnosti))
lze přesto streamovat: text jeho odpovědi přijde vcelku (jeden textový fragment nebo
jedna událost `text`), až je hotový, a uloží se jako každá jiná odpověď.

### Textový stream (verze 1)

Každé `data` je fragment odpovědi v Markdownu. Pro celou odpověď fragmenty spojte
v pořadí; keepalive chunky (`""`) nic nepřidávají.

Volání nástrojů jsou součástí textu, přesně jako v `content` nestreamované odpovědi:
každé volání nástroje je blok, který začíná značkou
`"\n\n### calling AI tool ...\n"`, pokračuje textem průběhu, který nástroj hlásí,
a končí značkou `"\n***\n"`. Viz [Volání nástrojů v odpovědi](chat.md#volani-nastroju-v-odpovedi).
Uvažování, stavové informace a vnitřní kroky sub-asistentů se v tomto formátu
neposílají.

```text
{"timestamp": 1759406400120, "data": "", "error": null}{"timestamp": 1759406400810, "data": "Let me check", "error": null}{"timestamp": 1759406400833, "data": " that.", "error": null}{"timestamp": 1759406401002, "data": "\n\n### calling AI tool ...\n", "error": null}{"timestamp": 1759406401004, "data": "### Scraping URL: https://example.com/rates\n", "error": null}{"timestamp": 1759406402950, "data": "\n***\n", "error": null}{"timestamp": 1759406403410, "data": "The current rate is 24.35 CZK per euro.", "error": null}
```

### Stream událostí (verze 2)

Každé `data` (kromě keepalive) je objekt události. Všechna pole jsou vždy přítomna;
ta, která se k typu události nevztahují, jsou prázdná (`""`, `null` nebo `{}`).

| Pole | Typ | Popis |
|---|---|---|
| `type` | řetězec | Typ události, viz [Typy událostí](#typy-udalosti). |
| `delta` | řetězec | Text, který nesou události `text`, `reasoning` a `tool_call_output`; jinak `""`. |
| `phase` | řetězec nebo null | Fáze události `status`, viz [Fáze stavu](#faze-stavu). |
| `call_id` | řetězec nebo null | Id volání nástroje, ke kterému událost patří (události nástrojů, `nested`, některé události `status`). |
| `tool_name` | řetězec nebo null | Technický název nástroje, stejné id, jaké uvádí [Modely a nástroje](models-and-tools.md) a na jaké odkazují asistenti. |
| `tool_display_name` | řetězec nebo null | Název nástroje čitelný pro člověka. |
| `tool_icon` | řetězec nebo null | Identifikátor ikony, kterou aplikace AYETO pro nástroj používá; může být prázdný. |
| `tool_status` | řetězec nebo null | Výsledek v `tool_call_end`: `ok` nebo `error`. |
| `data` | objekt | Doplňující hodnoty některých událostí (stav `model_selected`, `done`). Jinak `{}`. |
| `chunk` | událost nebo null | Vnitřní událost události `nested`. |

Celý chunk streamu událostí vypadá takto:

```json
{"timestamp": 1759406400810, "data": {"type": "text", "delta": "Unit tests", "phase": null, "call_id": null, "tool_name": null, "tool_display_name": null, "tool_icon": null, "tool_status": null, "data": {}, "chunk": null}, "error": null}
```

### Typy událostí

| `type` | Význam | Použitá pole |
|---|---|---|
| `text` | Další část viditelného textu odpovědi (Markdown). | `delta` |
| `reasoning` | Další část uvažování modelu nebo shrnutí jeho přemýšlení, u modelů, které ho zpřístupňují. Není součástí textu odpovědi. | `delta` |
| `status` | Požadavek vstoupil do nové fáze. | `phase` a podle fáze `call_id`, `tool_name`, `tool_display_name`, `tool_icon`, `data` |
| `tool_call_start` | Nástroj se spustil. | `call_id`, `tool_name`, `tool_display_name`, `tool_icon` |
| `tool_call_output` | Text průběhu hlášený běžícím nástrojem (Markdown), například vyhledávací dotazy nebo odkaz na vygenerovaný soubor. | `call_id`, `delta` |
| `tool_call_end` | Nástroj skončil. | `call_id`, `tool_status` |
| `nested` | Událost sub-asistenta, kterého asistent zavolal jako nástroj. `call_id` je id tohoto volání nástroje, `chunk` je vlastní událost sub-asistenta (libovolného typu, včetně dalšího `nested` pro hlubší úrovně). | `call_id`, `chunk` |
| `done` | Model dokončil odpověď. `data.stop_reason` je důvod od poskytovatele, například `stop`, `end_turn` nebo `completed`, případně `null`. | `data` |

### Fáze stavu

| `phase` | Význam |
|---|---|
| `context_building` | Požadavek se připravuje: instrukce, nástroje, historie. Obvykle první událost. |
| `rag_query` | Prohledává se znalostní báze asistenta. |
| `relevant_history` | Vybírá se relevantní část historie (`relevant_history` v požadavku). |
| `model_selected` | Uvádí model, který odpovídá: `data.model` (id modelu), `data.model_name` (zobrazovaný název) a, když asistent používá automatický model, `data.auto_model` (id automatického modelu, který ho vybral). |
| `model_call` | Modelu byl odeslán požadavek a čeká se na jeho první tokeny. Posílá se znovu po každém kole volání nástrojů. |
| `context_compacted` | Historie konverzace byla zkrácena, aby se vešla do kontextového okna modelu. |
| `tool_call_pending` | Model píše volání nástroje; `call_id` a `tool_name` ho identifikují. Nástroj se spustí událostí `tool_call_start`. |
| `silent_tool` | Běží nástroj, který nemá viditelný výstup; `call_id`, `tool_name`, `tool_display_name` a `tool_icon` ho identifikují. Žádné události `tool_call_*` pro něj nenásledují. |

Mohou přibýt nové typy událostí, fáze a klíče `data`. Hodnoty, které neznáte,
ignorujte.

### Pořadí událostí

Typická odpověď, která používá jeden nástroj, vytvoří (bez keepalive, jedno `data`
na řádek):

```json
{"type": "status", "phase": "context_building", ...}
{"type": "status", "phase": "model_selected", "data": {"model": "gpt-5-mini", "model_name": "GPT-5 mini"}, ...}
{"type": "status", "phase": "model_call", ...}
{"type": "text", "delta": "Let me check", ...}
{"type": "text", "delta": " that.", ...}
{"type": "status", "phase": "tool_call_pending", "call_id": "call_9f2c", "tool_name": "WebScraperFunctionTool", ...}
{"type": "tool_call_start", "call_id": "call_9f2c", "tool_name": "WebScraperFunctionTool", "tool_display_name": "Web Scraper", "tool_icon": "ayeto", ...}
{"type": "tool_call_output", "call_id": "call_9f2c", "delta": "### Scraping URL: https://example.com/rates\n", ...}
{"type": "tool_call_end", "call_id": "call_9f2c", "tool_status": "ok", ...}
{"type": "status", "phase": "model_call", ...}
{"type": "text", "delta": "The current rate is 24.35 CZK per euro.", ...}
{"type": "done", "data": {"stop_reason": "stop"}, ...}
```

### Sestavení textu odpovědi

Chcete-li získat stejný text, jaký obsahuje [textový stream](#textovy-stream-verze-1)
nebo uložená zpráva, připojte pro každou událost nejvyšší úrovně:

| Událost | Připojit |
|---|---|
| `text` | `delta` |
| `tool_call_start` | `"\n\n### calling AI tool ...\n"` |
| `tool_call_output` | `delta` |
| `tool_call_end` | `"\n***\n"` |
| jakákoli jiná | nic |

Vygenerované soubory (například obrázky) se objevují jako odkazy v Markdownu
v `tool_call_output`. Po skončení streamu si uloženou odpověď včetně jejích příloh
můžete přečíst přes API [Konverzace](conversations.md).

## Čtení streamu

Příklady posílají zprávu chatu s `runner_version` `"2"`, vypisují text odpovědi, jak
přichází, a zastaví se na chybovém chunku. Oba rozdělují tělo na objekty JSON pomocí
malého bufferu, takže fungují bez ohledu na to, jak síť data rozdělí.

### Python

```python
import codecs
import json
import os
import uuid

import requests


def iter_chunks(response):
    """Yield the JSON chunks of a streamed response."""
    decoder = json.JSONDecoder()
    text = codecs.getincrementaldecoder("utf-8")()
    buffer = ""
    for raw in response.iter_content(chunk_size=None):
        buffer += text.decode(raw)
        while True:
            buffer = buffer.lstrip()
            if not buffer:
                break
            try:
                chunk, end = decoder.raw_decode(buffer)
            except json.JSONDecodeError:
                break  # incomplete object, wait for more data
            buffer = buffer[end:]
            yield chunk


response = requests.post(
    "https://ayeto.ai/api/v3/chat",
    headers={"uni-api-key": os.environ["AYETO_API_KEY"]},
    json={
        "conversation_id": str(uuid.uuid4()),
        "model": "gpt-5-mini",
        "message": "Explain recursion with a short example.",
        "stream": True,
        "runner_version": "2",
    },
    stream=True,
    timeout=(10, 600),
)
if response.status_code != 200:  # failed before the stream started
    raise RuntimeError(f"{response.status_code}: {response.json()['detail']}")

for chunk in iter_chunks(response):
    if chunk["error"]:
        error = chunk["error"]
        raise RuntimeError(f"{error['status']}: {error['detail']}")
    event = chunk["data"]
    if not isinstance(event, dict):
        continue  # keepalive
    if event["type"] == "text":
        print(event["delta"], end="", flush=True)
    elif event["type"] == "tool_call_start":
        print(f"\n[{event['tool_display_name']}]", flush=True)
    elif event["type"] == "status" and event["phase"] == "model_selected":
        print(f"[answering: {event['data']['model']}]", flush=True)
print()
```

S `runner_version` `"1"` je `chunk["data"]` řetězec: vypište nebo připojte ho tak, jak je.

### JavaScript

```javascript
// Yields the JSON chunks of a streamed response.
async function* readChunks(response) {
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  let pos = 0;
  let depth = 0;
  let inString = false;
  let escaped = false;
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    for (; pos < buffer.length; pos++) {
      const c = buffer[pos];
      if (inString) {
        if (escaped) escaped = false;
        else if (c === "\\") escaped = true;
        else if (c === '"') inString = false;
      } else if (c === '"') {
        inString = true;
      } else if (c === "{") {
        depth++;
      } else if (c === "}" && --depth === 0) {
        yield JSON.parse(buffer.slice(0, pos + 1));
        buffer = buffer.slice(pos + 1);
        pos = -1; // continue at the start of the remaining buffer
      }
    }
  }
}

const response = await fetch("https://ayeto.ai/api/v3/chat", {
  method: "POST",
  headers: {
    "uni-api-key": process.env.AYETO_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    conversation_id: crypto.randomUUID(),
    model: "gpt-5-mini",
    message: "Explain recursion with a short example.",
    stream: true,
    runner_version: "2",
  }),
});
if (!response.ok) {
  // failed before the stream started
  const body = await response.json();
  throw new Error(`${response.status}: ${JSON.stringify(body.detail)}`);
}

let answer = "";
for await (const chunk of readChunks(response)) {
  if (chunk.error) {
    throw new Error(`${chunk.error.status}: ${chunk.error.detail}`);
  }
  const event = chunk.data;
  if (typeof event !== "object" || event === null) continue; // keepalive
  if (event.type === "text") {
    answer += event.delta;
    process.stdout.write(event.delta);
  }
}
```

Stejné funkce čtou i stream běhu workflow; liší se jen obsah `data`, viz
[Streamování běhu](workflows.md#streamovani-behu).
