Guides

Vision par API : lire un reçu mobile money avec un LLM (JSON)

TchaviPar Gael — fondateur de Tchavi

Publié le · Mis à jour le · 11 min de lecture

Retour au blog

Sommaire

Pour lire un reçu de mobile money ou une facture de boutique avec un LLM, tu envoies l'image dans le tableau content d'un message user, à côté de ta consigne, et tu demandes du JSON en retour. Un seul appel POST /v1/chat/completions, le même que pour du texte.

J'ai testé trois modèles de vision sur un reçu, une photo de ce reçu et une facture, tous synthétiques. Le résultat qui m'a le plus surpris : le coût par image varie d'un facteur vingt entre les modèles, alors que leurs prix au token sont du même ordre.

En bref

  • Tchavi, une passerelle d'API IA compatible OpenAI que tu paies en mobile money, accepte les images dans chat/completions : un bloc { type: "image_url", image_url: { url } } dans le tableau content, avec l'URL ou une chaîne data: en base64.
  • Précision mesurée : sur un reçu net, une photo dégradée et une facture, gemini-3.1-flash-lite et claude-haiku-5-5 ont tout juste (10/10, 10/10 et 18/18). gpt-4o-mini a fait deux erreurs sur la photo et un caractère parasite sur le reçu net.
  • Coût mesuré pour un reçu : 0,438 crédit avec claude-haiku-5-5, 1,139 avec gemini-3.1-flash-lite, 8,607 avec gpt-4o-mini. Avec 3 crédits pour 1 F, ça fait environ 0,15 F, 0,38 F et 2,9 F.
  • Base64 ou URL : même nombre de tokens sur la même image, donc même facture. La différence est pratique, pas financière.
  • N'accepte jamais un reçu en photo comme preuve de paiement. Une image se falsifie : le crédit se donne sur confirmation de l'opérateur, pas sur une capture d'écran.

Le format : un tableau de blocs

Dans messages, le content d'un message user peut être une chaîne ou un tableau de blocs. Pour une image :

{
  "model": "claude-haiku-5-5",
  "messages": [
    {
      "role": "user",
      "content": [
        { "type": "text", "text": "Lis ce reçu et réponds en JSON." },
        { "type": "image_url", "image_url": { "url": "https://exemple.com/recu.png" } }
      ]
    }
  ]
}

C'est le format décrit dans la documentation de chat/completions, avec les blocs text, image_url et document_url (pour les PDF). Tous les modèles ne lisent pas les images : vérifie la capacité sur la page du modèle ou dans GET /v1/models. J'ai utilisé gpt-4o-mini, gemini-3.1-flash-lite et claude-haiku-5-5.

Base64 ou URL

Tu as deux façons de donner l'image :

  • Une URL publique : pratique si l'image est déjà hébergée (ton stockage, ton CDN). Le fournisseur doit pouvoir la télécharger : une adresse locale, privée ou derrière une connexion ne marchera pas.
  • Du base64 dans une chaîne data: : c'est ce qu'il te faut pour un reçu que ton client vient d'envoyer sur WhatsApp ou depuis ton app. Rien à héberger, mais la requête grossit d'environ un tiers de la taille du fichier.

Je les ai comparées sur la même image, un petit logo public de 192 x 75 pixels, avec deux modèles :

ModèleTransportTokens d'entrée
gpt-4o-miniURL8 515
gpt-4o-minibase648 515
claude-haiku-5-5URL44
claude-haiku-5-5base6444

Les tokens sont les mêmes, donc le coût de l'image aussi. Les écarts de crédits entre mes deux appels viennent de la longueur de la réponse et, pour gpt-4o-mini, d'une partie de l'entrée servie depuis le cache (2 944 tokens la deuxième fois). Les temps étaient proches (1,8 s contre 1,1 s, et 2,5 s contre 1,6 s : trop peu d'essais pour conclure quoi que ce soit). Je n'ai pas hébergé mes reçus synthétiques : le test d'URL porte sur le logo, et les reçus sont partis en base64.

Le code, en Node.js

import { readFileSync } from 'node:fs';

const BASE = 'https://tchavi.com/api/v1';

const CONSIGNE =
  'Lis ce reçu de mobile money. Réponds uniquement avec un objet JSON : ' +
  '{"operateur": string, "montant": number, "devise": string, "frais": number, ' +
  '"destinataire": string, "numero": string, "reference": string, ' +
  '"date": "AAAA-MM-JJ", "heure": "HH:MM", "solde": number}. ' +
  'Les montants sont des nombres sans espaces. Si un champ est illisible, mets null.';

export async function lireRecu(fichier, modele = 'claude-haiku-5-5') {
  const mime = fichier.endsWith('.jpg') ? 'image/jpeg' : 'image/png';
  const dataUrl = `data:${mime};base64,${readFileSync(fichier).toString('base64')}`;

  const res = await fetch(`${BASE}/chat/completions`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.TCHAVI_API_KEY}`,
    },
    body: JSON.stringify({
      model: modele,
      temperature: 0,
      max_tokens: 800,
      messages: [
        {
          role: 'user',
          content: [
            { type: 'text', text: CONSIGNE },
            { type: 'image_url', image_url: { url: dataUrl } },
          ],
        },
      ],
    }),
  });
  const json = await res.json();
  if (!res.ok) throw new Error(`${json.error} : ${json.message}`);

  // Certains modèles entourent le JSON de ```json … ``` : on l'enlève.
  const brut = json.choices[0].message.content.replace(/^```(?:json)?\s*|\s*```$/g, '');
  return {
    donnees: JSON.parse(brut),
    credits: Number(res.headers.get('x-credits-used')),
  };
}

Le même appel en Python

import base64
import json
import os
import re

import requests

CONSIGNE = (
    "Lis ce reçu de mobile money. Réponds uniquement avec un objet JSON : "
    '{"operateur": string, "montant": number, "devise": string, "frais": number, '
    '"destinataire": string, "numero": string, "reference": string, '
    '"date": "AAAA-MM-JJ", "heure": "HH:MM", "solde": number}. '
    "Les montants sont des nombres sans espaces. Si un champ est illisible, mets null."
)


def lire_recu(fichier, modele="claude-haiku-5-5"):
    mime = "image/jpeg" if fichier.endswith(".jpg") else "image/png"
    b64 = base64.b64encode(open(fichier, "rb").read()).decode()
    r = requests.post(
        "https://tchavi.com/api/v1/chat/completions",
        headers={"Authorization": f"Bearer {os.environ['TCHAVI_API_KEY']}"},
        json={
            "model": modele,
            "temperature": 0,
            "max_tokens": 800,
            "messages": [{
                "role": "user",
                "content": [
                    {"type": "text", "text": CONSIGNE},
                    {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}},
                ],
            }],
        },
        timeout=120,
    )
    corps = r.json()
    if not r.ok:
        raise RuntimeError(f"{corps['error']} : {corps['message']}")
    texte = re.sub(r"^```(?:json)?\s*|\s*```$", "", corps["choices"][0]["message"]["content"])
    return json.loads(texte), float(r.headers.get("x-credits-used", 0))

Un point de vigilance : je n'ai utilisé temperature: 0 que sur mes trois modèles. Si un autre modèle, un modèle de raisonnement par exemple, te renvoie une erreur à cause de ce paramètre, retire-le.

Je n'ai pas testé l'option response_format (json_object ou json_schema) dans cette série, donc je ne dis pas qu'elle marche sur chaque modèle : sa prise en charge varie, l'onglet API du modèle te le dit. En attendant, la consigne qui impose le JSON et le nettoyage des blocs de code ci-dessus ont suffi sur mes trois modèles.

Le test : un reçu fictif, une photo, une facture

Pour ne montrer les données de personne, j'ai fabriqué trois images avec Python (PIL), toutes marquées « DOCUMENT FICTIF » :

  1. un reçu de transfert, net, avec dix champs ;
  2. le même reçu « photographié » : perspective, flou, ombre, bruit, JPEG compressé ;
  3. une facture de boutique légèrement penchée, avec trois lignes, un total et une référence de paiement (18 valeurs).

Les noms, numéros et références sont inventés (« Boutique Awa Démo », « TXN-7Q4K92LM18 », 15 000 FCFA). Une seule exécution par modèle et par image, avec temperature: 0 : c'est un petit échantillon, pas un banc d'essai.

Précision, champ par champ

Imagegpt-4o-minigemini-3.1-flash-liteclaude-haiku-5-5
Reçu net (10 champs)9/1010/1010/10
Reçu en photo (10 champs)8/1010/1010/10
Facture (18 valeurs)18/1818/1818/18

Le détail des erreurs de gpt-4o-mini :

  • Reçu net : le champ destinataire est revenu avec un о cyrillique à la place du o latin dans « Démo ». À l'œil, c'est identique. Dans une base de données, c'est un autre texte, et une recherche exacte échoue. Je l'ai trouvé en comparant les codes des caractères.
  • Reçu en photo : destinataire coupé en « Boutique Awa Dém », et numero rendu +22901000000, avec deux zéros en moins. Un numéro faux est pire qu'un champ vide.

Les montants, la date, l'heure, la référence et l'opérateur étaient justes chez les trois modèles sur les trois images. L'erreur est venue des textes longs et des numéros, pas des montants. Je ne tire pas de loi de ça sur d'autres reçus : teste avec les tiens.

Crédits et temps

Imagegpt-4o-minigemini-3.1-flash-liteclaude-haiku-5-5
Reçu net8,607 cr, 2,6 s1,139 cr, 1,9 s0,438 cr, 1,9 s
Reçu en photo8,596 cr, 2,9 s1,114 cr, 1,8 s0,678 cr, 4,0 s
Facture8,723 cr, 3,5 s1,593 cr, 2,3 s0,478 cr, 2,4 s

Pourquoi un tel écart ? Regarde les tokens d'entrée du reçu net (640 x 980 pixels) :

ModèleTokens d'entréePrix d'entrée (crédits / 1 000 tokens)
gpt-4o-mini25 6070,33
gemini-3.1-flash-lite1 2050,581
claude-haiku-5-59570,22

gpt-4o-mini a le deuxième prix au token, mais il compte la même image comme plus de vingt fois plus de tokens : la facture suit. Le prix par token seul ne dit donc pas combien coûtera une image. Compare sur une vraie image, avec x-credits-used.

Les coûts se recomptent : pour claude-haiku-5-5 sur le reçu net, 957 tokens en entrée à 0,22 et 206 en sortie à 1,1 pour 1 000 donnent 0,437, et j'ai mesuré 0,438. À 0,438 crédit le reçu, mille reçus font 438 crédits, soit environ 146 F. Avec gpt-4o-mini, 8 607 crédits, soit environ 2 869 F. Ce sont les tarifs publics ; certains modèles bénéficient en ce moment d'une remise, et la page de chaque modèle affiche le prix facturé du moment.

Ce que je ferais en production

  1. Choisis le modèle sur tes images, avec trente reçus réels (en retirant les données personnelles) et le décompte champ par champ ci-dessus.
  2. Valide le JSON avec du code : le montant est un nombre positif, la date existe, la référence suit le format de ton opérateur, et sur une facture la somme des lignes égale le total. Mes trois modèles ont tous passé ce dernier contrôle, mais c'est lui qui rattrapera l'erreur un jour.
  3. Normalise les textes (Unicode, espaces) avant de comparer ou de stocker, à cause du caractère cyrillique de mon test.
  4. Demande null plutôt qu'une supposition : la consigne « si un champ est illisible, mets null » est dans mon code pour ça.
  5. Ne considère pas l'image comme une preuve de paiement. Pour créditer un client, attends le webhook ou la confirmation de l'opérateur. Le reçu lu par le LLM sert à pré-remplir un formulaire, pas à valider un paiement.
  6. Plafonne la taille des images que tu acceptes et réduis-les avant l'envoi : le nombre de tokens suit les dimensions.

Pour les prix du texte en FCFA, tu as le comparatif des prix.

FAQ

Comment envoyer une image à un LLM par l'API ?

Dans messages, mets un message user dont le content est un tableau avec un bloc text et un bloc image_url. L'URL peut être une adresse publique ou une chaîne data:image/png;base64,....

Base64 ou URL : lequel choisir ?

Le base64 pour une image que tu viens de recevoir et que tu n'as pas hébergée, l'URL pour une image déjà en ligne et publique. Sur la même image, j'ai mesuré les mêmes tokens avec les deux, donc le même coût.

Quels modèles lisent les images ?

Ceux dont la capacité vision est affichée sur leur page ou dans GET /v1/models. J'ai testé gpt-4o-mini, gemini-3.1-flash-lite et claude-haiku-5-5.

Combien coûte la lecture d'un reçu ?

Entre 0,438 et 8,607 crédits sur mes trois modèles pour un reçu de 640 x 980 pixels, soit de 0,15 F à 2,9 F. Le coût dépend surtout du nombre de tokens que chaque modèle attribue à l'image.

Le LLM peut-il remplacer un OCR ?

Pour des reçus et des factures simples, il a bien fait sur mes essais, y compris sur une photo dégradée. Il peut aussi se tromper de façon discrète, comme un numéro avec des chiffres en moins. Garde donc des contrôles dans ton code.

Puis-je lire un PDF ?

Oui, avec un bloc document_url, sur les modèles qui ont la capacité documents (par exemple les modèles Claude). Je ne l'ai pas testé ici.

Comment payer sans carte bancaire ?

En mobile money : tu recharges à partir de 1 000 F, 3 crédits valent 1 F, et il n'y a ni abonnement ni crédits gratuits à l'inscription. Ta première recharge ajoute 150 crédits de bonus.

crée ton compte et recharge 1 000 F

Cet article fait partie de la série « Construire un SaaS IA depuis l'Afrique francophone ». Tchavi est une passerelle d'API IA compatible OpenAI : une seule clé donne accès à plus de 100 modèles de texte, d'image, d'audio et de vidéo, et tu la paies en mobile money, sans carte bancaire (pays et opérateurs). Plus de guides sur le blog. Pour suivre les prochains : LinkedIn et X.

Partager

Ton premier appel d'API, payé en mobile money

Sans carte bancaire. Recharge à partir de 1 000 F ; ta première recharge ajoute 150 crédits de bonus, et tes crédits n'expirent pas.