fr2026-05-13

Construire un agent de coding en Zig : le premier appel

Hypercode first call illustration

Dans le premier article, on a posé le squelette. Dans le deuxième, on a construit la CLI qui collecte le modèle, la clé API et le prompt. Le binaire affichait fièrement les trois et concluait par (no model call yet — that's Post 03.).

On y est. Cet article remplace ce message d'attente par un vrai appel à OpenRouter. À la fin, on aura un agent qui peut littéralement répondre à un prompt.

Le code reste sur github.com/alexisbchz/hypercode.

Observer la requête OpenRouter

OpenRouter expose une API compatible avec celle d'OpenAI — POST /api/v1/chat/completions avec un Authorization: Bearer <key>. Le corps est un JSON avec deux champs essentiels :

{
  "model": "poolside/laguna-m.1:free",
  "messages": [
    { "role": "user", "content": "Say hello in exactly 5 words." }
  ]
}

La réponse arrive sous cette forme :

{
  "id": "...",
  "model": "...",
  "choices": [
    {
      "message": { "role": "assistant", "content": "Hello, how are you today?" }
    }
  ]
}

Plein d'autres champs accompagnent (usage, finish_reason, créé...), mais pour un premier appel single-turn, on n'a besoin que de choices[0].message.content.

Le plan en deux temps

Pour ne pas mélanger les couches, on isole tout dans un nouveau fichier src/openrouter.zig. Sa responsabilité : étant donnés une clé, un modèle, un prompt, retourner le texte de la réponse. C'est tout. Pas de gestion d'erreur utilisateur, pas de TUI — juste la mécanique du protocole.

main.zig orchestre, comme avant.

Modéliser la requête en Zig

Les structs Zig se sérialisent naturellement en JSON via std.json. Pas besoin d'un quelconque attribut de mapping — les noms de champ deviennent les clés.

src/openrouter.zig
const Message = struct {
    role: []const u8,
    content: []const u8,
};

const Request = struct {
    model: []const u8,
    messages: []const Message,
};

const Response = struct {
    choices: []const struct {
        message: Message,
    },
};

Trois structs, le minimum pour le tour d'aller-retour. Response n'inclut que choices — par défaut std.json refuse les champs inconnus, mais on lui dira .{ .ignore_unknown_fields = true } au moment de parser, et il ignorera silencieusement id, usage, etc.

Encoder, c'est std.json.Stringify.valueAlloc

Encoder un struct vers une chaîne JSON est une ligne :

const payload = try std.json.Stringify.valueAlloc(gpa, req, .{});
defer gpa.free(payload);

valueAlloc alloue le buffer, écrit le JSON dedans, et nous le rend. On libère avec defer. La librairie standard couvre l'échappement des chaînes, les caractères de contrôle, les guillemets — choses qu'on ne veut surtout pas réimplémenter à la main pour un agent qui va recevoir du code utilisateur.

L'en-tête d'authentification

Le header Authorization: Bearer <key> doit être construit dans un buffer dont on contrôle la taille. Allocation statique, conformément à CLAUDE.md §2.4.

var auth_buf: [512]u8 = undefined;
const auth = try std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{api_key});

512 octets est largement suffisant pour un Bearer token (les clés OpenRouter font ~75 caractères). bufPrint retourne un []u8 qui pointe dans auth_buf — donc la durée de vie de auth est celle de la fonction. Aucun heap.

L'appel HTTPS via std.http.Client

C'est ici que Zig 0.16 fait beaucoup de travail pour nous. std.http.Client parle TLS via std.crypto.tls, gère les redirections, le keep-alive, les codes de statut. On lui donne une URL et un payload, il nous rend un status et écrit le corps dans un writer qu'on lui fournit.

var response: std.Io.Writer.Allocating = .init(gpa);
defer response.deinit();

var client: std.http.Client = .{ .allocator = gpa, .io = io };
defer client.deinit();

const fetched = client.fetch(.{
    .location = .{ .url = endpoint },
    .method = .POST,
    .payload = payload,
    .extra_headers = &.{
        .{ .name = "authorization", .value = auth },
        .{ .name = "content-type", .value = "application/json" },
    },
    .response_writer = &response.writer,
}) catch return .network_error;

Quelques détails qui méritent un mot.

ÉlémentRôle
std.Io.Writer.AllocatingUn writer qui grandit son buffer au fur et à mesure. Idéal pour récupérer un corps de réponse de taille inconnue.
client.io = ioEn 0.16, std.http.Client réclame un std.Io. C'est l'abstraction d'I/O qu'on passe à toutes les couches asynchrones. On le tient de init.io dans main.
.extra_headersLes en-têtes spéciaux à nous, en plus de ceux que la stdlib ajoute par défaut (User-Agent, Host, etc.).
catch return .network_errorToute erreur réseau (DNS, TLS, connexion refusée...) → on remonte une seule variante. Le diagnostic exact est rarement utile à l'utilisateur final.

Décoder la réponse

if (fetched.status != .ok) {
    return .{ .http_status = @intFromEnum(fetched.status) };
}

const body = response.writer.buffer[0..response.writer.end];
const parsed = std.json.parseFromSlice(
    Response,
    gpa,
    body,
    .{ .ignore_unknown_fields = true },
) catch return .bad_response;
defer parsed.deinit();

if (parsed.value.choices.len == 0) return .bad_response;
return .{ .ok = try gpa.dupe(u8, parsed.value.choices[0].message.content) };

Trois étapes :

  1. Vérifier le code HTTP. Tout ce qui n'est pas 200 part dans la variante http_status avec le code.
  2. Parser le JSON. Si la forme ne colle pas (parce que OpenRouter renvoie une page HTML d'erreur, par exemple), on rend bad_response.
  3. gpa.dupe(u8, ...) copie le texte dans la mémoire du caller — parce que parsed sera désalloué à la sortie de la fonction. Le caller possède la chaîne et libère quand il a fini.

L'union Result

pub const Result = union(enum) {
    /// Assistant text, owned by the caller's allocator.
    ok: []const u8,
    /// Couldn't reach the server (DNS, TLS, connection reset, ...).
    network_error,
    /// Reached the server, got a non-2xx status.
    http_status: u16,
    /// 2xx but the body didn't look like a chat-completions response.
    bad_response,
};

Quatre variantes. Quatre histoires d'erreur distinctes que le caller peut décider de traiter différemment — afficher un message, retenter, escalader. On suit le même pattern qu'avec cli.Result et config.Result : pas d'erreur opaque, pas de diagnostic perdu.

Brancher dans main

src/main.zig
const gpa = init.gpa;
const result = try openrouter.call(gpa, io, cfg.api_key, cfg.model, cfg.prompt);
switch (result) {
    .ok => |text| {
        defer gpa.free(text);
        try stdout.writeAll(text);
        try stdout.writeAll("\n");
        try stdout.flush();
    },
    .network_error => fail(stderr, "could not reach {s}", .{openrouter.endpoint}),
    .http_status => |code| fail(stderr, "{s} returned HTTP {d}", .{ openrouter.endpoint, code }),
    .bad_response => fail(stderr, "unexpected response shape from {s}", .{openrouter.endpoint}),
}

init.gpa est l'allocateur général que std.process.Init nous fournit. En mode debug il détecte les fuites — pratique pour vérifier que defer gpa.free(text) n'est pas oublié.

Le switch exhaustif sur result rend chaque variante visible. Si on ajoute rate_limited un jour, le compilateur nous force à la traiter ici.

Test en conditions réelles

On a déjà OPENROUTER_API_KEY dans l'environnement et HYPERCODE_MODEL=poolside/laguna-m.1:free — la Poolside via OpenRouter, gratuite pour ce modèle.

./zig-out/bin/hypercode "Say hello in exactly 5 words."
Hello, how are you today?

Premier vrai aller-retour. Le modèle ne respecte pas tout à fait la consigne (cinq mots mais avec une question), mais on s'en fiche — ce qui compte, c'est que la mécanique fonctionne.

Tester les chemins d'erreur

Mauvaise clé :

OPENROUTER_API_KEY=sk-bogus ./zig-out/bin/hypercode "ping"
error: https://openrouter.ai/api/v1/chat/completions returned HTTP 401
Run `hypercode --help` for usage.

exit 2, propre. L'utilisateur sait exactement où chercher.

Pas de réseau (simulable en debranchant le wifi) :

error: could not reach https://openrouter.ai/api/v1/chat/completions
Run `hypercode --help` for usage.

Les commits

Deux commits, deux couches :

45654a6 feat(main): send the prompt to openrouter and print the reply
dd0aaee feat(openrouter): minimal chat-completions client

git show dd0aaee montre la mécanique réseau seule, sans rien savoir de la CLI. git show 45654a6 montre uniquement l'orchestration. La séparation entre couche réseau et couche d'orchestration reste lisible.

Conclusion

On a un agent qui parle. Pas encore qui pense — un seul échange, pas de mémoire entre les appels, pas d'outils, pas de streaming. Mais le tube réseau est posé, et tout le reste s'empile dessus.

Dans le prochain article, on ajoute le streaming. Au lieu d'attendre la réponse complète, on traite les chunks SSE au fur et à mesure et on les écrit sur la sortie standard. C'est ce qui transforme un appel à un modèle en expérience de modèle.

Bloqué ou envie de partager vos notes ? Rejoins le serveur Discord.