Aller au contenu principal
alexis_amzFR / EN
// NAVIGATION
Home01Web02Vidéo03Photos04Musique05Blog06À Propos07Contact08
FR / EN
contact@alexisamz.fr
← Retour au blog

175 $ de crédits API gratuits avec AgentRouter (et comment les utiliser avec OpenCode)

Guide complet pour obtenir 175 $ de crédits API gratuits sur AgentRouter, accéder à GPT-5.6 Sol, Claude Opus 5 et Opus 4.8, et les brancher sur OpenCode grâce à un proxy Python qui corrige les erreurs 401.

Article publié// agentrouter-175-credits-gratuits
175 $ de crédits API gratuits avec AgentRouter (et comment les utiliser avec OpenCode)

175 $ de crédits API gratuits avec AgentRouter (et comment les utiliser avec OpenCode)

Les crédits API, c'est le nerf de la guerre quand tu bosses avec des agents de code. Entre Claude Opus, GPT-5 et les allers-retours interminables d'un agent qui refactorise ton projet, la facture grimpe vite.

AgentRouter propose actuellement 175 $ de crédits offerts à l'inscription. Dans cet article, je t'explique comment les récupérer, puis comment brancher tout ça sur OpenCode — y compris la partie que personne ne documente : les erreurs 401 unauthorized client detected qui bloquent la plupart des gens au premier lancement.

Transparence : le lien d'inscription ci-dessous est un lien affilié. C'est aussi la condition pour débloquer les 175 $ — sans passer par un lien de parrainage, les crédits ne sont pas crédités sur le compte.

Ce qu'est AgentRouter

AgentRouter est un routeur d'API compatible OpenAI. Concrètement : une seule clé, une seule URL de base, et derrière tu accèdes à plusieurs modèles de différents fournisseurs. Comme l'API respecte le format OpenAI, n'importe quel outil qui sait parler à OpenAI sait parler à AgentRouter — moyennant quelques ajustements dont on va parler.

Les modèles disponibles

Trois modèles sont accessibles :

  • gpt-5.6-sol
  • claude-opus-5
  • claude-opus-4-8

C'est peu en nombre, mais ce sont précisément les modèles les plus utiles pour du code agentique. Rien à jeter.

Étape 1 — Créer le compte et récupérer les 175 $

Une seule condition à remplir :

Ton compte GitHub doit avoir été créé avant 2025.

C'est le garde-fou anti-abus. Un compte GitHub fabriqué ce matin sera refusé, inutile d'essayer.

Pour l'inscription :

  1. Va sur agentrouter.org
  2. Connecte-toi avec GitHub
  3. Les crédits sont ajoutés automatiquement au compte
  4. Génère une clé API depuis le tableau de bord

Garde cette clé de côté, on en a besoin tout de suite. Et ne la commite jamais dans un dépôt Git.

Étape 2 — Comprendre pourquoi un proxy est nécessaire

Si tu configures OpenCode pour taper directement sur https://agentrouter.org/v1, tu vas te manger l'un de ces murs :

  • 401 unauthorized client detected
  • JSON parsing failed
  • Unexpected non-whitespace character after JSON
  • Un modèle qui tourne dans le vide, sans jamais répondre

Ce ne sont pas des bugs de ta config. Il y a trois incompatibilités réelles entre le SDK d'OpenCode et AgentRouter :

1. Le User-Agent

AgentRouter n'autorise que les requêtes portant l'en-tête User-Agent: opencode/<version>. Le SDK IA d'OpenCode envoie le sien, et se fait rejeter avec un 401 — même avec une clé parfaitement valide. C'est la cause numéro un des messages « clé invalide » alors que la clé est bonne.

2. Les champs null dans la requête

Le SDK envoie des champs à null, typiquement response_format: null. AgentRouter refuse et renvoie :

expected object, received null

3. Les objets billing.summary dans la réponse

AgentRouter injecte des objets de facturation au milieu du flux SSE :

data: {"object":"billing.summary", ...}

Ce n'est ni un chunk de complétion, ni une erreur. Le validateur Zod du SDK ne sait pas quoi en faire et casse le parsing. Même problème avec les événements data: null.

Un petit proxy local règle les trois d'un coup : il réécrit l'en-tête, nettoie la requête sortante et filtre le flux entrant.

Étape 3 — Installer le proxy

Vérifie d'abord que Python 3.10 ou plus récent est installé :

python3 --version

Crée ensuite un fichier agentrouter-proxy.py et colle ce script dedans :

#!/usr/bin/env python3
"""
AgentRouter <-> OpenCode proxy (OpenAI-compatible endpoints).

Fixes three incompatibilities:
  1. Injects the User-Agent required by AgentRouter (avoids 401).
  2. Strips null fields from the request body (avoids 400).
  3. Filters billing.summary and `data: null` events from the response.

Usage:
    export AGENTROUTER_API_KEY="sk-..."
    python3 agentrouter-proxy.py [port]      # default port: 4182

Environment variables:
    AGENTROUTER_API_KEY    AgentRouter API key (required)
    AGENTROUTER_UPSTREAM   Upstream URL (default: https://agentrouter.org)
    AGENTROUTER_DEBUG=1    Dump requests and SSE events to stderr
"""

import http.client
import http.server
import json
import os
import ssl
import sys
import threading
import urllib.error
import urllib.request

UPSTREAM = os.environ.get("AGENTROUTER_UPSTREAM", "https://agentrouter.org")
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 4182
API_KEY = os.environ.get("AGENTROUTER_API_KEY", "")
DEBUG = os.environ.get("AGENTROUTER_DEBUG", "") == "1"

# Without this exact User-Agent, AgentRouter returns 401 even with a valid key.
USER_AGENT = "opencode/1.17.12"
CTX = ssl.create_default_context()


def strip_nulls(value):
    """Recursively remove dict entries whose value is None."""
    if isinstance(value, dict):
        return {k: strip_nulls(v) for k, v in value.items() if v is not None}
    if isinstance(value, list):
        return [strip_nulls(item) for item in value]
    return value


def clean_request_body(raw: bytes) -> bytes:
    """Drop null fields from the outgoing JSON body."""
    try:
        payload = json.loads(raw)
    except (json.JSONDecodeError, ValueError):
        return raw

    if not isinstance(payload, dict):
        return raw

    return json.dumps(strip_nulls(payload), separators=(",", ":")).encode()


def is_billing_summary(payload) -> bool:
    """AgentRouter injects billing.summary objects the AI SDK cannot parse."""
    return isinstance(payload, dict) and payload.get("object") == "billing.summary"


def clean_sse_event(event: bytes):
    """Filter a single SSE event. Return None to drop it."""
    stripped = event.strip()
    if not stripped:
        return event  # SSE separator, keep as-is

    if stripped.startswith(b"data:"):
        payload = stripped[len(b"data:"):].strip()
        if payload in (b"", b"null"):
            return None
        try:
            parsed = json.loads(payload)
        except (json.JSONDecodeError, ValueError):
            return event  # e.g. "[DONE]"
        return None if is_billing_summary(parsed) else event

    # Bare JSON with no `data:` prefix
    if stripped[:1] in (b"{", b"["):
        try:
            parsed = json.loads(stripped)
        except (json.JSONDecodeError, ValueError):
            return event
        if is_billing_summary(parsed):
            return None

    return event


def clean_nonstream_body(raw: bytes) -> bytes:
    """Replace a billing.summary response with a readable JSON error."""
    if not raw:
        return raw
    try:
        payload = json.loads(raw)
    except (json.JSONDecodeError, ValueError):
        return raw

    if is_billing_summary(payload):
        sys.stderr.write("[ar] filtered billing.summary from non-stream response\n")
        return json.dumps(
            {
                "error": {
                    "message": "agentrouter billing.summary is not a chat response",
                    "type": "proxy_filtered",
                }
            }
        ).encode()
    return raw


class ProxyHandler(http.server.BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def do_POST(self):
        self._proxy()

    def do_GET(self):
        self._proxy()

    def do_OPTIONS(self):
        self.send_response(200)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "*")
        self.send_header("Content-Length", "0")
        self.end_headers()

    def _proxy(self):
        content_len = int(self.headers.get("Content-Length", 0))
        raw_body = self.rfile.read(content_len) if content_len > 0 else None

        if DEBUG and raw_body is not None:
            try:
                payload = json.loads(raw_body)
                sys.stderr.write(
                    f"[ar-dbg] req model={payload.get('model')} "
                    f"stream={payload.get('stream')} "
                    f"messages={len(payload.get('messages', []))} "
                    f"bytes={len(raw_body)} keys={list(payload.keys())}\n"
                )
            except (json.JSONDecodeError, ValueError):
                sys.stderr.write(f"[ar-dbg] req {len(raw_body)}b (not JSON)\n")

        body = clean_request_body(raw_body) if raw_body else None

        headers = {
            k: v
            for k, v in self.headers.items()
            if k.lower()
            not in ("host", "connection", "transfer-encoding", "accept-encoding")
        }
        headers["Authorization"] = f"Bearer {API_KEY}"
        headers["User-Agent"] = USER_AGENT
        # Force an uncompressed response: gzip/br cannot be filtered line by line.
        headers["Accept-Encoding"] = "identity"
        if body is not None:
            headers["Content-Length"] = str(len(body))

        url = f"{UPSTREAM}{self.path}"
        request = urllib.request.Request(
            url, data=body, headers=headers, method=self.command
        )
        is_stream = bool(body) and b'"stream":true' in body.replace(b" ", b"")

        try:
            response = urllib.request.urlopen(request, context=CTX, timeout=300)

            if is_stream:
                self.send_response(response.status)
                for k, v in response.headers.items():
                    if k.lower() not in (
                        "transfer-encoding",
                        "connection",
                        "content-length",
                    ):
                        self.send_header(k, v)
                self.send_header("Transfer-Encoding", "chunked")
                self.end_headers()
                self._stream_sse(response)
            else:
                try:
                    raw_response = response.read()
                except http.client.IncompleteRead as exc:
                    raw_response = exc.partial
                payload = clean_nonstream_body(raw_response)
                self.send_response(response.status)
                for k, v in response.headers.items():
                    if k.lower() not in (
                        "transfer-encoding",
                        "connection",
                        "content-length",
                    ):
                        self.send_header(k, v)
                self.send_header("Content-Length", str(len(payload)))
                self.end_headers()
                self.wfile.write(payload)

        except urllib.error.HTTPError as exc:
            err_body = exc.read()
            sys.stderr.write(
                f"[ar] ERROR {exc.code}: {err_body.decode(errors='replace')[:500]}\n"
            )
            self.send_response(exc.code)
            self.send_header(
                "Content-Type", exc.headers.get("Content-Type", "application/json")
            )
            self.send_header("Content-Length", str(len(err_body)))
            self.end_headers()
            self.wfile.write(err_body)

        except Exception as exc:  # noqa: BLE001 - proxy must never crash
            sys.stderr.write(f"[ar] EXCEPTION: {exc}\n")
            payload = json.dumps({"error": {"message": str(exc)}}).encode()
            self.send_response(502)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(payload)))
            self.end_headers()
            self.wfile.write(payload)

    def _stream_sse(self, response):
        """Forward the SSE stream, dropping `data: null` and billing.summary."""
        buffer = b""
        try:
            while True:
                try:
                    chunk = response.read(1)
                except http.client.IncompleteRead as exc:
                    chunk = exc.partial
                if not chunk:
                    break

                buffer += chunk
                if not (buffer.endswith(b"\n\n") or buffer.endswith(b"\r\n\r\n")):
                    continue

                if DEBUG:
                    sys.stderr.write(
                        f"[ar-dbg] event ({len(buffer)}b): {buffer[:400]!r}\n"
                    )

                cleaned = clean_sse_event(buffer)
                buffer = b""
                if cleaned is None:
                    if DEBUG:
                        sys.stderr.write("[ar-dbg] -> dropped by filter\n")
                    continue
                self._write_chunk(cleaned)

            if buffer:
                cleaned = clean_sse_event(buffer)
                if cleaned is not None:
                    self._write_chunk(cleaned)

            self.wfile.write(b"0\r\n\r\n")
            self.wfile.flush()
        except (ConnectionResetError, BrokenPipeError):
            return

    def _write_chunk(self, data: bytes):
        self.wfile.write(f"{len(data):X}\r\n".encode())
        self.wfile.write(data)
        self.wfile.write(b"\r\n")
        self.wfile.flush()

    def log_message(self, fmt, *args):
        sys.stderr.write(f"[ar] {args[0]}\n")


class ThreadedHTTPServer(http.server.HTTPServer):
    """Handle each request in its own thread so streaming never blocks."""

    daemon_threads = True

    def process_request(self, request, client_address):
        thread = threading.Thread(
            target=self.process_request_thread, args=(request, client_address)
        )
        thread.daemon = True
        thread.start()

    def process_request_thread(self, request, client_address):
        try:
            self.finish_request(request, client_address)
        except Exception:  # noqa: BLE001
            self.handle_error(request, client_address)
        finally:
            self.shutdown_request(request)


if __name__ == "__main__":
    if not API_KEY:
        print("ERROR: set AGENTROUTER_API_KEY first", file=sys.stderr)
        sys.exit(1)
    server = ThreadedHTTPServer(("127.0.0.1", PORT), ProxyHandler)
    print(f"agentrouter-proxy :{PORT} -> {UPSTREAM}")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nProxy stopped.")
        server.server_close()

Aucune dépendance à installer : tout vient de la bibliothèque standard de Python. Pas de pip install, pas d'environnement virtuel.

Étape 4 — Définir ta clé API

La clé se passe par variable d'environnement, jamais en dur dans le script.

macOS / Linux

export AGENTROUTER_API_KEY="ta_cle_agentrouter"

Windows (PowerShell)

$env:AGENTROUTER_API_KEY="ta_cle_agentrouter"

Windows (invite de commandes)

set AGENTROUTER_API_KEY=ta_cle_agentrouter

Attention : ces commandes ne valent que pour le terminal en cours. Si tu fermes la fenêtre, il faut recommencer. Pour rendre ça permanent sur macOS ou Linux, ajoute la ligne export à ton ~/.zshrc ou ~/.bashrc.

Étape 5 — Lancer le proxy

Ouvre un terminal dans le dossier qui contient agentrouter-proxy.py, puis :

python3 agentrouter-proxy.py

Tu dois voir :

agentrouter-proxy :4182 -> https://agentrouter.org

Laisse ce terminal ouvert. Le proxy doit tourner en permanence tant que tu utilises OpenCode. Si tu le fermes, OpenCode ne pourra plus rien envoyer.

Le port par défaut est 4182. S'il est déjà pris sur ta machine, passe-en un autre en argument :

python3 agentrouter-proxy.py 4300

Pense alors à reporter le même port dans la config de l'étape suivante.

Étape 6 — Configurer OpenCode

Ouvre ton dossier de configuration OpenCode et crée le fichier opencode.json :

{
  "$schema": "https://opencode.ai/config.json",
  "provider": {
    "agentrouter": {
      "name": "AgentRouter",
      "npm": "@ai-sdk/openai-compatible",
      "options": {
        "baseURL": "http://127.0.0.1:4182/v1",
        "apiKey": "TA_CLE_AGENTROUTER"
      },
      "models": {
        "gpt-5.6-sol": { "name": "GPT 5.6 Sol" },
        "claude-opus-5": { "name": "Claude Opus 5" },
        "claude-opus-4-8": { "name": "Claude Opus 4.8" }
      }
    }
  }
}

Remplace TA_CLE_AGENTROUTER par ta vraie clé.

Le point important : baseURL pointe vers http://127.0.0.1:4182/v1, c'est-à-dire ton proxy local — surtout pas vers https://agentrouter.org/v1 directement. Toute la mécanique repose là-dessus.

Relance OpenCode, sélectionne un modèle AgentRouter, et c'est parti. Aucune configuration supplémentaire n'est nécessaire.

Utiliser l'API directement (sans OpenCode)

Le proxy n'est utile que pour contourner les contraintes du SDK d'OpenCode. Si tu écris ton propre code, tu peux taper sur AgentRouter directement.

En curl

curl https://agentrouter.org/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $AGENTROUTER_API_KEY" \
  -d '{
    "model": "claude-opus-5",
    "messages": [
      {"role": "user", "content": "Explique-moi les closures en JavaScript"}
    ]
  }'

En Python

L'API étant compatible OpenAI, le SDK officiel fonctionne tel quel :

import os
from openai import OpenAI

client = OpenAI(
    api_key=os.environ["AGENTROUTER_API_KEY"],
    base_url="https://agentrouter.org/v1",
)

response = client.chat.completions.create(
    model="claude-opus-5",
    messages=[
        {"role": "user", "content": "Explique-moi les closures en JavaScript"}
    ],
)

print(response.choices[0].message.content)

En JavaScript / TypeScript

import OpenAI from "openai";

const client = new OpenAI({
  apiKey: process.env.AGENTROUTER_API_KEY,
  baseURL: "https://agentrouter.org/v1",
});

const response = await client.chat.completions.create({
  model: "gpt-5.6-sol",
  messages: [
    { role: "user", content: "Explique-moi les closures en JavaScript" },
  ],
});

console.log(response.choices[0].message.content);

Pour basculer d'un modèle à l'autre, il suffit de changer la chaîne model. Rien d'autre ne bouge.

Lire les logs du proxy

Le terminal où tourne le proxy est ton meilleur outil de diagnostic. À chaque requête d'OpenCode, tu verras passer :

[ar] POST /v1/chat/completions HTTP/1.1

En cas de problème, le proxy affiche l'erreur réelle renvoyée par AgentRouter :

[ar] ERROR 401: unauthorized client detected

ou

[ar] ERROR 400: expected object, received null

C'est infiniment plus exploitable que le message générique affiché dans OpenCode. Pour aller plus loin, active le mode debug qui dump les requêtes et chaque événement SSE :

AGENTROUTER_DEBUG=1 python3 agentrouter-proxy.py

Résoudre les problèmes courants

401 unauthorized client detected

La requête n'utilise pas le bon User-Agent. Vérifie que le proxy tourne bien, que baseURL vaut http://127.0.0.1:4182/v1, et que tu ne pointes pas OpenCode directement vers https://agentrouter.org/v1.

Si le problème persiste alors que le proxy tourne, il se peut qu'AgentRouter ait durci sa vérification de version. Ajuste la constante USER_AGENT dans le script pour coller à ta version d'OpenCode installée.

JSON parsing failed / Unexpected non-whitespace character after JSON

Flux SSE malformé, presque toujours à cause d'un billing.summary ou d'un data: null. Le proxy filtre ces deux cas — assure-toi que les requêtes passent bien par lui plutôt qu'en direct.

Connection refused sur 127.0.0.1:4182

Le proxy ne tourne pas. Relance-le :

python3 agentrouter-proxy.py

Si le port est occupé par un autre programme, choisis-en un autre et mets à jour baseURL en conséquence.

Aucune réponse de l'IA

Regarde d'abord le terminal du proxy. Si les requêtes arrivent mais qu'aucune réponse ne repart, le souci est en amont : soit chez AgentRouter, soit sur le modèle sélectionné. Teste un autre modèle pour trancher.

Les crédits n'apparaissent pas

Deux causes possibles : ton compte GitHub date de 2025 ou plus récent, ou tu ne t'es pas inscrit via un lien de parrainage. Sans lien d'affiliation, les 175 $ ne sont pas crédités.

Organisation des fichiers

Rien d'imposé, mais voici une structure qui fonctionne bien :

Configuration OpenCode
└── opencode.json

Dossier du proxy
└── agentrouter-proxy.py

Le proxy peut vivre n'importe où sur ton disque. Le seul lien entre les deux, c'est le port déclaré dans baseURL.

En résumé

Cinq minutes de configuration pour 175 $ de crédits sur trois excellents modèles. Le proxy paraît intimidant au premier abord, mais il ne fait que trois choses : réécrire un en-tête, retirer les champs null et filtrer deux types d'événements parasites. Une fois lancé, tu l'oublies.

Un dernier rappel qui sauve des heures de débogage : le proxy doit rester ouvert. La quasi-totalité des « ça marchait hier » vient d'un terminal fermé entre deux sessions.

Tu montes un projet qui a besoin d'agents IA, d'automatisation ou d'une intégration API sur-mesure ? Parlons-en.

Bon code.

> Un projet en tête ?

Site web, shooting photo ou vidéo.
Discutons-en.

Me contacter →
WhatsApp