📑 Table des matières

APIs IA : OpenRouter vs appels directs

Self-Hosting 🟡 Intermédiaire ⏱️ 15 min de lecture 📅 2026-02-24

Quand vous construisez un projet basé sur l'IA, une question cruciale se pose rapidement : comment appeler les modèles de langage ? Directement via les APIs officielles (OpenAI, Anthropic, Google) ou via un agrégateur comme OpenRouter ?

Ce choix a des implications profondes sur la fiabilité, les coûts, la flexibilité et la maintenabilité de votre code. Dans cet article, on compare les deux approches en détail, avec du code Python concret et un pattern de fallback production-ready.


🎯 Le problème : trop de fournisseurs, pas assez de standards

Le marché des LLM en 2025, c'est un zoo. Chaque fournisseur a :
- Son propre endpoint (api.openai.com, api.anthropic.com, generativelanguage.googleapis.com...)
- Son propre format de requête et de réponse
- Sa propre gestion des erreurs, rate limits, et authentification
- Ses propres modèles avec des noms et versions différents

Pour un développeur, ça signifie : N intégrations à maintenir, N clés API à gérer, N formats à parser.


🔌 Appels directs : la méthode classique

Comment ça marche

Vous créez un compte chez chaque fournisseur, obtenez une clé API, et appelez directement leur endpoint.

Appel direct à OpenAI

import httpx
import os

async def call_openai(prompt: str, model: str = "gpt-4o") -> str:
    """Appel direct a l'API OpenAI."""
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://api.openai.com/v1/chat/completions",
            headers={
                "Authorization": f"Bearer {os.getenv('OPENAI_API_KEY')}",
                "Content-Type": "application/json",
            },
            json={
                "model": model,
                "messages": [{"role": "user", "content": prompt}],
                "max_tokens": 1000,
                "temperature": 0.7,
            },
            timeout=30.0,
        )
        response.raise_for_status()
        return response.json()["choices"][0]["message"]["content"]

Appel direct à Anthropic

async def call_anthropic(prompt: str, model: str = "claude-sonnet-4-20250514") -> str:
    """Appel direct a l'API Anthropic."""
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://api.anthropic.com/v1/messages",
            headers={
                "x-api-key": os.getenv("ANTHROPIC_API_KEY"),
                "anthropic-version": "2023-06-01",
                "Content-Type": "application/json",
            },
            json={
                "model": model,
                "max_tokens": 1000,
                "messages": [{"role": "user", "content": prompt}],
            },
            timeout=30.0,
        )
        response.raise_for_status()
        return response.json()["content"][0]["text"]

Appel direct à Google (Gemini)

async def call_google(prompt: str, model: str = "gemini-2.0-flash") -> str:
    """Appel direct a l'API Google Gemini."""
    api_key = os.getenv("GOOGLE_API_KEY")
    async with httpx.AsyncClient() as client:
        response = await client.post(
            f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}",
            json={
                "contents": [{"parts": [{"text": prompt}]}],
                "generationConfig": {
                    "maxOutputTokens": 1000,
                    "temperature": 0.7,
                },
            },
            timeout=30.0,
        )
        response.raise_for_status()
        return response.json()["candidates"][0]["content"]["parts"][0]["text"]

Vous voyez le problème ? Trois fonctions différentes, trois formats de requête, trois formats de réponse, trois systèmes d'authentification. Et on n'a couvert que 3 fournisseurs sur des dizaines.

Avantages des appels directs

Avantage Détail
Latence minimale Pas d'intermédiaire, connexion directe
Moins de dépendance Un seul point de défaillance par fournisseur
Accès immédiat Nouveaux modèles disponibles dès la sortie
Pas de surcoût Prix fournisseur brut, sans marge
Contrôle total Accès à tous les paramètres, même expérimentaux

Inconvénients des appels directs

Inconvénient Détail
Maintenance lourde N intégrations = N fois plus de code à maintenir
N clés API Une clé par fournisseur à gérer et sécuriser
N factures Un compte billing par fournisseur
Pas de fallback natif Si un provider tombe, votre code crashe
Pas de vue unifiée Difficile de comparer les coûts et l'usage

🌐 OpenRouter : l'agrégateur universel

Comment ça marche

OpenRouter est un proxy API qui expose un endpoint unique compatible OpenAI. Vous envoyez vos requêtes à OpenRouter, qui les route vers le bon fournisseur.

Votre code ---> OpenRouter ---> OpenAI / Anthropic / Google / Mistral / ...

Une seule clé API, un seul format, un seul endpoint.

Appel via OpenRouter

async def call_openrouter(
    prompt: str,
    model: str = "anthropic/claude-sonnet-4-20250514"
) -> str:
    """Appel via OpenRouter, un endpoint pour tous les modeles."""
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://openrouter.ai/api/v1/chat/completions",
            headers={
                "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}",
                "Content-Type": "application/json",
                "HTTP-Referer": "https://monsite.com",
                "X-Title": "Mon App IA",
            },
            json={
                "model": model,
                "messages": [{"role": "user", "content": prompt}],
                "max_tokens": 1000,
                "temperature": 0.7,
            },
            timeout=60.0,
        )
        response.raise_for_status()
        return response.json()["choices"][0]["message"]["content"]

Le même code fonctionne pour TOUS les modèles -- il suffit de changer le paramètre model :

# Claude
result = await call_openrouter(prompt, "anthropic/claude-sonnet-4-20250514")

# GPT-4o
result = await call_openrouter(prompt, "openai/gpt-4o")

# Gemini
result = await call_openrouter(prompt, "google/gemini-2.0-flash")

# Mistral
result = await call_openrouter(prompt, "mistralai/mistral-large")

# Modeles GRATUITS
result = await call_openrouter(prompt, "google/gemini-2.0-flash-exp:free")

Tableau comparatif complet

Critère Appels directs OpenRouter
Endpoint 1 par fournisseur 1 unique
Clés API 1 par fournisseur 1 seule
Format Variable Unifié (compatible OpenAI)
Latence Minimale +50-100ms
Fallback À coder soi-même Intégré (route auto)
Billing N factures 1 facture
Modèles gratuits Rares Oui, plusieurs disponibles
Nouveaux modèles Immédiat Délai de quelques heures/jours
Coût Prix brut Prix brut + petite marge (~5-15%)
Disponibilité Directe Dépend d'OpenRouter
Dashboard N dashboards 1 unifié

⚡ Les vrais avantages d'OpenRouter

1. Modèles gratuits

OpenRouter propose des modèles gratuits, parfaits pour le prototypage et les tâches simples :

FREE_MODELS = [
    "google/gemini-2.0-flash-exp:free",
    "meta-llama/llama-3.1-8b-instruct:free",
    "mistralai/mistral-7b-instruct:free",
    "huggingfaceh4/zephyr-7b-beta:free",
]

2. Fallback automatique

Si un fournisseur est down, OpenRouter peut automatiquement basculer :

# Avec le parametre "route"
response = await client.post(
    "https://openrouter.ai/api/v1/chat/completions",
    json={
        "model": "anthropic/claude-sonnet-4-20250514",
        "route": "fallback",  # Bascule auto si le modele principal est down
        "messages": [{"role": "user", "content": prompt}],
    },
)

3. Billing unifié

Une seule facture, un seul dashboard pour voir :
- Combien vous dépensez par modèle
- Quels modèles sont les plus utilisés
- Votre consommation en temps réel

4. Comparaison facile

Tester un nouveau modèle = changer une string. Pas besoin de créer un compte, obtenir une clé API, intégrer un nouveau format.


⚠️ Les vrais inconvénients d'OpenRouter

Soyons honnêtes, tout n'est pas rose.

Pour aller plus loin sur ce sujet, consultez notre guide VPS + IA : le setup complet pour tout auto-héberger.

1. Latence supplémentaire (+50-100ms)

Pour aller plus loin sur ce sujet, consultez notre guide Docker + IA : conteneuriser ses services intelligents.

Chaque requête passe par un intermédiaire. Pour du chat temps réel, ça peut se sentir. Pour du batch processing, c'est négligeable.

Appel direct:    Vous ---- 120ms ----> Anthropic
OpenRouter:      Vous -- 50ms --> OpenRouter -- 120ms --> Anthropic
                         Total: ~170ms (+50ms)

2. Point de défaillance unique

Si OpenRouter tombe, tous vos appels échouent. C'est le risque principal de la centralisation.

3. Disponibilité des modèles

Certains modèles ne sont pas disponibles sur OpenRouter, ou avec un délai après leur sortie. Les modèles très récents ou en preview peuvent manquer.

4. Marge sur les prix

OpenRouter ajoute une marge (généralement 5-15%) par rapport aux prix directs. Sur de gros volumes, ça peut compter.


🏗️ Pattern production : ModelManager avec fallback

Voici le pattern que nous utilisons en production. Il combine le meilleur des deux mondes : OpenRouter en principal, appels directs en fallback.

import httpx
import asyncio
import time
import os
from dataclasses import dataclass, field
from typing import Optional
import logging

logger = logging.getLogger(__name__)


@dataclass
class ModelConfig:
    """Configuration d'un modele avec son provider."""
    name: str
    provider: str  # "openrouter", "openai", "anthropic", "google"
    model_id: str
    max_tokens: int = 2000
    temperature: float = 0.7
    cost_per_1k_input: float = 0.0
    cost_per_1k_output: float = 0.0


@dataclass
class RateLimitState:
    """Etat du rate limiting pour un provider."""
    is_limited: bool = False
    retry_after: float = 0.0
    limited_at: float = 0.0
    consecutive_errors: int = 0


class ModelManager:
    """Gestionnaire de modeles avec fallback chain et rate-limit detection."""

    def __init__(self):
        self.providers: dict[str, RateLimitState] = {}
        self.fallback_chains: dict[str, list[ModelConfig]] = {}
        self._setup_default_chains()

    def _setup_default_chains(self):
        """Configure les chaines de fallback par defaut."""

        # Chaine "smart" : meilleurs modeles
        self.fallback_chains["smart"] = [
            ModelConfig(
                name="Claude Sonnet (OpenRouter)",
                provider="openrouter",
                model_id="anthropic/claude-sonnet-4-20250514",
                cost_per_1k_input=0.003,
                cost_per_1k_output=0.015,
            ),
            ModelConfig(
                name="Claude Sonnet (Direct)",
                provider="anthropic",
                model_id="claude-sonnet-4-20250514",
                cost_per_1k_input=0.003,
                cost_per_1k_output=0.015,
            ),
            ModelConfig(
                name="GPT-4o (Direct)",
                provider="openai",
                model_id="gpt-4o",
                cost_per_1k_input=0.005,
                cost_per_1k_output=0.015,
            ),
        ]

        # Chaine "fast" : modeles rapides et economiques
        self.fallback_chains["fast"] = [
            ModelConfig(
                name="Gemini Flash (OpenRouter)",
                provider="openrouter",
                model_id="google/gemini-2.0-flash",
            ),
            ModelConfig(
                name="GPT-4o-mini (Direct)",
                provider="openai",
                model_id="gpt-4o-mini",
                cost_per_1k_input=0.00015,
                cost_per_1k_output=0.0006,
            ),
        ]

        # Chaine "free" : modeles gratuits uniquement
        self.fallback_chains["free"] = [
            ModelConfig(
                name="Gemini Flash Free",
                provider="openrouter",
                model_id="google/gemini-2.0-flash-exp:free",
            ),
            ModelConfig(
                name="Llama 3.1 Free",
                provider="openrouter",
                model_id="meta-llama/llama-3.1-8b-instruct:free",
            ),
        ]

    def _is_rate_limited(self, provider: str) -> bool:
        """Verifie si un provider est actuellement rate-limite."""
        state = self.providers.get(provider)
        if not state or not state.is_limited:
            return False
        if time.time() > state.limited_at + state.retry_after:
            state.is_limited = False
            state.consecutive_errors = 0
            return False
        return True

    def _mark_rate_limited(self, provider: str, retry_after: float = 60.0):
        """Marque un provider comme rate-limite."""
        if provider not in self.providers:
            self.providers[provider] = RateLimitState()
        state = self.providers[provider]
        state.is_limited = True
        state.retry_after = retry_after
        state.limited_at = time.time()
        state.consecutive_errors += 1
        logger.warning(
            f"Provider {provider} rate-limite pour {retry_after}s "
            f"(erreurs consecutives: {state.consecutive_errors})"
        )

    async def _call_provider(self, config: ModelConfig, messages: list[dict]) -> str:
        """Appelle un provider specifique."""
        if config.provider == "openrouter":
            return await self._call_openrouter(config, messages)
        elif config.provider == "openai":
            return await self._call_openai(config, messages)
        elif config.provider == "anthropic":
            return await self._call_anthropic(config, messages)
        else:
            raise ValueError(f"Provider inconnu: {config.provider}")

    async def _call_openrouter(self, config: ModelConfig, messages: list[dict]) -> str:
        async with httpx.AsyncClient() as client:
            resp = await client.post(
                "https://openrouter.ai/api/v1/chat/completions",
                headers={
                    "Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}",
                    "Content-Type": "application/json",
                },
                json={
                    "model": config.model_id,
                    "messages": messages,
                    "max_tokens": config.max_tokens,
                    "temperature": config.temperature,
                },
                timeout=60.0,
            )
            if resp.status_code == 429:
                retry = float(resp.headers.get("retry-after", 60))
                self._mark_rate_limited("openrouter", retry)
                raise Exception("Rate limited")
            resp.raise_for_status()
            return resp.json()["choices"][0]["message"]["content"]

    async def _call_openai(self, config: ModelConfig, messages: list[dict]) -> str:
        async with httpx.AsyncClient() as client:
            resp = await client.post(
                "https://api.openai.com/v1/chat/completions",
                headers={"Authorization": f"Bearer {os.getenv('OPENAI_API_KEY')}"},
                json={
                    "model": config.model_id,
                    "messages": messages,
                    "max_tokens": config.max_tokens,
                    "temperature": config.temperature,
                },
                timeout=30.0,
            )
            if resp.status_code == 429:
                retry = float(resp.headers.get("retry-after", 60))
                self._mark_rate_limited("openai", retry)
                raise Exception("Rate limited")
            resp.raise_for_status()
            return resp.json()["choices"][0]["message"]["content"]

    async def _call_anthropic(self, config: ModelConfig, messages: list[dict]) -> str:
        async with httpx.AsyncClient() as client:
            resp = await client.post(
                "https://api.anthropic.com/v1/messages",
                headers={
                    "x-api-key": os.getenv("ANTHROPIC_API_KEY"),
                    "anthropic-version": "2023-06-01",
                },
                json={
                    "model": config.model_id,
                    "max_tokens": config.max_tokens,
                    "messages": messages,
                },
                timeout=30.0,
            )
            if resp.status_code == 429:
                retry = float(resp.headers.get("retry-after", 60))
                self._mark_rate_limited("anthropic", retry)
                raise Exception("Rate limited")
            resp.raise_for_status()
            return resp.json()["content"][0]["text"]

    async def complete(self, messages: list[dict], chain: str = "smart") -> str:
        """Appelle le meilleur modele disponible dans la chaine de fallback."""
        models = self.fallback_chains.get(chain, self.fallback_chains["smart"])
        last_error = None
        for config in models:
            if self._is_rate_limited(config.provider):
                logger.info(f"Skipping {config.name} (rate-limite)")
                continue
            try:
                logger.info(f"Tentative avec {config.name}...")
                result = await self._call_provider(config, messages)
                logger.info(f"Succes avec {config.name}")
                return result
            except Exception as e:
                last_error = e
                logger.warning(f"Echec avec {config.name}: {e}")
                continue
        raise Exception(
            f"Tous les modeles de la chaine '{chain}' ont echoue. "
            f"Derniere erreur: {last_error}"
        )


# Utilisation
async def main():
    manager = ModelManager()

    # Utilisation simple
    result = await manager.complete(
        messages=[{"role": "user", "content": "Explique le RAG en 3 phrases."}],
        chain="smart",
    )
    print(result)

if __name__ == "__main__":
    asyncio.run(main())

Comment ça marche ?

  1. Chaînes de fallback : chaque chaîne (smart, fast, free) définit une liste ordonnée de modèles
  2. Rate-limit detection : quand un provider renvoie 429, il est marqué comme limité pour la durée indiquée
  3. Fallback automatique : si un modèle échoue ou est rate-limité, on passe au suivant
  4. Providers mixtes : une même chaîne peut mélanger OpenRouter et appels directs

📊 Quand utiliser quoi ?

Situation Recommandation Pourquoi
Prototypage / side project OpenRouter seul Un seul compte, modèles gratuits, rapide à setup
Production avec 1 modèle Appel direct Moins de latence, moins de dépendance
Production multi-modèles OpenRouter + fallback direct Meilleur des deux mondes
Gros volumes (>100k req/jour) Appels directs Économie sur la marge OpenRouter
Budget serré OpenRouter modèles gratuits Zéro coût pour les modèles free-tier
Tests / comparaison modèles OpenRouter Changer de modèle = changer une string

🔧 Configuration dans OpenClaw

Si vous utilisez OpenClaw sur votre VPS, la configuration des providers est simple. OpenClaw utilise nativement OpenRouter :

# Dans votre config OpenClaw
providers:
  openrouter:
    api_key: ${OPENROUTER_API_KEY}
    default_model: anthropic/claude-sonnet-4-20250514

  # Fallback direct (optionnel)
  anthropic:
    api_key: ${ANTHROPIC_API_KEY}

Pour en savoir plus, consultez notre guide Configurer OpenClaw.


🎯 Notre recommandation

Pour la plupart des projets self-hosted :

  1. Commencez avec OpenRouter -- c'est le plus simple et le plus flexible
  2. Ajoutez des appels directs en fallback pour vos modèles critiques
  3. Utilisez le pattern ModelManager pour gérer automatiquement les pannes
  4. Surveillez vos coûts via le dashboard OpenRouter et ajustez

Le code est dans l'article, le pattern est testé en production. À vous de jouer.


📚 Articles liés