/* * 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 #include #include #include #include #include #include #include #include #ifdef __cplusplus #include #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 *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 *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 *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 *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 */