# Servir l'inférence

> Objectif : servir la surface OpenAI-compatible (/v1/chat/completions, /v1/models, /v1/embeddings depuis 1.5.0) par le même plan de données que l'API - même…

Guide - Depuis la version 0.1.0 - Flexway v1.6.3 - https://flexway.io/docs/operations-inference/

> Guide d'exploitation : configurer, déployer et observer la passerelle. Ce que fait la capacité côté produit : [guide produit](https://flexway.io/docs/product-overview/) ; l'appeler : [guide développeur](https://flexway.io/docs/developer-overview/).

**Objectif** : servir la surface OpenAI-compatible (`/v1/chat/completions`, `/v1/models`,
`/v1/embeddings` depuis `1.5.0`) **par le
même plan de données** que l'API - même identité, même débit, mêmes politiques, même trace -,
routée par politique vers des backends, sous budget en tokens par tenant.

**Commande** - une section `ai:`. Elle ne demande **aucun contrat** : la passerelle déclare
elle-même ses opérations (`ai.chatCompletions`, `ai.listModels`, `ai.embeddings`), servies sur
chaque hôte.

```yaml
ai:
  security:                          # les identités acceptées, une suffit - comme `security` au contrat
    - oidc: { scopes: [inference] }
    - api_key: {}                    # une clé liée à ai.chatCompletions (voir Clés d'API)
  sensitive_header: X-Sensitive      # "true" épingle la requête sur sensitive_backend
  default_backend: eu-provider
  sensitive_backend: on-prem         # interne ; injoignable, ou aucun backend interne : refus, jamais un externe
  backends:
    eu-provider:
      url: https://api.provider.example/v1
      api_key: "file:/etc/flexway/secrets/provider-key"   # une référence, relue à chaque appel
      default_model: mistral-small-3.2-24b                # la passerelle choisit le modèle
      external: true
    on-prem:
      url: http://vllm.inference.svc:8000/v1
      default_model: mistral-7b-instruct
  budget:
    store: "${COUNTERS_URL}"         # redis://..., partagé par les répliques
    monthly_tokens: 40000000         # par tenant, 0 pour aucun
    daily_tokens: 2000000
  overrides:                         # la première qui correspond gagne
    - { match: { tenant: acme, agent: batch-summaries }, backend: on-prem }
```

**Quatre identités possibles**, chacune une alternative de `ai.security` :

| Alternative | L'appelant présente | Tenant |
| --- | --- | --- |
| `trusted_headers` | Les en-têtes qu'un ingress de maillage a écrits (`auth.trusted_headers`), **crus d'un pair de `server.trusted_proxies` seulement** | L'en-tête `tenant` ; `subject` nomme l'agent |
| `oidc` | Un jeton de l'un des émetteurs, avec les portées ou rôles déclarés | La claim `tenant_claim` |
| `api_key` | `Authorization: ApiKey <clé>`, la clé liée à `ai.chatCompletions` | Celui de la clé |
| `mtls` | Un certificat client déclaré, lié à l'opération | Celui du certificat |

Derrière un ingress de maillage qui a déjà authentifié l'appelant (les noms d'en-têtes sont ceux du
déploiement, jamais du produit) :

```yaml
server:
  trusted_proxies: [10.42.0.0/16]    # l'ingress, et lui seul : de quiconque d'autre, l'en-tête est une forgerie
auth:
  trusted_headers: { tenant: X-Mesh-Tenant, subject: X-Mesh-Agent }
ai:
  security: [ { trusted_headers: {} } ]
```

## Coût, plafond souple et repli

Chaque backend porte son **prix** par million de tokens ; le budget en **euros** s'ajoute à celui en
tokens - le premier atteint décide (exemple complet : [Coût, plafond souple et repli](https://flexway.io/docs/operations-examples-inference/#co%C3%BBt-plafond-souple-et-repli)) :

```yaml
ai:
  backends:
    eu-large:
      # ...
      price: { input_per_mtok: 2.00, output_per_mtok: 6.00 }   # euros par million de tokens
      fallback: [eu-small, on-prem]   # dans l'ordre, avant tout octet renvoyé au client
  budget:
    monthly_eur: 50                   # par tenant, sur le mois calendaire
    soft: { at: 0.8, backend: eu-small }
  overrides:
    - { match: { tenant: acme }, monthly_eur: 500 }
```

| Mécanisme | Comportement |
| --- | --- |
| **Coût** | Pré-réservé (entrée estimée à la taille du corps / 4, sortie à `max_tokens`), réconcilié sur l'usage réel **au prix du backend qui a servi** ; un appel en échec ou refusé rend sa réservation, jetons et euros ; porté par l'audit, la métrique `flexway.inference.cost` (euros, par tenant et backend) |
| **Plafond souple** | Au-delà de `at` x budget : en-tête `Flexway-Budget: soft`, métrique, journal ; l'appel part sur `soft.backend` - sauf une requête sensible, qui reste épinglée |
| **Plafond dur** | Une pré-réservation qui dépasserait le budget en euros (ou en tokens) : `429` `budget_exceeded`, avant tout appel |
| **Repli** | Sur erreur de connexion, `5xx`, délai ou circuit ouvert : le suivant de `fallback`, chaque backend une fois ; **jamais** sur un `4xx` (dont le `429` du fournisseur), ni après le premier octet d'un flux, ni vers un externe pour une requête sensible. Réponse marquée `Flexway-Degraded: backend-fallback` |

> Un prix est **obligatoire** pour chaque backend dès qu'un budget en euros existe : un coût que
> personne ne sait calculer ne se budgète pas. `0` est un prix - celui d'un backend interne.

**Vérif** :

| Appel | Réponse |
| --- | --- |
| Identité acceptée, budget restant | relayé tel que le backend l'a écrit ; le backend reçoit le corps du client avec le modèle de la politique, et **sa propre** clé - jamais l'identité de l'appelant |
| `stream: true` | les évènements arrivent **au fil de l'eau** ; le budget est réconcilié sur l'usage final |
| Aucune identité, ou une identité sans tenant | `401` `tenant_required` |
| Budget du tenant épuisé (complétion **ou** flux) | `429` `budget_exceeded`, avant tout appel au backend |
| `X-Sensitive: true` sans backend interne | `403` `sensitive_no_internal_backend` - jamais un backend externe |
| Backend en panne, sans repli disponible | `502` `backend_unavailable` ; pendant un flux, dernier évènement `{"error":{"type":"backend_unavailable"}}` |
| Refus de la passerelle sur `/v1/*` (portée, débit, route inconnue) | **enveloppe OpenAI**, `type` OpenAI (`permission_error`, `rate_limit_error`...) et `code` de la passerelle |
| Tenant au-delà de `soft.at` de son budget en euros | `Flexway-Budget: soft` ; servi par `soft.backend` |
| Backend en panne avec `fallback` | servi par le suivant, `Flexway-Degraded: backend-fallback` |
| Politique qui nomme un backend non déclaré | **refusée au chargement**, la clé nommée |

> **Débit** : `/v1/*` suit la chaîne de politiques comme toute route - une politique `rate-limit`
> propre, attachée à `routes: { "/v1/**": [...] }`, la borne à part (exemple 12.17). Derrière un
> ingress, le seau est celui du **tenant et de l'agent** qu'il atteste, jamais l'adresse de l'ingress.
>
> **Migrer depuis `config/policy.yaml`** - **plus lu depuis `0.1.0`** : sans `FLEXWAY_CONFIG`, la
> passerelle refuse de démarrer. La correspondance, à appliquer une fois à la main :
>
> | `policy.yaml` | `flexway.yaml` |
> | --- | --- |
> | `default_backend`, `backends.<n>.default_model`, `external` | `ai.default_backend`, `ai.backends.<n>.*` |
> | `backends.<n>.base_url_env` | `ai.backends.<n>.url` - l'URL elle-même |
> | `backends.<n>.api_key_env: VAR` | `ai.backends.<n>.api_key: "${VAR}"` ou `file:/...` |
> | `pii_guard.sensitive_backend` | `ai.sensitive_backend` |
> | `budget.*_tokens_default` | `ai.budget.*_tokens` ; `REDIS_URL` -> `ai.budget.store` |
> | `overrides` | `ai.overrides`, même forme |
> | En-têtes injectés par l'ingress | `auth.trusted_headers` + `server.trusted_proxies` + `ai.security: [ { trusted_headers: {} } ]` |

## Plusieurs backends : images, embeddings, relais et modèles spécialisés

Depuis `1.5.0`, un backend déclare **où** il est (`kind`), ce
qu'il **sert** (`capabilities`), les modèles qu'un appelant peut **nommer** (`models`), comment la
passerelle s'**authentifie** (`auth`) et **qui mesure** le coût (`metering`). Une configuration
écrite avant reste valide telle quelle : `kind: openai`, `capabilities: [chat]`, `api_key` vaut
`auth: { scheme: bearer }`, `metering: local`.

**Les trois genres** :

```yaml
ai:
  default_backend: platform
  sensitive_backend: local-vision
  backends:
    platform:                          # une autre Flexway : elle mesure, elle plafonne
      kind: relay
      url: https://llm.platform.example/v1
      auth: { scheme: token_file, path: /var/run/flexway/peer-token }   # relu à chaque appel
      identity_headers: { tenant: X-Tenant, agent: X-Agent, sensitive: X-Sensitive }
      metering: upstream
      external: false                  # l'exploitant le déclare : le sensible peut y aller
      capabilities: [chat, vision, embeddings]
    provider-direct:                   # un fournisseur en direct : la clé est ici
      kind: openai
      url: https://api.provider.example/v1
      auth: { scheme: header, header: X-Api-Key, key: "${PROVIDER_KEY}" }
      external: true
      capabilities: [chat, vision, embeddings]
      models: [model-a, model-vision-b, embed-c]   # servis s'ils sont demandés, sinon default_model
      default_model: model-a
      price: { input_per_mtok: 0.15, output_per_mtok: 0.35 }
    local-vision:                      # un modèle du cluster
      url: http://vision.models.svc:8080/v1
      default_model: vision-small
      auth: { scheme: none }
      capabilities: [vision]           # une image ; le texte aussi
    faces:                             # un modèle spécialisé : l'amont d'un contrat gRPC
      kind: grpc
      upstream: models                 # déclaré dans upstreams:, protocol: grpc
      capabilities: [vision]
  overrides:
    - { match: { agent: screen-reader }, backend: provider-direct }
    - { match: { capability: embeddings }, backend: provider-direct }   # route seulement, jamais un budget
  vision:
    max_image_bytes: 8MiB              # une image, décodée (défaut)
    allowed_origins: ["https://cdn.example"]   # sinon images en ligne (data:) seulement
```

| Clé | Valeurs | Effet |
| --- | --- | --- |
| `kind` | `openai` (défaut), `relay`, `grpc` | Une API compatible OpenAI ; une autre Flexway ; l'amont d'un modèle spécialisé gRPC, jamais une cible de `/v1/*` |
| `capabilities` | `chat` (défaut), `vision`, `embeddings` ; `model` (`grpc`) | Une image ne va qu'à un backend `vision`, des embeddings qu'à un backend `embeddings`, sinon `400` `capability_unavailable` |
| `models` | liste | Le modèle demandé est servi s'il y figure, sinon `default_model` ; l'audit garde le modèle servi. Un `relay` sans `default_model` transmet le modèle demandé |
| `auth.scheme` | `none`, `bearer` (`key`), `header` (`header`, `key`), `token_file` (`path`) | La clé est une référence (`${VAR}`, `file:`), relue à chaque appel ; `token_file` relit un jeton court (identité de charge de travail) |
| `metering` | `local` (défaut), `upstream` | Voir ci-dessous |
| `external` | booléen | Une requête sensible n'y va jamais ; un `relay` est externe sauf `external: false` |
| `identity_headers` | `tenant` (obligatoire), `agent`, `sensitive` | Les noms sous lesquels un `relay` reçoit l'identité de l'appel - ceux que l'autre Flexway déclare dans `auth.trusted_headers` et `ai.sensitive_header` |
| `tls` | comme un amont | Le mTLS vers un `relay` |
| `match.capability` (dérogation) | `chat`, `vision`, `embeddings` | Choisit le backend des appels de cette capacité, après la dérogation du tenant et de l'agent ; jamais un budget |

**Mesure** - qui compte le coût :

| `metering` | Jetons | Euros | Refus de l'amont | Séries |
| --- | --- | --- | --- | --- |
| `local` | Réservés, réconciliés sur l'usage | Au prix déclaré | Un `4xx` du fournisseur : `502` `backend_unavailable` | `metered_by="local"` |
| `upstream` | **Plafond local toujours appliqué**, sur l'usage que l'amont rend | Au prix déclaré s'il y en a un ; sans prix, l'amont fait foi | Relayé **tel quel**, `429` `budget_exceeded` compris | `metered_by="upstream"` |

> Une vue de consommation n'additionne que `metered_by!="upstream"` - jamais `="local"` : une série
> d'avant `1.5.0` n'a pas l'étiquette -, un appel relayé étant compté `local` par la Flexway qui le paie.
> La console montre la part de l'amont à côté, sous « Mesuré en amont » ([Lire avec la console](https://flexway.io/docs/operations-console/)). `flexway validate` avertit d'un `relay` mesuré localement.

**Côté plateforme** - la Flexway qui reçoit le relais croit les en-têtes d'identité de ce pair
seulement :

```yaml
server:
  trusted_proxies: [10.42.0.0/16]      # les Flexway des produits
auth:
  trusted_headers: { tenant: X-Tenant, subject: X-Agent }
ai:
  security: [ { trusted_headers: {} } ]
  sensitive_header: X-Sensitive
```

**Image et embeddings** :

```bash
# une image en ligne, vers un backend vision
curl -s http://localhost:8080/v1/chat/completions -H "Authorization: Bearer $TOKEN" \
  -d '{"model":"model-vision-b","messages":[{"role":"user","content":[{"type":"text","text":"Que montre cet écran ?"},{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0..."}}]}]}'
# des embeddings
curl -s http://localhost:8080/v1/embeddings -H "Authorization: Bearer $TOKEN" -d '{"model":"embed-c","input":["un","deux"]}'
```

**Modèle spécialisé gRPC** - une méthode du contrat du produit, mesurée par l'option `ai` :

```protobuf
rpc Classify(Image) returns (Labels) {
  option (flexway.v1.operation) = { ai: { unit: "call", price_per_1k: 0.02, capability: "vision" } };
}
```

Chaque appel réserve son prix dans le budget en **euros** du tenant (`ai.budget.monthly_eur` et les
dérogations), avant l'amont ; `unit: byte` compte les octets de la requête et de la réponse au
passage. Un amont en panne ne coûte rien.

**Vérif** :

| Appel | Réponse |
| --- | --- |
| Image vers un backend `vision` | servie ; le corps tel quel, le modèle admis |
| Image sans backend `vision` choisi, ou embeddings sans backend `embeddings` | `400` `capability_unavailable`, avant tout backend |
| Image au-delà de `max_image_bytes` | `413` `payload_too_large` |
| Image par URL d'une origine non déclarée, partie `input_audio` | `400` `invalid_request` |
| Relais : tenant au-delà du budget de la plateforme | `429` `budget_exceeded` de la plateforme, relayé tel quel |
| Relais : tenant au-delà du plafond local | `429` `budget_exceeded`, la plateforme jamais appelée |
| Méthode gRPC mesurée au-delà du budget | `RESOURCE_EXHAUSTED`, raison `budget_exceeded` ; l'amont jamais appelé |
| Méthode gRPC mesurée sans backend `grpc` qui nomme son amont | **refusée au chargement** |

*Disponible depuis `0.1.0` ; plusieurs backends depuis `1.5.0`.*
