summaryrefslogtreecommitdiffstats
path: root/src/llm.h
diff options
context:
space:
mode:
authorEthan Werner <mail@ethanwerner.com>2026-10-10 20:55:33 +0000
committerEthan Werner <mail@ethanwerner.com>2026-10-10 20:56:50 +0000
commitf8423ebea413605b42de23f5dd06c5479d3fa1dd (patch)
tree2612a5eb026c59e7aae01f10a1cf62f2ee43042c /src/llm.h
initial commitHEADmaster
Diffstat (limited to 'src/llm.h')
-rw-r--r--src/llm.h1722
1 files changed, 1722 insertions, 0 deletions
diff --git a/src/llm.h b/src/llm.h
new file mode 100644
index 0000000..f0b68f0
--- /dev/null
+++ b/src/llm.h
@@ -0,0 +1,1722 @@
+/*
+ * llm.h -- header-only LLM provider protocol layer.
+ *
+ * Speaks OpenAI-compatible (any /chat/completions endpoint), Anthropic
+ * (/v1/messages), and Ollama (/api/chat). Pure protocol: request assembly,
+ * HTTP POST with stop-cancellation, SSE / NDJSON streaming with live delta
+ * emission, response and error-body parsing, Retry-After handling. No
+ * application state: messages arrive as borrowed `llm_message` arrays, tools
+ * as a pre-built `"tools":[...]` JSON fragment, and tool execution, session
+ * access, and emit formatting are supplied by the caller.
+ *
+ * Header-only: every symbol is `static inline`, so a translation unit that
+ * `#include`s this file gets a self-contained copy and there is nothing to
+ * link. Include it from exactly the TUs that drive a provider. Each TU's
+ * copies are independent.
+ *
+ * Dependencies: curl, cJSON, libc.
+ *
+ * API:
+ * llm_url / llm_auth / llm_build_request request side
+ * llm_post / llm_post_stream transport
+ * llm_parse_body / llm_parse_error /
+ * llm_header_retry_after response side
+ * llm_sse_acc_* / llm_sse_parse_line streaming accumulator
+ * llm_completion / llm_completion_free parsed result
+ */
+#ifndef LLM_H
+#define LLM_H
+
+#include <curl/curl.h>
+#include <cjson/cJSON.h>
+#include <ctype.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#ifdef __cplusplus
+#include <atomic>
+#endif
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* ------------------------------------------------------------------ */
+/* Provider + connection */
+/* ------------------------------------------------------------------ */
+
+typedef enum {
+ LLM_OPENAI = 0,
+ LLM_ANTHROPIC,
+ LLM_OLLAMA,
+} llm_provider;
+
+/* Default cap on generated tokens for Anthropic requests, which require an
+ * explicit `max_tokens`. */
+#define LLM_DEFAULT_MAX_TOKENS 8192UL
+
+/* Total wall-clock budget for a single (possibly streaming) LLM request.
+ * Generous on purpose: long generations and slow cold-start servers. */
+#define LLM_REQUEST_TIMEOUT_SECS 600L
+
+/* One provider connection. Strings are borrowed for the call's lifetime.
+ * base_url NULL = provider default; api_key NULL = unauthenticated;
+ * ca_bundle NULL = curl's default CA path. */
+typedef struct {
+ llm_provider provider;
+ const char *model;
+ const char *base_url;
+ const char *api_key;
+ const char *ca_bundle;
+} llm_conn;
+
+/* Optional generation parameters. Every field has an "unset" value, so a
+ * zeroed struct (or NULL) produces a plain request:
+ * temperature < 0 = unset
+ * max_tokens 0 = unset
+ * reasoning_effort NULL = unset ("low" / "medium" / "high")
+ * thinking_budget 0 = unset (tokens; Anthropic extended thinking)
+ * response_schema NULL = unset (JSON Schema text, caller-validated) */
+typedef struct {
+ double temperature;
+ unsigned long max_tokens;
+ const char *reasoning_effort;
+ unsigned long thinking_budget;
+ const char *response_schema;
+} llm_params;
+
+static inline void llm_params_init(llm_params *p) {
+ memset(p, 0, sizeof *p);
+ p->temperature = -1.0;
+}
+
+/* Anthropic extended-thinking budget for a reasoning-effort label. */
+#define LLM_EFFORT_BUDGET_LOW 1024UL
+#define LLM_EFFORT_BUDGET_MEDIUM 4096UL
+#define LLM_EFFORT_BUDGET_HIGH 16384UL
+
+/* ------------------------------------------------------------------ */
+/* Wire message model (borrowed strings) */
+/* ------------------------------------------------------------------ */
+
+typedef enum {
+ LLM_ROLE_SYSTEM,
+ LLM_ROLE_USER,
+ LLM_ROLE_ASSISTANT,
+} llm_role;
+
+typedef enum {
+ LLM_TEXT, /* user/assistant text */
+ LLM_REASONING, /* assistant chain of thought (display only, not re-sent) */
+ LLM_TOOL_CALL, /* assistant: name, args (JSON text) */
+ LLM_TOOL_RESULT, /* user: call (tool-call id), name, text */
+} llm_part_kind;
+
+typedef struct {
+ llm_part_kind kind;
+ const char *text; /* LLM_TEXT / LLM_REASONING / LLM_TOOL_RESULT */
+ const char *name; /* LLM_TOOL_CALL / LLM_TOOL_RESULT */
+ const char *args; /* LLM_TOOL_CALL: JSON text */
+ const char *call; /* LLM_TOOL_RESULT: tool-call id (may be NULL) */
+} llm_part;
+
+typedef struct {
+ llm_role role;
+ llm_part *parts; /* borrowed; const from the consumer's view */
+ size_t part_count;
+} llm_message;
+
+/* Growable byte buffer (NUL-terminated on steal). */
+typedef struct llm_buf {
+ char *data;
+ size_t len;
+ size_t cap;
+} llm_buf;
+
+/* ------------------------------------------------------------------ */
+/* Parsed completion (owned strings) */
+/* ------------------------------------------------------------------ */
+
+typedef struct {
+ char *text;
+ char *reasoning; /* thinking / reasoning_content, when the model returns it */
+ char **tc_id;
+ char **tc_name;
+ char **tc_args;
+ size_t tc_len;
+ unsigned long input_tokens;
+} llm_completion;
+
+/* Accumulates one streamed response. Text/reasoning arrive as deltas;
+ * tool calls may arrive in indexed chunks (OpenAI) or as whole blocks
+ * (Anthropic) or complete objects (Ollama NDJSON). */
+typedef struct {
+ struct llm_buf text;
+ struct llm_buf reasoning;
+ char **tc_id;
+ char **tc_name;
+ struct llm_buf *tc_args; /* growing argument fragments, one per call */
+ size_t tc_len;
+ unsigned long input_tokens;
+ bool done;
+ /* Anthropic event state: the `event:` line that the next `data:`
+ * belongs to, and the kind of the open content block. */
+ char *anth_event;
+ int anth_mode; /* 0 = text, 1 = thinking, 2 = tool */
+ size_t anth_tc;
+} llm_sse_acc;
+
+/* Live-delta emitter for streaming. Return 0 to continue, non-zero to
+ * abort the transfer (a user stop). */
+typedef enum {
+ LLM_EMIT_TEXT = 0,
+ LLM_EMIT_REASONING,
+} llm_emit_kind;
+
+typedef int (*llm_emit_cb)(llm_provider prov, llm_emit_kind kind,
+ const char *delta, void *user);
+
+/* ================================================================== */
+/* Implementation (static inline; active in every including TU) */
+/* ================================================================== */
+
+/* ------------------------------------------------------------------ */
+/* Allocation + small string utilities (self-contained) */
+/* ------------------------------------------------------------------ */
+
+static inline void *llm_xmalloc(size_t n) {
+ void *p = malloc(n ? n : 1);
+ if (!p) {
+ fprintf(stderr, "llm: out of memory\n");
+ abort();
+ }
+ return p;
+}
+
+static inline void *llm_xrealloc(void *p, size_t n) {
+ void *q = realloc(p, n ? n : 1);
+ if (!q) {
+ fprintf(stderr, "llm: out of memory\n");
+ abort();
+ }
+ return q;
+}
+
+static inline char *llm_strdup(const char *s) {
+ size_t n = s ? strlen(s) : 0;
+ char *p = (char *)llm_xmalloc(n + 1);
+ if (s)
+ memcpy(p, s, n);
+ p[n] = '\0';
+ return p;
+}
+
+static inline char *llm_strndup(const char *s, size_t n) {
+ char *p = (char *)llm_xmalloc(n + 1);
+ memcpy(p, s, n);
+ p[n] = '\0';
+ return p;
+}
+
+static inline char *llm_vsnprintf(const char *fmt, va_list ap) {
+ va_list ap2;
+ va_copy(ap2, ap);
+ int n = vsnprintf(NULL, 0, fmt, ap2);
+ va_end(ap2);
+ if (n < 0)
+ return llm_strdup("");
+ char *p = (char *)llm_xmalloc((size_t)n + 1);
+ vsnprintf(p, (size_t)n + 1, fmt, ap);
+ return p;
+}
+
+static inline char *llm_strfmt(const char *fmt, ...) {
+ va_list ap;
+ va_start(ap, fmt);
+ char *s = llm_vsnprintf(fmt, ap);
+ va_end(ap);
+ return s;
+}
+
+static inline bool llm_streq(const char *a, const char *b) {
+ return a && b && strcmp(a, b) == 0;
+}
+
+/* First case-insensitive occurrence of `needle` in `hay` (portable
+ * strcasestr). */
+static inline const char *llm_strcasestr(const char *hay, const char *needle) {
+ size_t nn = strlen(needle);
+ if (nn == 0)
+ return hay;
+ for (const char *p = hay; *p; p++) {
+ bool ok = true;
+ for (size_t i = 0; i < nn; i++) {
+ if (tolower((unsigned char)p[i]) !=
+ tolower((unsigned char)needle[i])) {
+ ok = false;
+ break;
+ }
+ }
+ if (ok)
+ return p;
+ }
+ return NULL;
+}
+
+static inline void llm_buf_init(llm_buf *b) {
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+}
+
+static inline void llm_buf_free(llm_buf *b) {
+ free(b->data);
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+}
+
+static inline void llm_buf_reserve(llm_buf *b, size_t extra) {
+ if (b->len + extra + 1 > b->cap) {
+ size_t cap = b->cap ? b->cap : 64;
+ while (cap < b->len + extra + 1)
+ cap *= 2;
+ b->data = (char *)llm_xrealloc(b->data, cap);
+ b->cap = cap;
+ }
+}
+
+static inline void llm_buf_putn(llm_buf *b, const char *s, size_t n) {
+ llm_buf_reserve(b, n);
+ memcpy(b->data + b->len, s, n);
+ b->len += n;
+ b->data[b->len] = '\0';
+}
+
+static inline void llm_buf_put(llm_buf *b, const char *s) {
+ if (s)
+ llm_buf_putn(b, s, strlen(s));
+}
+
+static inline void llm_buf_push(llm_buf *b, char c) {
+ llm_buf_reserve(b, 1);
+ b->data[b->len++] = c;
+ b->data[b->len] = '\0';
+}
+
+static inline char *llm_buf_steal(llm_buf *b) {
+ if (!b->data)
+ b->data = (char *)llm_xmalloc(1);
+ b->data[b->len] = '\0';
+ char *s = b->data;
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+ return s;
+}
+
+/* Small owned string list. */
+typedef struct {
+ char **items;
+ size_t len;
+ size_t cap;
+} llm_strlist;
+
+static inline void llm_sl_init(llm_strlist *l) {
+ l->items = NULL;
+ l->len = 0;
+ l->cap = 0;
+}
+
+static inline void llm_sl_free(llm_strlist *l) {
+ for (size_t i = 0; i < l->len; i++)
+ free(l->items[i]);
+ free(l->items);
+ llm_sl_init(l);
+}
+
+static inline void llm_sl_push_copy(llm_strlist *l, const char *s) {
+ if (l->len + 1 > l->cap) {
+ l->cap = l->cap ? l->cap * 2 : 8;
+ l->items =
+ (char **)llm_xrealloc(l->items, l->cap * sizeof(char *));
+ }
+ l->items[l->len++] = llm_strdup(s);
+}
+
+/* ------------------------------------------------------------------ */
+/* Request side */
+/* ------------------------------------------------------------------ */
+
+static inline char *llm_url(const llm_conn *c) {
+ const char *def;
+ switch (c->provider) {
+ case LLM_ANTHROPIC:
+ def = "https://api.anthropic.com";
+ break;
+ case LLM_OLLAMA:
+ def = "http://localhost:11434";
+ break;
+ case LLM_OPENAI:
+ default:
+ def = "https://api.openai.com/v1";
+ break;
+ }
+ char *base = llm_strdup(c->base_url ? c->base_url : def);
+ size_t n = strlen(base);
+ while (n && base[n - 1] == '/')
+ base[--n] = '\0';
+ llm_buf u;
+ llm_buf_init(&u);
+ llm_buf_put(&u, base);
+ free(base);
+ switch (c->provider) {
+ case LLM_ANTHROPIC:
+ llm_buf_put(&u, "/v1/messages");
+ break;
+ case LLM_OLLAMA:
+ llm_buf_put(&u, "/api/chat");
+ break;
+ case LLM_OPENAI:
+ default:
+ llm_buf_put(&u, "/chat/completions");
+ break;
+ }
+ char *out = llm_buf_steal(&u);
+ llm_buf_free(&u);
+ return out;
+}
+
+static inline void llm_auth(const llm_conn *c, char **header, char **value) {
+ if (header)
+ *header = NULL;
+ if (value)
+ *value = NULL;
+ if (!c->api_key)
+ return;
+ switch (c->provider) {
+ case LLM_OPENAI:
+ if (header)
+ *header = llm_strdup("Authorization");
+ if (value)
+ *value = llm_strfmt("Bearer %s", c->api_key);
+ break;
+ case LLM_ANTHROPIC:
+ if (header)
+ *header = llm_strdup("x-api-key");
+ if (value)
+ *value = llm_strdup(c->api_key);
+ break;
+ case LLM_OLLAMA:
+ break;
+ }
+}
+
+/* Shared setup: cached-or-throwaway curl handle, the JSON + auth header
+ * list, and the common POST options. Returns the handle (or NULL with
+ * *err set) and the header list in *out_h (the caller frees it with
+ * curl_slist_free_all). */
+static inline CURL *llm_post_setup(const llm_conn *c, const char *url,
+ const char *body,
+ struct curl_slist **out_h, char **err,
+ CURL **cached) {
+ CURL *h = cached ? *cached : NULL;
+ if (!h) {
+ h = curl_easy_init();
+ if (h && cached)
+ *cached = h;
+ }
+ if (!h) {
+ if (err)
+ *err = llm_strdup("could not initialize curl");
+ return NULL;
+ }
+ struct curl_slist *hl = NULL;
+ hl = curl_slist_append(hl, "Content-Type: application/json");
+ char *ah = NULL, *av = NULL;
+ llm_auth(c, &ah, &av);
+ if (ah && av) {
+ llm_buf hb;
+ llm_buf_init(&hb);
+ char *combo = llm_strfmt("%s: %s", ah, av);
+ llm_buf_put(&hb, combo);
+ free(combo);
+ hl = curl_slist_append(hl, hb.data);
+ llm_buf_free(&hb);
+ free(ah);
+ free(av);
+ }
+ curl_easy_setopt(h, CURLOPT_HTTPHEADER, hl);
+ curl_easy_setopt(h, CURLOPT_URL, url);
+ curl_easy_setopt(h, CURLOPT_POST, 1L);
+ curl_easy_setopt(h, CURLOPT_POSTFIELDS, body);
+ curl_easy_setopt(h, CURLOPT_POSTFIELDSIZE, (long)strlen(body));
+ curl_easy_setopt(h, CURLOPT_TIMEOUT, LLM_REQUEST_TIMEOUT_SECS);
+ curl_easy_setopt(h, CURLOPT_NOSIGNAL, 1L);
+ if (c->ca_bundle)
+ curl_easy_setopt(h, CURLOPT_CAINFO, c->ca_bundle);
+ *out_h = hl;
+ return h;
+}
+
+typedef struct {
+ llm_buf body;
+ llm_buf headers;
+ std::atomic<bool> *stop;
+ bool response_too_large;
+} llm_post_ctx;
+
+static inline size_t llm_post_write(char *d, size_t s, size_t n, void *ud) {
+ llm_post_ctx *c = (llm_post_ctx *)ud;
+ const size_t cap = 8u * 1024u * 1024u;
+ if ((s && n > SIZE_MAX / s) || c->body.len > cap ||
+ s * n > cap - c->body.len) {
+ c->response_too_large = true;
+ return 0;
+ }
+ llm_buf_putn(&c->body, d, s * n);
+ return s * n;
+}
+
+static inline size_t llm_post_header(char *d, size_t s, size_t n, void *ud) {
+ llm_post_ctx *c = (llm_post_ctx *)ud;
+ llm_buf_putn(&c->headers, d, s * n);
+ return s * n;
+}
+
+static inline int llm_post_xfer(void *clientp, curl_off_t t, curl_off_t dn,
+ curl_off_t ut, curl_off_t un) {
+ (void)t;
+ (void)dn;
+ (void)ut;
+ (void)un;
+ llm_post_ctx *c = (llm_post_ctx *)clientp;
+ if (c->stop && c->stop->load())
+ return 1; /* abort the transfer */
+ return 0;
+}
+
+static inline int llm_post(const llm_conn *c, const char *url,
+ const char *body, long *status_out, char **body_out,
+ char **headers_out, char **err,
+ std::atomic<bool> *stop, CURL **cached) {
+ if (body_out)
+ *body_out = NULL;
+ if (headers_out)
+ *headers_out = NULL;
+ if (err)
+ *err = NULL;
+
+ llm_post_ctx ctx;
+ llm_buf_init(&ctx.body);
+ llm_buf_init(&ctx.headers);
+ ctx.stop = stop;
+ ctx.response_too_large = false;
+
+ struct curl_slist *h = NULL;
+ CURL *curl =
+ llm_post_setup(c, url, body, &h, err, cached);
+ if (!curl) {
+ llm_buf_free(&ctx.body);
+ llm_buf_free(&ctx.headers);
+ return -1;
+ }
+ curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, llm_post_write);
+ curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx.body);
+ curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, llm_post_header);
+ curl_easy_setopt(curl, CURLOPT_HEADERDATA, &ctx);
+ curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
+ curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, llm_post_xfer);
+ curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &ctx);
+
+ CURLcode rc = curl_easy_perform(curl);
+ long code = 0;
+ curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code);
+ curl_slist_free_all(h);
+ char *body_str = llm_buf_steal(&ctx.body);
+ char *hdr_str = llm_buf_steal(&ctx.headers);
+ llm_buf_free(&ctx.body);
+ llm_buf_free(&ctx.headers);
+ if (!cached || *cached != curl)
+ curl_easy_cleanup(curl); /* throwaway handle; the cached one outlives us */
+
+ if (rc != CURLE_OK) {
+ if (err)
+ *err = llm_strdup(ctx.response_too_large
+ ? "LLM response exceeds 8 MiB limit"
+ : curl_easy_strerror(rc));
+ free(body_str);
+ free(hdr_str);
+ return -1;
+ }
+ if (status_out)
+ *status_out = code;
+ if (body_out)
+ *body_out = body_str;
+ else
+ free(body_str);
+ if (headers_out)
+ *headers_out = hdr_str;
+ else
+ free(hdr_str);
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* Wire messages -> provider JSON */
+/* ------------------------------------------------------------------ */
+
+/* Recover the tool-call ids in order (they live on the tool results). */
+static inline void llm_collect_result_ids(const llm_message *msgs,
+ size_t n, llm_strlist *out) {
+ llm_sl_init(out);
+ for (size_t i = 0; i < n; i++) {
+ const llm_message *m = &msgs[i];
+ if (m->role != LLM_ROLE_USER)
+ continue;
+ for (size_t j = 0; j < m->part_count; j++) {
+ const llm_part *p = &m->parts[j];
+ if (p->kind == LLM_TOOL_RESULT)
+ llm_sl_push_copy(out, p->call ? p->call : "");
+ }
+ }
+}
+
+static inline char *llm_id_or(const llm_strlist *ids, size_t i) {
+ if (i < ids->len && ids->items[i] && ids->items[i][0])
+ return llm_strdup(ids->items[i]);
+ return llm_strfmt("call-%d", (int)(i + 1));
+}
+
+static inline cJSON *llm_openai_messages(const llm_message *msgs, size_t n) {
+ cJSON *arr = cJSON_CreateArray();
+ llm_strlist ids;
+ llm_collect_result_ids(msgs, n, &ids);
+ size_t call_idx = 0, result_idx = 0;
+ for (size_t i = 0; i < n; i++) {
+ const llm_message *m = &msgs[i];
+ if (m->role == LLM_ROLE_SYSTEM) {
+ const char *text = "";
+ if (m->part_count && m->parts[0].text)
+ text = m->parts[0].text;
+ cJSON *o = cJSON_CreateObject();
+ cJSON_AddStringToObject(o, "role", "system");
+ cJSON_AddStringToObject(o, "content", text);
+ cJSON_AddItemToArray(arr, o);
+ } else if (m->role == LLM_ROLE_USER) {
+ for (size_t j = 0; j < m->part_count; j++) {
+ const llm_part *p = &m->parts[j];
+ if (p->kind == LLM_TEXT) {
+ cJSON *o = cJSON_CreateObject();
+ cJSON_AddStringToObject(o, "role", "user");
+ cJSON_AddStringToObject(o, "content",
+ p->text ? p->text : "");
+ cJSON_AddItemToArray(arr, o);
+ } else if (p->kind == LLM_TOOL_RESULT) {
+ cJSON *o = cJSON_CreateObject();
+ cJSON_AddStringToObject(o, "role", "tool");
+ char *id = llm_id_or(&ids, result_idx);
+ result_idx++;
+ cJSON_AddStringToObject(o, "tool_call_id", id);
+ free(id);
+ if (p->name)
+ cJSON_AddStringToObject(o, "name", p->name);
+ cJSON_AddStringToObject(o, "content",
+ p->text ? p->text : "");
+ cJSON_AddItemToArray(arr, o);
+ }
+ }
+ } else { /* assistant */
+ llm_buf txt;
+ llm_buf_init(&txt);
+ bool first = true;
+ cJSON *tcs = NULL;
+ for (size_t j = 0; j < m->part_count; j++) {
+ const llm_part *p = &m->parts[j];
+ if (p->kind == LLM_TEXT) {
+ if (!first)
+ llm_buf_push(&txt, '\n');
+ if (p->text)
+ llm_buf_put(&txt, p->text);
+ first = false;
+ } else if (p->kind == LLM_TOOL_CALL) {
+ if (!tcs)
+ tcs = cJSON_CreateArray();
+ cJSON *tcobj = cJSON_CreateObject();
+ char *id = llm_id_or(&ids, call_idx);
+ cJSON_AddStringToObject(tcobj, "id", id);
+ free(id);
+ cJSON_AddStringToObject(tcobj, "type", "function");
+ cJSON *fn = cJSON_AddObjectToObject(tcobj, "function");
+ cJSON_AddStringToObject(fn, "name", p->name ? p->name : "");
+ cJSON_AddStringToObject(fn, "arguments", p->args ? p->args : "{}");
+ cJSON_AddItemToArray(tcs, tcobj);
+ call_idx++;
+ }
+ }
+ char *t = llm_buf_steal(&txt);
+ llm_buf_free(&txt);
+ if (t[0] || tcs) {
+ cJSON *o = cJSON_CreateObject();
+ cJSON_AddStringToObject(o, "role", "assistant");
+ if (t[0])
+ cJSON_AddStringToObject(o, "content", t);
+ else
+ cJSON_AddItemToObject(o, "content", cJSON_CreateNull());
+ if (tcs)
+ cJSON_AddItemToObject(o, "tool_calls", tcs);
+ cJSON_AddItemToArray(arr, o);
+ }
+ free(t);
+ }
+ }
+ llm_sl_free(&ids);
+ return arr;
+}
+
+static inline cJSON *llm_anthropic_messages(const llm_message *msgs,
+ size_t n) {
+ cJSON *arr = cJSON_CreateArray();
+ llm_strlist ids;
+ llm_collect_result_ids(msgs, n, &ids);
+ size_t call_idx = 0, result_idx = 0;
+ for (size_t i = 0; i < n; i++) {
+ const llm_message *m = &msgs[i];
+ if (m->role == LLM_ROLE_SYSTEM)
+ continue; /* folded into the top-level `system` */
+ cJSON *o = cJSON_CreateObject();
+ cJSON *content = cJSON_CreateArray();
+ if (m->role == LLM_ROLE_USER) {
+ cJSON_AddStringToObject(o, "role", "user");
+ for (size_t j = 0; j < m->part_count; j++) {
+ const llm_part *p = &m->parts[j];
+ if (p->kind == LLM_TEXT) {
+ cJSON *b = cJSON_CreateObject();
+ cJSON_AddStringToObject(b, "type", "text");
+ cJSON_AddStringToObject(b, "text", p->text ? p->text : "");
+ cJSON_AddItemToArray(content, b);
+ } else if (p->kind == LLM_TOOL_RESULT) {
+ cJSON *b = cJSON_CreateObject();
+ cJSON_AddStringToObject(b, "type", "tool_result");
+ char *id = llm_id_or(&ids, result_idx);
+ result_idx++;
+ cJSON_AddStringToObject(b, "tool_use_id", id);
+ free(id);
+ cJSON_AddStringToObject(b, "content", p->text ? p->text : "");
+ cJSON_AddItemToArray(content, b);
+ }
+ }
+ } else { /* assistant */
+ cJSON_AddStringToObject(o, "role", "assistant");
+ for (size_t j = 0; j < m->part_count; j++) {
+ const llm_part *p = &m->parts[j];
+ if (p->kind == LLM_TEXT) {
+ cJSON *b = cJSON_CreateObject();
+ cJSON_AddStringToObject(b, "type", "text");
+ cJSON_AddStringToObject(b, "text", p->text ? p->text : "");
+ cJSON_AddItemToArray(content, b);
+ } else if (p->kind == LLM_TOOL_CALL) {
+ cJSON *b = cJSON_CreateObject();
+ cJSON_AddStringToObject(b, "type", "tool_use");
+ char *id = llm_id_or(&ids, call_idx);
+ cJSON_AddStringToObject(b, "id", id);
+ free(id);
+ cJSON_AddStringToObject(b, "name", p->name ? p->name : "");
+ cJSON *inp = p->args ? cJSON_Parse(p->args) : NULL;
+ if (!inp)
+ inp = cJSON_CreateObject();
+ cJSON_AddItemToObject(b, "input", inp);
+ cJSON_AddItemToArray(content, b);
+ call_idx++;
+ }
+ }
+ }
+ cJSON_AddItemToObject(o, "content", content);
+ cJSON_AddItemToArray(arr, o);
+ }
+ llm_sl_free(&ids);
+ return arr;
+}
+
+/* ------------------------------------------------------------------ */
+/* Response parsing */
+/* ------------------------------------------------------------------ */
+
+static inline void llm_completion_free(llm_completion *c) {
+ free(c->text);
+ free(c->reasoning);
+ for (size_t i = 0; i < c->tc_len; i++) {
+ free(c->tc_id[i]);
+ free(c->tc_name[i]);
+ free(c->tc_args[i]);
+ }
+ free(c->tc_id);
+ free(c->tc_name);
+ free(c->tc_args);
+ c->text = NULL;
+ c->reasoning = NULL;
+ c->tc_id = c->tc_name = c->tc_args = NULL;
+ c->tc_len = 0;
+ c->input_tokens = 0;
+}
+
+static inline void llm_add_tool_call(llm_completion *c, char *id, char *name,
+ char *args) {
+ c->tc_id = (char **)llm_xrealloc(c->tc_id, (c->tc_len + 1) * sizeof *c->tc_id);
+ c->tc_name = (char **)llm_xrealloc(c->tc_name, (c->tc_len + 1) * sizeof *c->tc_name);
+ c->tc_args = (char **)llm_xrealloc(c->tc_args, (c->tc_len + 1) * sizeof *c->tc_args);
+ c->tc_id[c->tc_len] = id;
+ c->tc_name[c->tc_len] = name;
+ c->tc_args[c->tc_len] = args;
+ c->tc_len++;
+}
+
+static inline void llm_parse_openai(const cJSON *resp, llm_completion *c) {
+ const cJSON *choices = cJSON_GetObjectItem(resp, "choices");
+ if (cJSON_IsArray(choices) && choices->child) {
+ const cJSON *choice = choices->child;
+ const cJSON *msg = cJSON_GetObjectItem(choice, "message");
+ if (msg) {
+ const cJSON *ct = cJSON_GetObjectItem(msg, "content");
+ if (cJSON_IsString(ct) && ct->valuestring)
+ c->text = llm_strdup(ct->valuestring);
+ /* Reasoning models (o1/o3 and compatibles) return the chain of
+ * thought in `reasoning_content`; some servers use `reasoning`. */
+ const cJSON *rc = cJSON_GetObjectItem(msg, "reasoning_content");
+ if (!cJSON_IsString(rc) || !rc->valuestring)
+ rc = cJSON_GetObjectItem(msg, "reasoning");
+ if (cJSON_IsString(rc) && rc->valuestring && rc->valuestring[0])
+ c->reasoning = llm_strdup(rc->valuestring);
+ const cJSON *tcs = cJSON_GetObjectItem(msg, "tool_calls");
+ if (cJSON_IsArray(tcs)) {
+ const cJSON *tc = NULL;
+ cJSON_ArrayForEach(tc, tcs) {
+ const cJSON *fn = cJSON_GetObjectItem(tc, "function");
+ char *id = NULL, *name = NULL, *args = NULL;
+ const cJSON *cid = cJSON_GetObjectItem(tc, "id");
+ if (cJSON_IsString(cid) && cid->valuestring)
+ id = llm_strdup(cid->valuestring);
+ if (fn) {
+ const cJSON *cn = cJSON_GetObjectItem(fn, "name");
+ if (cJSON_IsString(cn) && cn->valuestring)
+ name = llm_strdup(cn->valuestring);
+ const cJSON *ca = cJSON_GetObjectItem(fn, "arguments");
+ if (cJSON_IsString(ca) && ca->valuestring)
+ args = llm_strdup(ca->valuestring);
+ else if (cJSON_IsObject(ca)) {
+ char *s = cJSON_PrintUnformatted(ca);
+ args = s ? s : llm_strdup("{}");
+ }
+ }
+ llm_add_tool_call(
+ c, id ? id : llm_strfmt("call-%d", (int)(c->tc_len + 1)),
+ name ? name : llm_strdup(""),
+ args ? args : llm_strdup("{}"));
+ }
+ }
+ }
+ }
+ const cJSON *usage = cJSON_GetObjectItem(resp, "usage");
+ if (usage) {
+ const cJSON *pt = cJSON_GetObjectItem(usage, "prompt_tokens");
+ if (cJSON_IsNumber(pt))
+ c->input_tokens = (unsigned long)pt->valuedouble;
+ }
+}
+
+static inline void llm_parse_anthropic(const cJSON *resp, llm_completion *c) {
+ const cJSON *content = cJSON_GetObjectItem(resp, "content");
+ if (cJSON_IsArray(content)) {
+ const cJSON *b = NULL;
+ cJSON_ArrayForEach(b, content) {
+ const cJSON *type = cJSON_GetObjectItem(b, "type");
+ const char *t = (cJSON_IsString(type) && type->valuestring)
+ ? type->valuestring
+ : "";
+ if (llm_streq(t, "text")) {
+ const cJSON *txt = cJSON_GetObjectItem(b, "text");
+ if (cJSON_IsString(txt) && txt->valuestring) {
+ free(c->text);
+ c->text = llm_strdup(txt->valuestring);
+ }
+ } else if (llm_streq(t, "thinking")) {
+ const cJSON *th = cJSON_GetObjectItem(b, "thinking");
+ if (cJSON_IsString(th) && th->valuestring && th->valuestring[0]) {
+ free(c->reasoning);
+ c->reasoning = llm_strdup(th->valuestring);
+ }
+ } else if (llm_streq(t, "tool_use")) {
+ char *id = NULL, *name = NULL, *args = NULL;
+ const cJSON *tid = cJSON_GetObjectItem(b, "id");
+ if (cJSON_IsString(tid) && tid->valuestring)
+ id = llm_strdup(tid->valuestring);
+ const cJSON *tn = cJSON_GetObjectItem(b, "name");
+ if (cJSON_IsString(tn) && tn->valuestring)
+ name = llm_strdup(tn->valuestring);
+ const cJSON *tin = cJSON_GetObjectItem(b, "input");
+ if (cJSON_IsObject(tin)) {
+ char *s = cJSON_PrintUnformatted(tin);
+ args = s ? s : llm_strdup("{}");
+ }
+ llm_add_tool_call(
+ c, id ? id : llm_strfmt("call-%d", (int)(c->tc_len + 1)),
+ name ? name : llm_strdup(""),
+ args ? args : llm_strdup("{}"));
+ }
+ }
+ }
+ const cJSON *usage = cJSON_GetObjectItem(resp, "usage");
+ if (usage) {
+ const cJSON *it = cJSON_GetObjectItem(usage, "input_tokens");
+ if (cJSON_IsNumber(it))
+ c->input_tokens = (unsigned long)it->valuedouble;
+ }
+}
+
+static inline void llm_parse_ollama(const cJSON *resp, llm_completion *c) {
+ const cJSON *msg = cJSON_GetObjectItem(resp, "message");
+ if (msg) {
+ const cJSON *ct = cJSON_GetObjectItem(msg, "content");
+ if (cJSON_IsString(ct) && ct->valuestring)
+ c->text = llm_strdup(ct->valuestring);
+ /* Thinking models (qwen3, deepseek-r1, …) return `message.thinking`. */
+ const cJSON *th = cJSON_GetObjectItem(msg, "thinking");
+ if (cJSON_IsString(th) && th->valuestring && th->valuestring[0])
+ c->reasoning = llm_strdup(th->valuestring);
+ const cJSON *tcs = cJSON_GetObjectItem(msg, "tool_calls");
+ if (cJSON_IsArray(tcs)) {
+ const cJSON *tc = NULL;
+ cJSON_ArrayForEach(tc, tcs) {
+ const cJSON *fn = cJSON_GetObjectItem(tc, "function");
+ const cJSON *id = cJSON_GetObjectItem(tc, "id");
+ char *cid = (cJSON_IsString(id) && id->valuestring)
+ ? llm_strdup(id->valuestring)
+ : llm_strfmt("call-%d", (int)(c->tc_len + 1));
+ char *name = llm_strdup(""), *args = llm_strdup("{}");
+ if (fn) {
+ const cJSON *cn = cJSON_GetObjectItem(fn, "name");
+ if (cJSON_IsString(cn) && cn->valuestring) {
+ free(name);
+ name = llm_strdup(cn->valuestring);
+ }
+ const cJSON *ca = cJSON_GetObjectItem(fn, "arguments");
+ if (cJSON_IsString(ca) && ca->valuestring) {
+ free(args);
+ args = llm_strdup(ca->valuestring);
+ } else if (cJSON_IsObject(ca)) {
+ char *s = cJSON_PrintUnformatted(ca);
+ if (s) {
+ free(args);
+ args = s;
+ }
+ }
+ }
+ llm_add_tool_call(c, cid, name, args);
+ }
+ }
+ }
+ const cJSON *pe = cJSON_GetObjectItem(resp, "prompt_eval_count");
+ if (cJSON_IsNumber(pe))
+ c->input_tokens = (unsigned long)pe->valuedouble;
+}
+
+static inline int llm_parse_body(const llm_conn *c, const char *body,
+ llm_completion *out) {
+ if (!body || !body[0])
+ return -1;
+ cJSON *resp = cJSON_Parse(body);
+ if (!resp)
+ return -1;
+ if (c->provider == LLM_ANTHROPIC)
+ llm_parse_anthropic(resp, out);
+ else if (c->provider == LLM_OLLAMA)
+ llm_parse_ollama(resp, out);
+ else
+ llm_parse_openai(resp, out);
+ cJSON_Delete(resp);
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* Error body + Retry-After */
+/* ------------------------------------------------------------------ */
+
+static inline char *llm_parse_error(const char *body, long status) {
+ char *msg = NULL;
+ if (body && body[0]) {
+ cJSON *j = cJSON_Parse(body);
+ if (j) {
+ const cJSON *e = cJSON_GetObjectItem(j, "error");
+ const cJSON *em = cJSON_IsObject(e)
+ ? cJSON_GetObjectItem(e, "message")
+ : (cJSON_IsString(e) ? e : NULL);
+ if (cJSON_IsString(em) && em->valuestring)
+ msg = llm_strdup(em->valuestring);
+ if (!msg)
+ msg = llm_strdup("");
+ cJSON_Delete(j);
+ } else {
+ size_t cap = strlen(body);
+ if (cap > 500)
+ cap = 500;
+ msg = llm_strndup(body, cap);
+ }
+ }
+ if (!msg || !msg[0])
+ msg = llm_strfmt("HTTP %ld", status);
+ return msg;
+}
+
+static inline unsigned long llm_retry_after(const char *val) {
+ if (!val)
+ return 0;
+ char *end = NULL;
+ long v = strtol(val, &end, 10);
+ if (end != val && v >= 0 && v <= 300)
+ return (unsigned long)v;
+ /* HTTP-date (RFC 7231 §7.1.1.1): a GMT timestamp. Take the delta from
+ * now, clamped to the same [0, 300]s window as delta-seconds. */
+ struct tm t;
+ memset(&t, 0, sizeof t);
+ static const char *const fmts[] = {
+ "%a, %d %b %Y %H:%M:%S GMT", /* IMF-fixdate */
+ "%a, %d %b %Y %H:%M:%S %Z", /* tolerant tz name */
+ "%a %b %d %H:%M:%S %Y", /* obs-date / asctime */
+ };
+ for (size_t i = 0; i < sizeof fmts / sizeof *fmts; i++) {
+ struct tm tt = t;
+ if (strptime(val, fmts[i], &tt) != NULL) {
+ long delta = (long)(timegm(&tt) - time(NULL));
+ if (delta < 0)
+ delta = 0;
+ if (delta > 300)
+ delta = 300;
+ return (unsigned long)delta;
+ }
+ }
+ return 0;
+}
+
+static inline unsigned long llm_header_retry_after(const char *headers) {
+ if (!headers)
+ return 0;
+ const char *p = headers;
+ while ((p = llm_strcasestr(p, "retry-after")) != NULL) {
+ p += strlen("retry-after");
+ while (*p == ':' || *p == ' ' || *p == '\t')
+ p++;
+ /* find the value up to CRLF */
+ char *val = llm_strdup(p);
+ size_t n = strlen(val);
+ while (n && (val[n - 1] == '\r' || val[n - 1] == '\n'))
+ val[--n] = '\0';
+ unsigned long r = llm_retry_after(val);
+ free(val);
+ if (r)
+ return r;
+ p += 1;
+ }
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* SSE streaming */
+/* ------------------------------------------------------------------ */
+
+static inline void llm_sse_acc_init(llm_sse_acc *a, llm_provider prov) {
+ (void)prov;
+ memset(a, 0, sizeof *a);
+ llm_buf_init(&a->text);
+ llm_buf_init(&a->reasoning);
+}
+
+static inline void llm_sse_acc_free(llm_sse_acc *a) {
+ llm_buf_free(&a->text);
+ llm_buf_free(&a->reasoning);
+ for (size_t i = 0; i < a->tc_len; i++) {
+ free(a->tc_id[i]);
+ free(a->tc_name[i]);
+ llm_buf_free(&a->tc_args[i]);
+ }
+ free(a->tc_id);
+ free(a->tc_name);
+ free(a->tc_args);
+ free(a->anth_event);
+ memset(a, 0, sizeof *a);
+}
+
+static inline void llm_acc_push_tool(llm_sse_acc *a, char *id, char *name) {
+ a->tc_id = (char **)llm_xrealloc(a->tc_id, (a->tc_len + 1) * sizeof *a->tc_id);
+ a->tc_name =
+ (char **)llm_xrealloc(a->tc_name, (a->tc_len + 1) * sizeof *a->tc_name);
+ a->tc_args =
+ (llm_buf *)llm_xrealloc(a->tc_args, (a->tc_len + 1) * sizeof *a->tc_args);
+ a->tc_id[a->tc_len] = id;
+ a->tc_name[a->tc_len] = name;
+ llm_buf_init(&a->tc_args[a->tc_len]);
+ a->tc_len++;
+}
+
+/* The args buffer of the tool call at `idx` (creating the slot when the
+ * stream has not announced it yet). Fresh slots adopt `id`/`name`. */
+static inline llm_buf *llm_acc_tool_args(llm_sse_acc *a, size_t idx, char *id,
+ char *name) {
+ while (a->tc_len <= idx)
+ llm_acc_push_tool(a, NULL, NULL);
+ if (id) {
+ free(a->tc_id[idx]);
+ a->tc_id[idx] = id;
+ }
+ if (name) {
+ if (a->tc_name[idx] && a->tc_name[idx][0])
+ free(name);
+ else {
+ free(a->tc_name[idx]);
+ a->tc_name[idx] = name;
+ }
+ }
+ return &a->tc_args[idx];
+}
+
+static inline void llm_sse_parse_line(llm_provider prov, const char *line,
+ llm_sse_acc *a) {
+ const char *p = line;
+ while (*p == ' ' || *p == '\t')
+ p++;
+ if (!*p)
+ return;
+
+ if (prov == LLM_ANTHROPIC) {
+ if (strncmp(p, "event:", 6) == 0) {
+ const char *v = p + 6;
+ while (*v == ' ')
+ v++;
+ char *ev = llm_strdup(v);
+ size_t n = strlen(ev);
+ while (n && (ev[n - 1] == '\r' || ev[n - 1] == '\n'))
+ ev[--n] = '\0';
+ free(a->anth_event);
+ a->anth_event = ev;
+ return;
+ }
+ if (strncmp(p, "data:", 5) != 0)
+ return;
+ const char *v = p + 5;
+ while (*v == ' ')
+ v++;
+ const char *ev = a->anth_event ? a->anth_event : "";
+ /* message_stop arrives as `data: [DONE]` (not JSON). */
+ if (llm_streq(ev, "message_stop")) {
+ a->done = true;
+ return;
+ }
+ cJSON *j = cJSON_Parse(v);
+ if (!j)
+ return;
+ if (llm_streq(ev, "message_start")) {
+ const cJSON *m = cJSON_GetObjectItem(j, "message");
+ const cJSON *u = m ? cJSON_GetObjectItem(m, "usage") : NULL;
+ if (u) {
+ const cJSON *it = cJSON_GetObjectItem(u, "input_tokens");
+ if (cJSON_IsNumber(it))
+ a->input_tokens = (unsigned long)it->valuedouble;
+ }
+ } else if (llm_streq(ev, "content_block_start")) {
+ const cJSON *cb = cJSON_GetObjectItem(j, "content_block");
+ const cJSON *t = cb ? cJSON_GetObjectItem(cb, "type") : NULL;
+ if (cJSON_IsString(t) && t->valuestring) {
+ if (llm_streq(t->valuestring, "thinking")) {
+ a->anth_mode = 1;
+ } else if (llm_streq(t->valuestring, "tool_use")) {
+ char *id = NULL, *name = NULL;
+ const cJSON *tid = cJSON_GetObjectItem(cb, "id");
+ if (cJSON_IsString(tid) && tid->valuestring)
+ id = llm_strdup(tid->valuestring);
+ const cJSON *tn = cJSON_GetObjectItem(cb, "name");
+ if (cJSON_IsString(tn) && tn->valuestring)
+ name = llm_strdup(tn->valuestring);
+ llm_acc_push_tool(a, id, name);
+ a->anth_mode = 2;
+ a->anth_tc = a->tc_len - 1;
+ } else {
+ a->anth_mode = 0;
+ }
+ }
+ } else if (llm_streq(ev, "content_block_delta")) {
+ const cJSON *d = cJSON_GetObjectItem(j, "delta");
+ const cJSON *t = d ? cJSON_GetObjectItem(d, "type") : NULL;
+ if (cJSON_IsString(t) && t->valuestring) {
+ if (llm_streq(t->valuestring, "text_delta")) {
+ const cJSON *s = cJSON_GetObjectItem(d, "text");
+ if (cJSON_IsString(s) && s->valuestring)
+ llm_buf_put(&a->text, s->valuestring);
+ } else if (llm_streq(t->valuestring, "thinking_delta")) {
+ const cJSON *s = cJSON_GetObjectItem(d, "thinking");
+ if (cJSON_IsString(s) && s->valuestring)
+ llm_buf_put(&a->reasoning, s->valuestring);
+ } else if (llm_streq(t->valuestring, "input_json_delta")) {
+ const cJSON *s = cJSON_GetObjectItem(d, "partial_json");
+ if (cJSON_IsString(s) && s->valuestring && a->anth_mode == 2)
+ llm_buf_put(&a->tc_args[a->anth_tc], s->valuestring);
+ }
+ }
+ }
+ cJSON_Delete(j);
+ return;
+ }
+
+ if (prov == LLM_OLLAMA) {
+ cJSON *j = cJSON_Parse(p);
+ if (!j)
+ return;
+ const cJSON *msg = cJSON_GetObjectItem(j, "message");
+ if (msg) {
+ const cJSON *ct = cJSON_GetObjectItem(msg, "content");
+ if (cJSON_IsString(ct) && ct->valuestring && ct->valuestring[0])
+ llm_buf_put(&a->text, ct->valuestring);
+ const cJSON *th = cJSON_GetObjectItem(msg, "thinking");
+ if (cJSON_IsString(th) && th->valuestring && th->valuestring[0])
+ llm_buf_put(&a->reasoning, th->valuestring);
+ const cJSON *tcs = cJSON_GetObjectItem(msg, "tool_calls");
+ if (cJSON_IsArray(tcs)) {
+ /* Ollama carries no per-call index (and often no id), so the
+ * array position is the only stable key: map the i-th call to
+ * accumulator slot i so arguments streamed across chunks
+ * accumulate into one call instead of appending a duplicate
+ * slot per chunk (the OpenAI path does this via `index`). */
+ size_t pos = 0;
+ const cJSON *tc = tcs->child;
+ while (tc) {
+ char *id = NULL, *name = NULL;
+ const cJSON *cid = cJSON_GetObjectItem(tc, "id");
+ if (cJSON_IsString(cid) && cid->valuestring)
+ id = llm_strdup(cid->valuestring);
+ const cJSON *fn = cJSON_GetObjectItem(tc, "function");
+ if (fn) {
+ const cJSON *cn = cJSON_GetObjectItem(fn, "name");
+ if (cJSON_IsString(cn) && cn->valuestring)
+ name = llm_strdup(cn->valuestring);
+ }
+ llm_buf *ab = llm_acc_tool_args(a, pos, id, name);
+ if (fn) {
+ const cJSON *ca = cJSON_GetObjectItem(fn, "arguments");
+ if (cJSON_IsString(ca) && ca->valuestring)
+ llm_buf_put(ab, ca->valuestring);
+ else if (cJSON_IsObject(ca)) {
+ char *s = cJSON_PrintUnformatted(ca);
+ if (s) {
+ llm_buf_put(ab, s);
+ free(s);
+ }
+ }
+ }
+ tc = tc->next;
+ pos++;
+ }
+ }
+ }
+ const cJSON *pe = cJSON_GetObjectItem(j, "prompt_eval_count");
+ if (cJSON_IsNumber(pe))
+ a->input_tokens = (unsigned long)pe->valuedouble;
+ const cJSON *dn = cJSON_GetObjectItem(j, "done");
+ if (cJSON_IsBool(dn) && dn->valueint)
+ a->done = true;
+ cJSON_Delete(j);
+ return;
+ }
+
+ /* OpenAI-compatible SSE. */
+ if (strncmp(p, "data:", 5) != 0)
+ return;
+ const char *v = p + 5;
+ while (*v == ' ')
+ v++;
+ if (llm_streq(v, "[DONE]")) {
+ a->done = true;
+ return;
+ }
+ cJSON *j = cJSON_Parse(v);
+ if (!j)
+ return;
+ const cJSON *choices = cJSON_GetObjectItem(j, "choices");
+ if (cJSON_IsArray(choices) && choices->child) {
+ const cJSON *choice = choices->child;
+ const cJSON *delta = cJSON_GetObjectItem(choice, "delta");
+ if (delta) {
+ const cJSON *ct = cJSON_GetObjectItem(delta, "content");
+ if (cJSON_IsString(ct) && ct->valuestring && ct->valuestring[0])
+ llm_buf_put(&a->text, ct->valuestring);
+ const cJSON *rc = cJSON_GetObjectItem(delta, "reasoning_content");
+ if (!cJSON_IsString(rc) || !rc->valuestring)
+ rc = cJSON_GetObjectItem(delta, "reasoning");
+ if (cJSON_IsString(rc) && rc->valuestring && rc->valuestring[0])
+ llm_buf_put(&a->reasoning, rc->valuestring);
+ const cJSON *tcs = cJSON_GetObjectItem(delta, "tool_calls");
+ if (cJSON_IsArray(tcs)) {
+ const cJSON *tc = NULL;
+ cJSON_ArrayForEach(tc, tcs) {
+ size_t idx = 0;
+ const cJSON *ci = cJSON_GetObjectItem(tc, "index");
+ if (cJSON_IsNumber(ci))
+ idx = (size_t)ci->valuedouble;
+ char *id = NULL, *name = NULL;
+ const cJSON *cid = cJSON_GetObjectItem(tc, "id");
+ if (cJSON_IsString(cid) && cid->valuestring)
+ id = llm_strdup(cid->valuestring);
+ const cJSON *fn = cJSON_GetObjectItem(tc, "function");
+ if (fn) {
+ const cJSON *cn = cJSON_GetObjectItem(fn, "name");
+ if (cJSON_IsString(cn) && cn->valuestring)
+ name = llm_strdup(cn->valuestring);
+ }
+ llm_buf *ab = llm_acc_tool_args(a, idx, id, name);
+ if (fn) {
+ const cJSON *ca = cJSON_GetObjectItem(fn, "arguments");
+ if (cJSON_IsString(ca) && ca->valuestring)
+ llm_buf_put(ab, ca->valuestring);
+ else if (cJSON_IsObject(ca)) {
+ char *s = cJSON_PrintUnformatted(ca);
+ if (s) {
+ llm_buf_put(ab, s);
+ free(s);
+ }
+ }
+ }
+ }
+ }
+ const cJSON *fr = cJSON_GetObjectItem(choice, "finish_reason");
+ if (cJSON_IsString(fr) && fr->valuestring && fr->valuestring[0])
+ a->done = true;
+ }
+ }
+ const cJSON *usage = cJSON_GetObjectItem(j, "usage");
+ if (usage) {
+ const cJSON *pt = cJSON_GetObjectItem(usage, "prompt_tokens");
+ if (cJSON_IsNumber(pt))
+ a->input_tokens = (unsigned long)pt->valuedouble;
+ }
+ cJSON_Delete(j);
+}
+
+static inline bool llm_sse_acc_had_content(const llm_sse_acc *a) {
+ return a->text.len > 0 || a->reasoning.len > 0 || a->tc_len > 0 || a->done;
+}
+
+/* Materialize the accumulated stream into `c` (ownership transfers). */
+static inline void llm_sse_acc_into(llm_sse_acc *a, llm_completion *c) {
+ /* Ownership transfers to `c` when non-empty; only free the buffer when
+ * `c` did not take it (empty). Freeing unconditionally would dangle the
+ * pointer `c` now owns -> double-free in llm_completion_free. */
+ char *t = llm_buf_steal(&a->text);
+ if (t && t[0])
+ c->text = t;
+ else
+ free(t);
+ char *r = llm_buf_steal(&a->reasoning);
+ if (r && r[0])
+ c->reasoning = r;
+ else
+ free(r);
+ for (size_t i = 0; i < a->tc_len; i++) {
+ char *args = llm_buf_steal(&a->tc_args[i]);
+ char *id = a->tc_id[i] ? a->tc_id[i]
+ : llm_strfmt("call-%d", (int)(i + 1));
+ char *name = a->tc_name[i] ? a->tc_name[i] : llm_strdup("");
+ if (!args || !args[0]) {
+ free(args);
+ args = llm_strdup("{}");
+ }
+ llm_add_tool_call(c, id, name, args);
+ a->tc_id[i] = a->tc_name[i] = NULL;
+ }
+ c->input_tokens = a->input_tokens;
+ a->input_tokens = 0;
+}
+
+/* Write-callback state for a streaming POST. */
+typedef struct {
+ llm_buf body;
+ llm_buf headers;
+ llm_buf linebuf; /* bytes not yet terminated by a newline */
+ llm_sse_acc *acc;
+ llm_provider prov;
+ llm_emit_cb emit;
+ void *user;
+ std::atomic<bool> *stop;
+ bool stopped; /* set when the transfer should abort */
+ bool response_too_large;
+ size_t emitted_text;
+ size_t emitted_reasoning;
+} llm_stream_ctx;
+
+static inline size_t llm_stream_write(char *d, size_t s, size_t n, void *ud) {
+ llm_stream_ctx *c = (llm_stream_ctx *)ud;
+ const size_t cap = 8u * 1024u * 1024u;
+ if ((s && n > SIZE_MAX / s) || c->body.len > cap ||
+ s * n > cap - c->body.len || c->linebuf.len > cap - s * n) {
+ c->response_too_large = true;
+ return 0;
+ }
+ llm_buf_putn(&c->body, d, s * n);
+ llm_buf_putn(&c->linebuf, d, s * n);
+ for (;;) {
+ char *nl = (char *)memchr(c->linebuf.data, '\n', c->linebuf.len);
+ if (!nl)
+ break;
+ size_t lpos = (size_t)(nl - c->linebuf.data);
+ if (lpos > 0 && c->linebuf.data[lpos - 1] == '\r')
+ lpos--;
+ char *line = llm_strndup(c->linebuf.data, lpos);
+ size_t rest = c->linebuf.len - lpos - 1;
+ memmove(c->linebuf.data, c->linebuf.data + lpos + 1, rest);
+ c->linebuf.len = rest;
+ llm_sse_parse_line(c->prov, line, c->acc);
+ free(line);
+
+ if (c->acc->text.len > c->emitted_text) {
+ char *delta = llm_strndup(c->acc->text.data + c->emitted_text,
+ c->acc->text.len - c->emitted_text);
+ c->emitted_text = c->acc->text.len;
+ if (c->emit &&
+ c->emit(c->prov, LLM_EMIT_TEXT, delta, c->user) != 0)
+ c->stopped = true;
+ free(delta);
+ }
+ if (!c->stopped && c->acc->reasoning.len > c->emitted_reasoning) {
+ char *delta = llm_strndup(c->acc->reasoning.data +
+ c->emitted_reasoning,
+ c->acc->reasoning.len -
+ c->emitted_reasoning);
+ c->emitted_reasoning = c->acc->reasoning.len;
+ if (c->emit &&
+ c->emit(c->prov, LLM_EMIT_REASONING, delta, c->user) != 0)
+ c->stopped = true;
+ free(delta);
+ }
+ if (c->stopped || (c->stop && c->stop->load())) {
+ c->stopped = true;
+ break;
+ }
+ }
+ if (c->stopped)
+ return 0; /* short write: abort the transfer */
+ return s * n;
+}
+
+static inline size_t llm_stream_header(char *d, size_t s, size_t n, void *ud) {
+ llm_stream_ctx *c = (llm_stream_ctx *)ud;
+ llm_buf_putn(&c->headers, d, s * n);
+ return s * n;
+}
+
+static inline int llm_stream_xfer(void *clientp, curl_off_t t, curl_off_t dn,
+ curl_off_t ut, curl_off_t un) {
+ (void)t;
+ (void)dn;
+ (void)ut;
+ (void)un;
+ llm_stream_ctx *c = (llm_stream_ctx *)clientp;
+ if (c->stop && c->stop->load())
+ return 1;
+ return 0;
+}
+
+static inline int llm_post_stream(const llm_conn *c, const char *url,
+ const char *body, long *status_out,
+ char **body_out, char **headers_out,
+ llm_sse_acc *acc, llm_emit_cb emit, void *user,
+ std::atomic<bool> *stop, bool *stopped,
+ char **err, CURL **cached) {
+ if (body_out)
+ *body_out = NULL;
+ if (headers_out)
+ *headers_out = NULL;
+ if (stopped)
+ *stopped = false;
+ if (err)
+ *err = NULL;
+ if (status_out)
+ *status_out = 0;
+
+ llm_stream_ctx ctx;
+ memset(&ctx, 0, sizeof ctx);
+ llm_buf_init(&ctx.body);
+ llm_buf_init(&ctx.headers);
+ llm_buf_init(&ctx.linebuf);
+ ctx.acc = acc;
+ ctx.prov = c->provider;
+ ctx.emit = emit;
+ ctx.user = user;
+ ctx.stop = stop;
+
+ struct curl_slist *h = NULL;
+ CURL *curl = llm_post_setup(c, url, body, &h, err, cached);
+ if (!curl) {
+ llm_buf_free(&ctx.body);
+ llm_buf_free(&ctx.headers);
+ llm_buf_free(&ctx.linebuf);
+ return -1;
+ }
+ curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, llm_stream_write);
+ curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx);
+ curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, llm_stream_header);
+ curl_easy_setopt(curl, CURLOPT_HEADERDATA, &ctx);
+ curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
+ curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, llm_stream_xfer);
+ curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &ctx);
+
+ CURLcode rc = curl_easy_perform(curl);
+ long code = 0;
+ curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code);
+ curl_slist_free_all(h);
+ char *body_str = llm_buf_steal(&ctx.body);
+ char *hdr_str = llm_buf_steal(&ctx.headers);
+ llm_buf_free(&ctx.body);
+ llm_buf_free(&ctx.headers);
+ llm_buf_free(&ctx.linebuf);
+ if (!cached || *cached != curl)
+ curl_easy_cleanup(curl);
+
+ if (ctx.stopped) {
+ if (stopped)
+ *stopped = true;
+ free(body_str);
+ free(hdr_str);
+ return 0;
+ }
+ if (rc != CURLE_OK) {
+ if (err)
+ *err = llm_strdup(ctx.response_too_large
+ ? "LLM streaming response exceeds 8 MiB limit"
+ : curl_easy_strerror(rc));
+ free(body_str);
+ free(hdr_str);
+ return -1;
+ }
+ if (status_out)
+ *status_out = code;
+ if (body_out)
+ *body_out = body_str;
+ else
+ free(body_str);
+ if (headers_out)
+ *headers_out = hdr_str;
+ else
+ free(hdr_str);
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* Request assembly */
+/* ------------------------------------------------------------------ */
+
+/* JSON-encode an arbitrary string (NULL-safe). */
+static inline char *llm_json_str(const char *s) {
+ cJSON *j = cJSON_CreateString(s ? s : "");
+ char *r = cJSON_PrintUnformatted(j);
+ cJSON_Delete(j);
+ return r ? r : llm_strdup("\"\"");
+}
+
+/* OpenAI-compatible body (OpenAI + Ollama). `top_level_system` routes the
+ * preamble to a top-level `system` field (Ollama) instead of a leading
+ * system message (OpenAI). `p` may be NULL. */
+static inline void llm_emit_openai_body(llm_buf *req, const llm_conn *c,
+ const char *preamble,
+ const llm_message *msgs, size_t n,
+ const char *tools_json,
+ bool top_level_system, bool stream,
+ const llm_params *p) {
+ llm_buf_put(req, "{\"model\":");
+ char *model = llm_json_str(c->model);
+ llm_buf_put(req, model);
+ free(model);
+ llm_buf_put(req, ",\"stream\":");
+ llm_buf_put(req, stream ? "true" : "false");
+ llm_buf_push(req, ',');
+ if (top_level_system && preamble && preamble[0]) {
+ char *ps = llm_json_str(preamble);
+ llm_buf_put(req, "\"system\":");
+ llm_buf_put(req, ps);
+ free(ps);
+ llm_buf_push(req, ',');
+ }
+ llm_buf_put(req, "\"messages\":");
+ cJSON *om = llm_openai_messages(msgs, n);
+ if (!top_level_system && preamble && preamble[0]) {
+ /* OpenAI-compatible: the preamble becomes a leading system message. */
+ cJSON *sys = cJSON_CreateObject();
+ cJSON_AddStringToObject(sys, "role", "system");
+ cJSON_AddStringToObject(sys, "content", preamble);
+ cJSON_InsertItemInArray(om, 0, sys);
+ }
+ char *oms = cJSON_PrintUnformatted(om);
+ llm_buf_put(req, oms ? oms : "[]");
+ free(oms);
+ cJSON_Delete(om);
+ if (tools_json) {
+ llm_buf_push(req, ',');
+ llm_buf_put(req, tools_json);
+ }
+ if (p) {
+ if (top_level_system) {
+ /* Ollama: sampling knobs nest under `options`. */
+ if (p->temperature >= 0.0 || p->max_tokens > 0) {
+ llm_buf_put(req, ",\"options\":{");
+ bool first = true;
+ if (p->temperature >= 0.0) {
+ char *t = llm_strfmt("\"temperature\":%.6g",
+ p->temperature);
+ llm_buf_put(req, t);
+ free(t);
+ first = false;
+ }
+ if (p->max_tokens > 0) {
+ if (!first)
+ llm_buf_push(req, ',');
+ char *v = llm_strfmt("\"num_predict\":%lu",
+ p->max_tokens);
+ llm_buf_put(req, v);
+ free(v);
+ }
+ llm_buf_push(req, '}');
+ }
+ if (p->reasoning_effort)
+ llm_buf_put(req, ",\"think\":true");
+ if (p->response_schema && p->response_schema[0]) {
+ llm_buf_put(req, ",\"format\":");
+ llm_buf_put(req, p->response_schema);
+ }
+ } else {
+ if (p->temperature >= 0.0) {
+ char *t = llm_strfmt(",\"temperature\":%.6g", p->temperature);
+ llm_buf_put(req, t);
+ free(t);
+ }
+ if (p->max_tokens > 0) {
+ char *v = llm_strfmt(",\"max_tokens\":%lu", p->max_tokens);
+ llm_buf_put(req, v);
+ free(v);
+ }
+ if (p->reasoning_effort) {
+ char *e = llm_strfmt(",\"reasoning_effort\":\"%s\"",
+ p->reasoning_effort);
+ llm_buf_put(req, e);
+ free(e);
+ }
+ if (p->response_schema && p->response_schema[0]) {
+ llm_buf_put(req,
+ ",\"response_format\":{\"type\":\"json_schema\","
+ "\"json_schema\":{\"name\":\"response\","
+ "\"schema\":");
+ llm_buf_put(req, p->response_schema);
+ llm_buf_put(req, "}}");
+ }
+ }
+ }
+ llm_buf_push(req, '}');
+}
+
+/* Assemble a request body. `p` may be NULL for a plain request. */
+static inline char *llm_build_request_ex(const llm_conn *c,
+ const char *preamble,
+ const llm_message *msgs, size_t n,
+ const char *tools_json, bool stream,
+ const llm_params *p) {
+ llm_buf req;
+ llm_buf_init(&req);
+ if (c->provider == LLM_ANTHROPIC) {
+ unsigned long budget = p ? p->thinking_budget : 0;
+ if (budget == 0 && p && p->reasoning_effort) {
+ if (llm_streq(p->reasoning_effort, "low"))
+ budget = LLM_EFFORT_BUDGET_LOW;
+ else if (llm_streq(p->reasoning_effort, "medium"))
+ budget = LLM_EFFORT_BUDGET_MEDIUM;
+ else
+ budget = LLM_EFFORT_BUDGET_HIGH;
+ }
+ unsigned long mt = LLM_DEFAULT_MAX_TOKENS;
+ if (p && p->max_tokens > 0)
+ mt = p->max_tokens;
+ if (budget && mt <= budget)
+ mt = budget + 1024;
+ llm_buf_put(&req, "{\"model\":");
+ char *model = llm_json_str(c->model);
+ llm_buf_put(&req, model);
+ free(model);
+ {
+ char *mtb = llm_strfmt(",\"max_tokens\":%lu,\"stream\":", mt);
+ llm_buf_put(&req, mtb);
+ free(mtb);
+ }
+ llm_buf_put(&req, stream ? "true" : "false");
+ if (budget) {
+ /* Extended thinking; Anthropic pins temperature to 1 with it. */
+ char *th = llm_strfmt(
+ ",\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":%lu}",
+ budget);
+ llm_buf_put(&req, th);
+ free(th);
+ } else if (p && p->temperature >= 0.0) {
+ char *t = llm_strfmt(",\"temperature\":%.6g", p->temperature);
+ llm_buf_put(&req, t);
+ free(t);
+ }
+ llm_buf_push(&req, ',');
+ const char *sys = preamble;
+ char *schema_sys = NULL;
+ if (p && p->response_schema && p->response_schema[0]) {
+ /* Anthropic has no response_format: steer via the system prompt. */
+ schema_sys = llm_strfmt(
+ "%s\n\nRespond with a single JSON object that matches this "
+ "JSON Schema. Output nothing else.\n%s",
+ preamble ? preamble : "", p->response_schema);
+ sys = schema_sys;
+ }
+ if (sys && sys[0]) {
+ char *ps = llm_json_str(sys);
+ llm_buf_put(&req, "\"system\":");
+ llm_buf_put(&req, ps);
+ free(ps);
+ llm_buf_push(&req, ',');
+ }
+ free(schema_sys);
+ llm_buf_put(&req, "\"messages\":");
+ cJSON *m = llm_anthropic_messages(msgs, n);
+ char *ms = cJSON_PrintUnformatted(m);
+ llm_buf_put(&req, ms ? ms : "[]");
+ free(ms);
+ cJSON_Delete(m);
+ if (tools_json) {
+ llm_buf_push(&req, ',');
+ llm_buf_put(&req, tools_json);
+ }
+ llm_buf_push(&req, '}');
+ } else if (c->provider == LLM_OLLAMA) {
+ /* Ollama takes the system prompt as a top-level `system` field. */
+ llm_emit_openai_body(&req, c, preamble, msgs, n, tools_json, true,
+ stream, p);
+ } else {
+ llm_emit_openai_body(&req, c, preamble, msgs, n, tools_json, false,
+ stream, p);
+ }
+ char *out = llm_buf_steal(&req);
+ llm_buf_free(&req);
+ return out;
+}
+
+static inline char *llm_build_request(const llm_conn *c, const char *preamble,
+ const llm_message *msgs, size_t n,
+ const char *tools_json, bool stream) {
+ return llm_build_request_ex(c, preamble, msgs, n, tools_json, stream, NULL);
+}
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* LLM_H */