diff options
| author | Ethan Werner <mail@ethanwerner.com> | 2026-10-10 20:55:33 +0000 |
|---|---|---|
| committer | Ethan Werner <mail@ethanwerner.com> | 2026-10-10 20:56:50 +0000 |
| commit | f8423ebea413605b42de23f5dd06c5479d3fa1dd (patch) | |
| tree | 2612a5eb026c59e7aae01f10a1cf62f2ee43042c /src/llm.h | |
Diffstat (limited to 'src/llm.h')
| -rw-r--r-- | src/llm.h | 1722 |
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 */ |