
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.
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.
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.
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.
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.
std.json.Stringify.valueAllocEncoder 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.
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.
std.http.ClientC'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ément | Rôle |
|---|---|
std.Io.Writer.Allocating | Un 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 = io | En 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_headers | Les en-têtes spéciaux à nous, en plus de ceux que la stdlib ajoute par défaut (User-Agent, Host, etc.). |
catch return .network_error | Toute erreur réseau (DNS, TLS, connexion refusée...) → on remonte une seule variante. Le diagnostic exact est rarement utile à l'utilisateur final. |
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 :
http_status avec le code.bad_response.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.Resultpub 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.
mainconst 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.
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.
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.
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.
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.