# Streaming

> Comment les réponses en streaming sont transmises, ce que contient chaque chunk et comment lire un stream.

Certains endpoints peuvent envoyer leur résultat pendant sa production au lieu de l'envoyer en une seule fois :
une réponse du [chat](chat.md) lorsque la requête définit `"stream": true`, et une
[exécution de workflow](workflows.md#execution-en-streaming). Tous les streams utilisent la même enveloppe, décrite
ici ; ce que transporte l'enveloppe dépend de l'endpoint. Cette page décrit
l'enveloppe et le stream du chat ; les événements d'une exécution de workflow sont décrits avec
l'[endpoint de workflow](workflows.md#execution-en-streaming).

## Comment un stream est transmis

Une réponse en streaming est une réponse HTTP ordinaire dont le corps arrive morceau par morceau :

- Statut `200`, `Content-Type: text/event-stream; charset=utf-8`, encodage de transfert
  chunked (pas de `Content-Length`).
- Le corps est une suite de [chunks](#chunks). Chaque chunk est un objet JSON, et les
  objets sont écrits **directement les uns après les autres, sans séparateur** : pas de saut de ligne,
  pas de préfixe `data:`.

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

Malgré le type de contenu, le corps n'est **pas** au format Server-Sent Events, si bien que
`EventSource` et les bibliothèques clientes SSE ne peuvent pas le lire (et `EventSource` ne peut de toute façon pas envoyer un
`POST` avec un corps). Lisez le corps comme un flux d'octets et découpez-le vous-même en objets JSON,
voir [Lire un stream](#lire-un-stream). Une lecture réseau peut s'arrêter au
milieu d'un objet ou contenir plusieurs objets ; utilisez donc toujours un tampon.

Les erreurs détectées avant le début du stream (authentification, autorisations, requête
invalide, modèle inconnu, ...) sont renvoyées comme une réponse d'erreur normale avec leur propre statut
HTTP et un corps `{"detail": "..."}`, voir [Erreurs](conventions.md#erreurs). Vérifiez le
code de statut avant de lire le corps comme un stream.

## Chunks

| Champ | Type | Description |
|---|---|---|
| `timestamp` | horodatage | Moment où le chunk a été produit (millisecondes depuis l'epoch). |
| `data` | chaîne, objet ou null | Le payload. Sa forme dépend de l'endpoint et, pour le chat, de [`runner_version`](#formats-du-stream-de-chat). `null` dans un chunk d'erreur. |
| `error` | [Erreur de stream](#erreurs-dans-un-stream) ou null | Défini lorsque la requête a échoué après le début du stream ; `null` sinon. |

**Keepalive.** Lorsque rien n'a été produit pendant environ une seconde (le modèle réfléchit,
un outil s'exécute), le serveur envoie un chunk dont `data` vaut la chaîne vide `""`.
Ignorez ces chunks ; ils servent uniquement à éviter que la connexion et les éventuels proxys n'expirent.
Les keepalives sont envoyés dans tous les formats de stream, si bien que `data` peut valoir `""` même dans les streams dont
les payloads sont par ailleurs des objets.

## Erreurs dans un stream

Une fois le stream commencé, le statut HTTP est déjà `200`. Un échec survenant après ce point
est envoyé comme dernier chunk avec `error` défini, puis le stream se termine.

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

| Champ | Type | Description |
|---|---|---|
| `status` | entier | Le statut HTTP qu'aurait eu l'erreur dans une réponse normale, par exemple `422` ou `500`. |
| `text` | chaîne | Nom court de la classe d'erreur : `Validation error`, `Forbidden`, `Not found`, `Unauthorized`, `Too many requests`, `Lock error`, `Internal server error` ; `Internal Server Error` pour les défaillances inattendues. |
| `detail` | chaîne | Le message d'erreur, le même texte que celui que porte `detail` dans une réponse d'erreur normale. |

Les erreurs du chat qui peuvent arriver de cette façon sont marquées *pendant* dans le
[tableau des erreurs du chat](chat.md#erreurs) ; la plus fréquente est l'épuisement des crédits au
milieu d'une réponse. Une réponse du chat interrompue par une erreur est conservée dans la conversation
jusqu'au point où elle s'est arrêtée.

## Fin du stream

Le stream se termine lorsque le corps de la réponse HTTP se termine. Il n'y a pas de chunk final de « fin » :

- le corps s'est terminé après un chunk avec `error` défini : la requête a échoué ;
- le corps s'est terminé autrement : la requête a abouti.

Dans le [stream d'événements](#stream-devenements-version-2) du chat, un événement `done` marque le moment où le
modèle a terminé sa réponse, mais considérez la fin du corps comme la fin du stream.

Si votre client ferme la connexion prématurément, le serveur arrête de générer la réponse du chat.
La partie générée jusque-là est conservée dans la conversation et les appels au modèle déjà effectués
sont facturés. (Une exécution de workflow continue sur le serveur après une déconnexion.)

## Formats du stream de chat

Le champ `runner_version` de la requête de chat détermine ce que contient `data` dans un stream de chat :

| `runner_version` | `data` | À utiliser lorsque |
|---|---|---|
| `"1"` (par défaut) | Une chaîne : le morceau suivant du texte de la réponse. | Vous n'avez besoin que du texte de la réponse. |
| `"2"` | Un objet [événement](#stream-devenements-version-2) : texte, raisonnement, activité des outils, statut. | Vous voulez afficher la progression, le raisonnement ou l'activité des outils séparément du texte. |

Les deux formats proviennent de la même génération et la réponse stockée est identique ; seul ce
qui est transmis sur le réseau diffère.

Un modèle sans la capacité `stream` (voir [Modèles et outils](models-and-tools.md#capacites))
peut quand même être utilisé en streaming : le texte de sa réponse arrive en un seul morceau (un fragment de texte, ou un
événement `text`) lorsqu'il est complet, et il est stocké comme toute autre réponse.

### Stream texte (version 1)

Chaque `data` est un fragment de la réponse au format Markdown. Concaténez les fragments dans l'ordre
pour obtenir la réponse complète ; les chunks keepalive (`""`) n'ajoutent rien.

Les appels d'outils font partie du texte, exactement comme dans le `content` d'une réponse
non streamée : chaque appel d'outil est un bloc qui commence par le marqueur
`"\n\n### calling AI tool ...\n"`, se poursuit avec le texte de progression que rapporte l'outil et
se termine par le marqueur `"\n***\n"`. Voir [Appels d'outils dans la réponse](chat.md#appels-doutils-dans-la-reponse).
Le raisonnement, les informations de statut et les étapes internes des sous-assistants ne sont pas envoyés dans
ce format.

```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 d'événements (version 2)

Chaque `data` (hors keepalives) est un objet événement. Tous les champs sont toujours présents ;
ceux qui ne s'appliquent pas au type d'événement sont vides (`""`, `null` ou `{}`).

| Champ | Type | Description |
|---|---|---|
| `type` | chaîne | Type d'événement, voir [Types d'événements](#types-devenements). |
| `delta` | chaîne | Texte porté par les événements `text`, `reasoning` et `tool_call_output` ; `""` sinon. |
| `phase` | chaîne ou null | Phase d'un événement `status`, voir [Phases de statut](#phases-de-statut). |
| `call_id` | chaîne ou null | Identifiant de l'appel d'outil auquel appartient l'événement (événements d'outil, `nested`, certains événements `status`). |
| `tool_name` | chaîne ou null | Nom technique de l'outil, le même identifiant que celui que liste [Modèles et outils](models-and-tools.md) et que référencent les assistants. |
| `tool_display_name` | chaîne ou null | Nom lisible de l'outil. |
| `tool_icon` | chaîne ou null | Identifiant de l'icône que l'application AYETO utilise pour l'outil ; peut être vide. |
| `tool_status` | chaîne ou null | Résultat dans `tool_call_end` : `ok` ou `error`. |
| `data` | objet | Valeurs supplémentaires de certains événements (statut `model_selected`, `done`). `{}` sinon. |
| `chunk` | événement ou null | L'événement interne d'un événement `nested`. |

Un chunk complet du stream d'événements ressemble à ceci :

```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}
```

### Types d'événements

| `type` | Signification | Champs utilisés |
|---|---|---|
| `text` | Morceau suivant du texte visible de la réponse (Markdown). | `delta` |
| `reasoning` | Morceau suivant du raisonnement du modèle ou du résumé de sa réflexion, pour les modèles qui l'exposent. Ne fait pas partie du texte de la réponse. | `delta` |
| `status` | La requête est entrée dans une nouvelle phase. | `phase`, et selon la phase `call_id`, `tool_name`, `tool_display_name`, `tool_icon`, `data` |
| `tool_call_start` | Un outil a commencé à s'exécuter. | `call_id`, `tool_name`, `tool_display_name`, `tool_icon` |
| `tool_call_output` | Texte de progression rapporté par l'outil en cours d'exécution (Markdown), par exemple des requêtes de recherche ou un lien vers un fichier généré. | `call_id`, `delta` |
| `tool_call_end` | L'outil a terminé. | `call_id`, `tool_status` |
| `nested` | Un événement d'un sous-assistant que l'assistant a appelé comme outil. `call_id` est l'identifiant de cet appel d'outil, `chunk` est l'événement propre du sous-assistant (de n'importe quel type, y compris de nouveau `nested` pour les niveaux plus profonds). | `call_id`, `chunk` |
| `done` | Le modèle a terminé sa réponse. `data.stop_reason` est la raison donnée par le fournisseur, par exemple `stop`, `end_turn` ou `completed`, ou `null`. | `data` |

### Phases de statut

| `phase` | Signification |
|---|---|
| `context_building` | La requête est en cours de préparation : instructions, outils, historique. Généralement le premier événement. |
| `rag_query` | La base de connaissances de l'assistant est en cours de recherche. |
| `relevant_history` | La partie pertinente de l'historique est en cours de sélection (`relevant_history` dans la requête). |
| `model_selected` | Nomme le modèle qui répond : `data.model` (identifiant du modèle), `data.model_name` (nom affiché) et, lorsque l'assistant utilise un modèle automatique, `data.auto_model` (identifiant du modèle automatique qui l'a choisi). |
| `model_call` | Une requête a été envoyée au modèle et ses premiers tokens sont attendus. Renvoyé après chaque série d'appels d'outils. |
| `context_compacted` | L'historique de la conversation a été raccourci pour tenir dans la fenêtre de contexte du modèle. |
| `tool_call_pending` | Le modèle est en train d'écrire un appel d'outil ; `call_id` et `tool_name` l'identifient. L'outil démarre avec `tool_call_start`. |
| `silent_tool` | Un outil sans sortie visible est en cours d'exécution ; `call_id`, `tool_name`, `tool_display_name` et `tool_icon` l'identifient. Aucun événement `tool_call_*` ne suit pour lui. |

De nouveaux types d'événements, phases et clés de `data` peuvent être ajoutés. Ignorez les valeurs que vous ne connaissez pas.

### Ordre des événements

Une réponse typique qui utilise un outil produit (keepalives omis, un `data` par ligne) :

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

### Reconstituer le texte de la réponse

Pour obtenir le même texte que celui que contient un [stream texte](#stream-texte-version-1) ou le message
stocké, ajoutez pour chaque événement de premier niveau :

| Événement | Ajouter |
|---|---|
| `text` | `delta` |
| `tool_call_start` | `"\n\n### calling AI tool ...\n"` |
| `tool_call_output` | `delta` |
| `tool_call_end` | `"\n***\n"` |
| tout autre | rien |

Les fichiers générés (par exemple des images) apparaissent sous forme de liens Markdown dans `tool_call_output`.
Une fois le stream terminé, vous pouvez lire la réponse stockée, y compris ses pièces jointes, via
l'API [Conversations](conversations.md).

## Lire un stream

Les exemples envoient un message de chat avec `runner_version` `"2"`, affichent le texte de la réponse au fur et à mesure
de son arrivée et s'arrêtent sur un chunk d'erreur. Tous deux découpent le corps en objets JSON à l'aide d'un petit
tampon, si bien qu'ils fonctionnent quelle que soit la façon dont le réseau découpe les données.

### 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()
```

Avec `runner_version` `"1"`, `chunk["data"]` est une chaîne : affichez-la ou ajoutez-la telle quelle.

### 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);
  }
}
```

Les mêmes fonctions lisent le stream d'une exécution de workflow ; seuls les payloads `data` diffèrent, voir
[Exécution en streaming](workflows.md#execution-en-streaming).
