Prompt for Realm password when CST_REALM_PASSWORD is unset; map worker request-gate exhaustion to a clear budget error with partial progress; hard-skip identical tool failures after 3 tries. Add docs/parliament.md. Co-authored-by: Cursor <cursoragent@cursor.com>
1460 lines
48 KiB
C
1460 lines
48 KiB
C
#ifndef _DEFAULT_SOURCE
|
|
#define _DEFAULT_SOURCE
|
|
#endif
|
|
|
|
#include "cJSON.h"
|
|
#include "cst_agent.h"
|
|
#include "cst_tools.h"
|
|
#include "cst_util.h"
|
|
#include "os_compat.h"
|
|
|
|
#include <curl/curl.h>
|
|
#include <stdatomic.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#ifdef _WIN32
|
|
#include <windows.h>
|
|
#else
|
|
#include <unistd.h>
|
|
#endif
|
|
|
|
#define ERRBODY_MAX 4096
|
|
#define LLM_MAX_RETRIES 3
|
|
|
|
static int api_host_has(const char *base, const char *needle) {
|
|
return base && needle && strstr(base, needle) != NULL;
|
|
}
|
|
|
|
static int is_kimi_api(const char *base) {
|
|
return api_host_has(base, "api.moonshot.ai") ||
|
|
api_host_has(base, "api.moonshot.cn") ||
|
|
api_host_has(base, "api.kimi.ai") ||
|
|
api_host_has(base, "api.kimi.com");
|
|
}
|
|
|
|
static int is_kimi_k2(const char *model) {
|
|
return model && strncmp(model, "kimi-k2", 7) == 0;
|
|
}
|
|
|
|
static int is_kimi_k27(const char *model) {
|
|
return model && strncmp(model, "kimi-k2.7-code", 14) == 0;
|
|
}
|
|
|
|
static int ca_file_ok(const char *path) {
|
|
if (!path || !path[0])
|
|
return 0;
|
|
FILE *f = fopen(path, "rb");
|
|
if (!f)
|
|
return 0;
|
|
fclose(f);
|
|
return 1;
|
|
}
|
|
|
|
/* Point libcurl at a CA bundle.
|
|
*
|
|
* libcurl keeps the bundle path it was built with. On Windows an OpenSSL
|
|
* build carries MSYS2's "/mingw64/etc/ssl/certs/ca-bundle.crt"; the static
|
|
* Linux release is built on one distro and runs on all of them, whose bundles
|
|
* live in different places. A path that does not exist fails every HTTPS
|
|
* request with CURLE_SSL_CACERT_BADFILE (77).
|
|
*
|
|
* Order: $CST_CA_BUNDLE; on Windows ca-bundle.crt beside the executable (if
|
|
* absent nothing is set, so Schannel uses the certificate store); elsewhere
|
|
* $SSL_CERT_FILE, libcurl's own default if that file exists, then the usual
|
|
* distro locations. Decided once per process. */
|
|
void cst_curl_apply_ca(void *curl) {
|
|
static char ca[OS_PATH_MAX];
|
|
static atomic_int state; /* 0 unchecked, 1 checking, 2 done */
|
|
int expect = 0;
|
|
if (atomic_compare_exchange_strong(&state, &expect, 1)) {
|
|
const char *env = getenv("CST_CA_BUNDLE");
|
|
if (ca_file_ok(env)) {
|
|
snprintf(ca, sizeof(ca), "%s", env);
|
|
} else {
|
|
#if defined(_WIN32)
|
|
char dir[OS_PATH_MAX], p[OS_PATH_MAX];
|
|
if (os_exe_dir(dir, sizeof(dir)) == 0 &&
|
|
os_path_join(p, sizeof(p), dir, "ca-bundle.crt") == 0 &&
|
|
ca_file_ok(p))
|
|
snprintf(ca, sizeof(ca), "%s", p);
|
|
#else
|
|
static const char *const known[] = {
|
|
"/etc/ssl/certs/ca-certificates.crt", /* Debian, Ubuntu, Arch, Alpine */
|
|
"/etc/pki/tls/certs/ca-bundle.crt", /* Fedora, RHEL */
|
|
"/etc/ssl/cert.pem", /* macOS, BSD, Alpine */
|
|
"/etc/ssl/ca-bundle.pem", /* openSUSE */
|
|
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",
|
|
};
|
|
const char *pick = NULL;
|
|
const char *sslenv = getenv("SSL_CERT_FILE");
|
|
const curl_version_info_data *vi = curl_version_info(CURLVERSION_NOW);
|
|
if (ca_file_ok(sslenv))
|
|
pick = sslenv;
|
|
else if (vi && ca_file_ok(vi->cainfo))
|
|
pick = NULL; /* libcurl's built-in default already works */
|
|
else
|
|
for (size_t i = 0; i < sizeof(known) / sizeof(known[0]); i++)
|
|
if (ca_file_ok(known[i])) {
|
|
pick = known[i];
|
|
break;
|
|
}
|
|
if (pick)
|
|
snprintf(ca, sizeof(ca), "%s", pick);
|
|
#endif
|
|
}
|
|
atomic_store(&state, 2);
|
|
} else {
|
|
while (atomic_load(&state) != 2)
|
|
;
|
|
}
|
|
if (ca[0])
|
|
curl_easy_setopt((CURL *)curl, CURLOPT_CAINFO, ca);
|
|
}
|
|
|
|
#include "cst_attach.h"
|
|
#include "cst_models.h"
|
|
|
|
/* --- wire body ----------------------------------------------------------- */
|
|
|
|
static size_t wire_tool_cap(const cst_llm_request_t *req) {
|
|
if (req->tool_out_cap)
|
|
return req->tool_out_cap;
|
|
const char *e = getenv("CST_TOOL_OUT_CAP");
|
|
if (e && atol(e) > 256)
|
|
return (size_t)atol(e);
|
|
return CST_DEFAULT_TOOL_OUT_CAP;
|
|
}
|
|
|
|
/* Keep head and tail (errors usually sit at the end of build/test logs). */
|
|
static char *cap_middle(const char *s, size_t n, size_t cap, size_t *out_n) {
|
|
if (n <= cap) {
|
|
*out_n = n;
|
|
return NULL;
|
|
}
|
|
size_t head = cst_utf8_floor(s, cap * 3 / 5);
|
|
size_t tail_start = n - (cap - head);
|
|
while (tail_start < n && ((unsigned char)s[tail_start] & 0xC0) == 0x80)
|
|
tail_start++;
|
|
char note[96];
|
|
int nl = snprintf(note, sizeof(note),
|
|
"\n…(%zu bytes omitted for the model)…\n",
|
|
tail_start - head);
|
|
size_t total = head + (size_t)nl + (n - tail_start);
|
|
char *out = malloc(total + 1);
|
|
if (!out) {
|
|
*out_n = n;
|
|
return NULL;
|
|
}
|
|
memcpy(out, s, head);
|
|
memcpy(out + head, note, (size_t)nl);
|
|
memcpy(out + head + nl, s + tail_start, n - tail_start);
|
|
out[total] = 0;
|
|
*out_n = total;
|
|
return out;
|
|
}
|
|
|
|
static cJSON *clean_string(const char *s, size_t cap) {
|
|
if (!s)
|
|
s = "";
|
|
size_t n = strlen(s);
|
|
size_t cn = n;
|
|
char *capped = cap ? cap_middle(s, n, cap, &cn) : NULL;
|
|
size_t sl = 0;
|
|
char *san = cst_utf8_sanitize(capped ? capped : s, cn, &sl);
|
|
free(capped);
|
|
if (!san)
|
|
return cJSON_CreateString("");
|
|
cJSON *j = cJSON_CreateString(san);
|
|
free(san);
|
|
return j;
|
|
}
|
|
|
|
/* --- multimodal content --------------------------------------------------
|
|
* OpenAI-compatible servers take a parts array where a plain string is also
|
|
* accepted, so text-only messages keep the string form and only those with
|
|
* images switch to parts. Images live in the session store and are read (and
|
|
* base64'd) here, once per request. */
|
|
|
|
/* The message's "content" as a parts array, converting a string in place. */
|
|
static cJSON *content_parts(cJSON *msg) {
|
|
cJSON *c = cJSON_GetObjectItemCaseSensitive(msg, "content");
|
|
if (cJSON_IsArray(c))
|
|
return c;
|
|
cJSON *arr = cJSON_CreateArray();
|
|
if (cJSON_IsString(c)) {
|
|
cJSON *t = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(t, "type", "text");
|
|
cJSON_AddStringToObject(t, "text", c->valuestring);
|
|
cJSON_AddItemToArray(arr, t);
|
|
}
|
|
cJSON_ReplaceItemInObjectCaseSensitive(msg, "content", arr);
|
|
return arr;
|
|
}
|
|
|
|
/* Append text, joining it onto a trailing text part when there is one.
|
|
* Takes ownership of txt. */
|
|
static void parts_add_text(cJSON *parts, cJSON *txt) {
|
|
if (!txt)
|
|
txt = cJSON_CreateString("");
|
|
cJSON *last = cJSON_GetArrayItem(parts, cJSON_GetArraySize(parts) - 1);
|
|
const cJSON *lt = last ? cJSON_GetObjectItemCaseSensitive(last, "type") : NULL;
|
|
if (cJSON_IsString(lt) && strcmp(lt->valuestring, "text") == 0) {
|
|
const cJSON *old = cJSON_GetObjectItemCaseSensitive(last, "text");
|
|
cst_buf_t b = {0};
|
|
if (cJSON_IsString(old))
|
|
cst_buf_puts(&b, old->valuestring);
|
|
if (b.len)
|
|
cst_buf_puts(&b, "\n\n");
|
|
cst_buf_puts(&b, txt->valuestring ? txt->valuestring : "");
|
|
cJSON_ReplaceItemInObjectCaseSensitive(
|
|
last, "text", cJSON_CreateString(b.buf ? b.buf : ""));
|
|
cst_buf_free(&b);
|
|
cJSON_Delete(txt);
|
|
return;
|
|
}
|
|
if (!txt->valuestring || !txt->valuestring[0]) {
|
|
cJSON_Delete(txt);
|
|
return;
|
|
}
|
|
cJSON *t = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(t, "type", "text");
|
|
cJSON_AddItemToObject(t, "text", txt);
|
|
cJSON_AddItemToArray(parts, t);
|
|
}
|
|
|
|
/* One stored image as an image_url part, or NULL with why filled in. */
|
|
static cJSON *image_part(const cst_llm_request_t *req,
|
|
const cst_attachment_t *a, size_t *budget, char *why,
|
|
size_t why_sz) {
|
|
why[0] = 0;
|
|
if (!req->session_path || !req->session_path[0]) {
|
|
snprintf(why, why_sz, "there is no session store to read it from");
|
|
return NULL;
|
|
}
|
|
char err[256];
|
|
size_t n = 0;
|
|
char *data = cst_attach_read(req->session_path, a, &n, err, sizeof(err));
|
|
if (!data) {
|
|
snprintf(why, why_sz, "%s", err);
|
|
return NULL;
|
|
}
|
|
if (n > *budget) {
|
|
snprintf(why, why_sz, "%d KB left in the %d MB image budget",
|
|
(int)(*budget / 1024), (int)(CST_ATTACH_MAX_TOTAL >> 20));
|
|
free(data);
|
|
return NULL;
|
|
}
|
|
*budget -= n;
|
|
char mime[32];
|
|
cst_attach_sniff(data, n, mime, sizeof(mime)); /* read verified it */
|
|
char *b64 = cst_base64_encode(data, n);
|
|
free(data);
|
|
if (!b64) {
|
|
snprintf(why, why_sz, "out of memory");
|
|
return NULL;
|
|
}
|
|
cst_buf_t b = {0};
|
|
cst_buf_puts(&b, "data:");
|
|
cst_buf_puts(&b, mime);
|
|
cst_buf_puts(&b, ";base64,");
|
|
cst_buf_puts(&b, b64);
|
|
free(b64);
|
|
if (!b.buf) {
|
|
snprintf(why, why_sz, "out of memory");
|
|
return NULL;
|
|
}
|
|
cJSON *p = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(p, "type", "image_url");
|
|
cJSON *iu = cJSON_AddObjectToObject(p, "image_url");
|
|
cJSON_AddStringToObject(iu, "url", b.buf);
|
|
if (a->detail[0] && strcmp(a->detail, "auto") != 0)
|
|
cJSON_AddStringToObject(iu, "detail", a->detail);
|
|
cst_buf_free(&b);
|
|
return p;
|
|
}
|
|
|
|
/* One stored document as a text part: its text, framed so the model knows
|
|
* where it came from. Documents are plain text on the wire, so a text-only
|
|
* model and CST_NO_IMAGES both still get them. */
|
|
static cJSON *document_part(const cst_llm_request_t *req,
|
|
const cst_attachment_t *a, size_t *budget,
|
|
char *why, size_t why_sz) {
|
|
why[0] = 0;
|
|
if (!req->session_path || !req->session_path[0]) {
|
|
snprintf(why, why_sz, "there is no session store to read it from");
|
|
return NULL;
|
|
}
|
|
char err[256];
|
|
size_t n = 0;
|
|
char *text = cst_attach_read(req->session_path, a, &n, err, sizeof(err));
|
|
if (!text) {
|
|
snprintf(why, why_sz, "%s", err);
|
|
return NULL;
|
|
}
|
|
int cut = 0;
|
|
if (n > *budget) {
|
|
n = *budget;
|
|
cut = 1;
|
|
}
|
|
*budget -= n;
|
|
char desc[128];
|
|
cst_attach_describe(a, desc, sizeof(desc));
|
|
cst_buf_t b = {0};
|
|
cst_buf_printf(&b, "[document \"%.120s\" (%s)%s]\n", a->name, desc,
|
|
cut ? " -- only the start is included" : "");
|
|
cst_buf_append(&b, text, n);
|
|
cst_buf_puts(&b, cut ? "\n[-- document truncated --]"
|
|
: "\n[-- end of document --]");
|
|
free(text);
|
|
cJSON *p = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(p, "type", "text");
|
|
cJSON_AddStringToObject(p, "text", b.buf ? b.buf : "");
|
|
cst_buf_free(&b);
|
|
return p;
|
|
}
|
|
|
|
/* Append every attachment of m: images as image parts (or a note saying why
|
|
* not), documents inline as text. Returns how many parts were added: a
|
|
* delivered file (for_user) adds none. */
|
|
static int parts_add_attachments(const cst_llm_request_t *req, cJSON *parts,
|
|
const cst_message_t *m, size_t *budget,
|
|
size_t *doc_budget, const char *img_off) {
|
|
int added = 0;
|
|
int n = m->nattach;
|
|
if (n > CST_ATTACH_MAX_COUNT) {
|
|
char note[160];
|
|
snprintf(note, sizeof(note),
|
|
"[only the first %d of %d attachments are sent]",
|
|
CST_ATTACH_MAX_COUNT, n);
|
|
parts_add_text(parts, cJSON_CreateString(note));
|
|
added++;
|
|
n = CST_ATTACH_MAX_COUNT;
|
|
}
|
|
for (int i = 0; i < n; i++) {
|
|
const cst_attachment_t *a = &m->attach[i];
|
|
/* A delivered file belongs to the person, not to the model: no part, and
|
|
* not even a "not sent" note -- it was never meant to be in the request. */
|
|
if (a->for_user)
|
|
continue;
|
|
int is_img = cst_attach_is_image(a);
|
|
char why[256];
|
|
cJSON *p = NULL;
|
|
if (is_img && img_off)
|
|
snprintf(why, sizeof(why), "%s", img_off);
|
|
else if (is_img)
|
|
p = image_part(req, a, budget, why, sizeof(why));
|
|
else
|
|
p = document_part(req, a, doc_budget, why, sizeof(why));
|
|
if (p) {
|
|
cJSON_AddItemToArray(parts, p);
|
|
added++;
|
|
continue;
|
|
}
|
|
char note[512];
|
|
snprintf(note, sizeof(note), "[%s \"%.120s\" not sent: %.200s]",
|
|
is_img ? "image" : "document", a->name, why);
|
|
parts_add_text(parts, cJSON_CreateString(note));
|
|
added++;
|
|
}
|
|
return added;
|
|
}
|
|
|
|
/* Images a tool result produced are deferred: an assistant tool_call must be
|
|
* answered by tool messages only, so they ride in one extra user message
|
|
* after the run of tool messages ends. *pending collects it. */
|
|
static void defer_attachments(const cst_llm_request_t *req,
|
|
const cst_message_t *m, cJSON **pending,
|
|
size_t *budget, size_t *doc_budget,
|
|
const char *img_off) {
|
|
cJSON *p = *pending;
|
|
if (!p) {
|
|
p = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(p, "role", "user");
|
|
cJSON_AddArrayToObject(p, "content");
|
|
*pending = p;
|
|
}
|
|
cJSON *content = cJSON_GetObjectItemCaseSensitive(p, "content");
|
|
parts_add_attachments(req, content, m, budget, doc_budget, img_off);
|
|
if (cJSON_GetArraySize(content) == 0) {
|
|
/* Nothing to carry (a tool result that only delivered a file): an empty
|
|
* user message is noise, and some providers refuse it. */
|
|
cJSON_Delete(p);
|
|
*pending = NULL;
|
|
}
|
|
}
|
|
|
|
/* m's text plus one line per image it would have carried (malloc'd); a
|
|
* document is not affected (it is text and is sent as such). */
|
|
static char *image_notes(const cst_message_t *m, const char *why) {
|
|
cst_buf_t b = {0};
|
|
cst_buf_puts(&b, m->content ? m->content : "");
|
|
for (int i = 0; i < m->nattach; i++) {
|
|
if (!cst_attach_is_image(&m->attach[i]))
|
|
continue;
|
|
char d[128];
|
|
cst_attach_describe(&m->attach[i], d, sizeof(d));
|
|
cst_buf_printf(&b, "%s[image \"%.120s\" (%s) not sent: %s]",
|
|
b.len ? "\n" : "", m->attach[i].name, d, why);
|
|
}
|
|
return cst_buf_steal(&b);
|
|
}
|
|
|
|
/* --- cache breakpoints (Anthropic-style) ----------------------------------
|
|
* At most 4 per request. The system prompt one also covers tools[]; the
|
|
* newest message one moves forward every step; the newest real user message
|
|
* keeps a hit when a long tool run pushes the previous one out of the
|
|
* provider's lookback. */
|
|
|
|
static void mark_message(cJSON *msg, const char *ttl) {
|
|
const cJSON *c = cJSON_GetObjectItemCaseSensitive(msg, "content");
|
|
if (cJSON_IsString(c) ? !c->valuestring[0] : !cJSON_IsArray(c))
|
|
return;
|
|
cJSON *parts = content_parts(msg);
|
|
cJSON *last = cJSON_GetArrayItem(parts, cJSON_GetArraySize(parts) - 1);
|
|
if (!cJSON_IsObject(last) ||
|
|
cJSON_GetObjectItemCaseSensitive(last, "cache_control"))
|
|
return;
|
|
cJSON *cc = cJSON_AddObjectToObject(last, "cache_control");
|
|
cJSON_AddStringToObject(cc, "type", "ephemeral");
|
|
if (ttl && ttl[0])
|
|
cJSON_AddStringToObject(cc, "ttl", ttl);
|
|
}
|
|
|
|
static int msg_role_is(const cJSON *msg, const char *role) {
|
|
const cJSON *r = cJSON_GetObjectItemCaseSensitive(msg, "role");
|
|
return cJSON_IsString(r) && strcmp(r->valuestring, role) == 0;
|
|
}
|
|
|
|
static int msg_has_content(const cJSON *msg) {
|
|
const cJSON *c = cJSON_GetObjectItemCaseSensitive(msg, "content");
|
|
return cJSON_IsString(c) ? c->valuestring[0] != 0 : cJSON_IsArray(c);
|
|
}
|
|
|
|
static void cache_marks(cJSON *msgs, const char *ttl) {
|
|
int n = cJSON_GetArraySize(msgs);
|
|
cJSON *first = cJSON_GetArrayItem(msgs, 0);
|
|
if (first && msg_role_is(first, "system"))
|
|
mark_message(first, ttl);
|
|
int last = -1;
|
|
for (int i = n - 1; i > 0 && last < 0; i--)
|
|
if (msg_has_content(cJSON_GetArrayItem(msgs, i)))
|
|
last = i;
|
|
if (last < 0)
|
|
return;
|
|
mark_message(cJSON_GetArrayItem(msgs, last), ttl);
|
|
for (int i = last - 1; i > 0; i--) {
|
|
cJSON *m = cJSON_GetArrayItem(msgs, i);
|
|
if (msg_role_is(m, "user") && msg_has_content(m)) {
|
|
mark_message(m, ttl);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
static const char *role_api(cst_role_t r) {
|
|
switch (r) {
|
|
case CST_ROLE_SYSTEM:
|
|
return "system";
|
|
case CST_ROLE_USER:
|
|
return "user";
|
|
case CST_ROLE_ASSISTANT:
|
|
return "assistant";
|
|
case CST_ROLE_TOOL:
|
|
return "tool";
|
|
}
|
|
return "user";
|
|
}
|
|
|
|
char *cst_llm_build_body(const cst_llm_request_t *req) {
|
|
if (!req || !req->model)
|
|
return NULL;
|
|
cJSON *root = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(root, "model", req->model);
|
|
const int kimi = is_kimi_api(req->api_base);
|
|
const int kimi_k2 = kimi && is_kimi_k2(req->model);
|
|
if (req->reasoning_effort && req->reasoning_effort[0] && !kimi_k2)
|
|
cJSON_AddStringToObject(root, "reasoning_effort", req->reasoning_effort);
|
|
if (kimi_k2) {
|
|
/* K2.6/K2.7 do not understand K3's top-level reasoning_effort. They
|
|
* expose thinking as an object and require preserved reasoning to be
|
|
* sent back on the next turn. K2.7-code is always enabled/kept. */
|
|
cJSON *thinking = cJSON_AddObjectToObject(root, "thinking");
|
|
cJSON_AddStringToObject(thinking, "type", "enabled");
|
|
if (req->send_reasoning || is_kimi_k27(req->model))
|
|
cJSON_AddStringToObject(thinking, "keep", "all");
|
|
}
|
|
if (kimi && (!getenv("CST_NO_CACHE_HINTS") ||
|
|
!getenv("CST_NO_CACHE_HINTS")[0] ||
|
|
getenv("CST_NO_CACHE_HINTS")[0] == '0')) {
|
|
/* Kimi's implicit cache defaults to 5m. Agent sessions commonly pause
|
|
* longer than that, so opt into the 1h tier; users can select 5m/1h. */
|
|
const char *ttl = getenv("CST_KIMI_CACHE_TTL");
|
|
if (!ttl || (strcmp(ttl, "5m") != 0 && strcmp(ttl, "1h") != 0))
|
|
ttl = "1h";
|
|
cJSON *cache = cJSON_AddObjectToObject(root, "prompt_cache_options");
|
|
cJSON_AddStringToObject(cache, "mode", "implicit");
|
|
cJSON_AddStringToObject(cache, "ttl", ttl);
|
|
}
|
|
cJSON_AddTrueToObject(root, "stream");
|
|
const char *nousage = getenv("CST_NO_STREAM_USAGE");
|
|
if (!nousage || !nousage[0] || nousage[0] == '0') {
|
|
cJSON *so = cJSON_AddObjectToObject(root, "stream_options");
|
|
cJSON_AddTrueToObject(so, "include_usage");
|
|
}
|
|
/* The generation parameters the user set (Settings -> Advanced). A field
|
|
* that was not set is left out: an absent field is the provider's own
|
|
* default, which "temperature": 0 would not be. */
|
|
if (req->temperature > 0)
|
|
cJSON_AddNumberToObject(root, "temperature", req->temperature);
|
|
if (req->top_p > 0)
|
|
cJSON_AddNumberToObject(root, "top_p", req->top_p);
|
|
if (req->max_tokens > 0)
|
|
cJSON_AddNumberToObject(root, "max_tokens", req->max_tokens);
|
|
cJSON *msgs = cJSON_AddArrayToObject(root, "messages");
|
|
size_t tcap = wire_tool_cap(req);
|
|
cJSON *prev_user = NULL; /* merge consecutive user messages */
|
|
cJSON *pending = NULL; /* images from tool results, waiting for a break */
|
|
size_t img_budget = CST_ATTACH_MAX_TOTAL;
|
|
size_t doc_budget = CST_ATTACH_MAX_TOTAL;
|
|
const char *noimg = getenv("CST_NO_IMAGES");
|
|
const char *img_off =
|
|
noimg && noimg[0] && noimg[0] != '0'
|
|
? "image sending is disabled (CST_NO_IMAGES)"
|
|
: req->images_off ? "this model does not take images"
|
|
: NULL;
|
|
|
|
for (int i = 0; i < req->nmsgs; i++) {
|
|
cst_message_t mm = req->msgs[i];
|
|
const cst_message_t *m = &mm;
|
|
cst_role_t role = m->role;
|
|
if (role == CST_ROLE_TOOL && !m->tool_call_id[0])
|
|
role = CST_ROLE_USER;
|
|
int n_att = m->attach ? m->nattach : 0;
|
|
int has_img = 0, has_doc = 0;
|
|
for (int k = 0; k < n_att; k++) {
|
|
if (cst_attach_is_image(&m->attach[k]))
|
|
has_img = 1;
|
|
else
|
|
has_doc = 1;
|
|
}
|
|
char *noted = NULL;
|
|
if (has_img && img_off) {
|
|
has_img = 0;
|
|
if (!has_doc) {
|
|
/* plain text: text-only APIs may refuse content arrays */
|
|
noted = image_notes(m, img_off);
|
|
if (noted)
|
|
mm.content = noted;
|
|
}
|
|
}
|
|
/* a document needs a content array even when no image can go out */
|
|
int att_parts = has_img || has_doc;
|
|
|
|
/* Every tool_call must be answered by tool messages, so tool-produced
|
|
* images only leave the queue when the run of tool results is over. */
|
|
if (role != CST_ROLE_TOOL && pending) {
|
|
cJSON_AddItemToArray(msgs, pending);
|
|
prev_user = pending;
|
|
pending = NULL;
|
|
}
|
|
|
|
if (role == CST_ROLE_USER) {
|
|
/* Text-protocol result frames are capped by the agent before framing
|
|
* (their byte counts must stay exact), so never cap user text here. */
|
|
cJSON *pc = prev_user
|
|
? cJSON_GetObjectItemCaseSensitive(prev_user, "content")
|
|
: NULL;
|
|
if (!att_parts && !cJSON_IsArray(pc)) {
|
|
cJSON *c = clean_string(m->content, 0);
|
|
if (prev_user) {
|
|
cst_buf_t b = {0};
|
|
cst_buf_puts(&b, pc->valuestring);
|
|
cst_buf_puts(&b, "\n\n");
|
|
cst_buf_puts(&b, c->valuestring);
|
|
cJSON_ReplaceItemInObjectCaseSensitive(prev_user, "content",
|
|
cJSON_CreateString(b.buf));
|
|
cst_buf_free(&b);
|
|
cJSON_Delete(c);
|
|
free(noted);
|
|
continue;
|
|
}
|
|
cJSON *o = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(o, "role", "user");
|
|
cJSON_AddItemToObject(o, "content", c);
|
|
cJSON_AddItemToArray(msgs, o);
|
|
prev_user = o;
|
|
free(noted);
|
|
continue;
|
|
}
|
|
cJSON *o = prev_user;
|
|
if (!o) {
|
|
o = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(o, "role", "user");
|
|
cJSON_AddArrayToObject(o, "content");
|
|
cJSON_AddItemToArray(msgs, o);
|
|
prev_user = o;
|
|
}
|
|
cJSON *parts = content_parts(o);
|
|
parts_add_text(parts, clean_string(m->content, 0));
|
|
if (att_parts)
|
|
parts_add_attachments(req, parts, m, &img_budget, &doc_budget, img_off);
|
|
free(noted);
|
|
continue;
|
|
}
|
|
prev_user = NULL;
|
|
|
|
cJSON *o = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(o, "role", role_api(role));
|
|
if (role == CST_ROLE_ASSISTANT && m->tool_calls_json &&
|
|
m->tool_calls_json[0]) {
|
|
cJSON *tc = cJSON_Parse(m->tool_calls_json);
|
|
int has_tc = cJSON_IsArray(tc) && cJSON_GetArraySize(tc) > 0;
|
|
if (has_tc && (!m->content || !m->content[0]))
|
|
cJSON_AddNullToObject(o, "content");
|
|
else
|
|
cJSON_AddItemToObject(o, "content", clean_string(m->content, 0));
|
|
if (has_tc)
|
|
cJSON_AddItemToObject(o, "tool_calls", tc);
|
|
else
|
|
cJSON_Delete(tc);
|
|
} else if (role == CST_ROLE_TOOL) {
|
|
cJSON_AddStringToObject(o, "tool_call_id", m->tool_call_id);
|
|
cJSON_AddItemToObject(o, "content", clean_string(m->content, tcap));
|
|
} else {
|
|
cJSON_AddItemToObject(o, "content", clean_string(m->content, 0));
|
|
}
|
|
if (role == CST_ROLE_ASSISTANT) {
|
|
if (req->send_reasoning && m->reasoning && m->reasoning[0])
|
|
cJSON_AddItemToObject(o, "reasoning_content",
|
|
clean_string(m->reasoning, 0));
|
|
if (m->prefix) {
|
|
/* Kimi calls the OpenAI-style assistant prefill flag `partial`. */
|
|
cJSON_AddTrueToObject(o, kimi ? "partial" : "prefix");
|
|
}
|
|
}
|
|
cJSON_AddItemToArray(msgs, o);
|
|
if (role == CST_ROLE_TOOL && n_att)
|
|
defer_attachments(req, m, &pending, &img_budget, &doc_budget, img_off);
|
|
free(noted);
|
|
}
|
|
if (pending)
|
|
cJSON_AddItemToArray(msgs, pending);
|
|
|
|
if (req->enable_tools) {
|
|
cJSON *tools = req->tools_json ? cJSON_Parse(req->tools_json)
|
|
: cst_tools_schema_cfg(req->tool_cfg);
|
|
if (tools) {
|
|
cJSON_AddItemToObject(root, "tools", tools);
|
|
cJSON_AddStringToObject(root, "tool_choice",
|
|
req->tool_choice_none ? "none" : "auto");
|
|
}
|
|
}
|
|
if (req->cache_style == CST_CACHE_OPENAI && req->cache_key &&
|
|
req->cache_key[0]) {
|
|
cJSON_AddStringToObject(root, "prompt_cache_key", req->cache_key);
|
|
const char *ret = getenv("CST_CACHE_RETENTION");
|
|
if (ret && ret[0])
|
|
cJSON_AddStringToObject(root, "prompt_cache_retention", ret);
|
|
} else if (req->cache_style == CST_CACHE_ANTHROPIC) {
|
|
cache_marks(msgs, req->cache_ttl);
|
|
}
|
|
char *out = cJSON_PrintUnformatted(root);
|
|
cJSON_Delete(root);
|
|
return out;
|
|
}
|
|
|
|
/* --- SSE decoding -------------------------------------------------------- */
|
|
|
|
typedef struct {
|
|
int index;
|
|
int emitted;
|
|
int overflow;
|
|
int opened;
|
|
char id[CTL_MAX_ID];
|
|
char name[CTL_MAX_NAME];
|
|
cst_buf_t args;
|
|
} slot_t;
|
|
|
|
typedef struct {
|
|
const cst_llm_request_t *req;
|
|
cst_llm_stats_t *st;
|
|
cst_buf_t line;
|
|
slot_t *slots;
|
|
int nslots;
|
|
int cap;
|
|
int last_index;
|
|
int in_reason;
|
|
int emitted_any;
|
|
int stopped;
|
|
double t0;
|
|
CURL *curl;
|
|
int http_checked;
|
|
int error_body;
|
|
cst_buf_t errbody;
|
|
FILE *rec;
|
|
} sse_t;
|
|
|
|
static void emit_text(sse_t *s, const char *t, size_t n) {
|
|
if (n && s->req->on_delta) {
|
|
s->req->on_delta(t, n, s->req->userdata);
|
|
s->emitted_any = 1;
|
|
}
|
|
}
|
|
|
|
static void slot_emit(sse_t *s, slot_t *sl) {
|
|
if (sl->emitted)
|
|
return;
|
|
sl->emitted = 1;
|
|
if (!sl->name[0] || !s->req->on_native_tool)
|
|
return;
|
|
const char *args = sl->args.buf ? sl->args.buf : "";
|
|
int valid = !sl->overflow;
|
|
if (valid && sl->args.len) {
|
|
cJSON *j = cJSON_Parse(args);
|
|
valid = j != NULL;
|
|
cJSON_Delete(j);
|
|
}
|
|
s->emitted_any = 1;
|
|
s->req->on_native_tool(sl->id, sl->name, valid ? args : NULL,
|
|
s->req->userdata);
|
|
}
|
|
|
|
static void slots_emit_all(sse_t *s, int except_index) {
|
|
for (int i = 0; i < s->nslots; i++) {
|
|
if (s->slots[i].index != except_index)
|
|
slot_emit(s, &s->slots[i]);
|
|
}
|
|
}
|
|
|
|
static slot_t *slot_get(sse_t *s, int index) {
|
|
for (int i = 0; i < s->nslots; i++) {
|
|
if (s->slots[i].index == index)
|
|
return &s->slots[i];
|
|
}
|
|
if (s->nslots == s->cap) {
|
|
int nc = s->cap ? s->cap * 2 : 4;
|
|
slot_t *nv = realloc(s->slots, (size_t)nc * sizeof(slot_t));
|
|
if (!nv)
|
|
return NULL;
|
|
s->slots = nv;
|
|
s->cap = nc;
|
|
}
|
|
slot_t *sl = &s->slots[s->nslots++];
|
|
memset(sl, 0, sizeof(*sl));
|
|
sl->index = index;
|
|
return sl;
|
|
}
|
|
|
|
static int max_index(const sse_t *s) {
|
|
int m = -1;
|
|
for (int i = 0; i < s->nslots; i++)
|
|
if (s->slots[i].index > m)
|
|
m = s->slots[i].index;
|
|
return m;
|
|
}
|
|
|
|
static void handle_tool_delta(sse_t *s, const cJSON *tc) {
|
|
const cJSON *jidx = cJSON_GetObjectItemCaseSensitive(tc, "index");
|
|
const cJSON *jid = cJSON_GetObjectItemCaseSensitive(tc, "id");
|
|
const cJSON *fn = cJSON_GetObjectItemCaseSensitive(tc, "function");
|
|
const cJSON *jname = cJSON_GetObjectItemCaseSensitive(fn, "name");
|
|
const cJSON *jargs = cJSON_GetObjectItemCaseSensitive(fn, "arguments");
|
|
const char *id = cJSON_IsString(jid) && jid->valuestring[0] ? jid->valuestring
|
|
: NULL;
|
|
int index = cJSON_IsNumber(jidx) ? jidx->valueint
|
|
: (s->last_index < 0 ? 0 : s->last_index);
|
|
slot_t *cur = slot_get(s, index);
|
|
if (!cur)
|
|
return;
|
|
/* Same index but a different id: gateway without indices. */
|
|
if (id && cur->id[0] && strcmp(cur->id, id) != 0) {
|
|
index = max_index(s) + 1;
|
|
cur = slot_get(s, index);
|
|
if (!cur)
|
|
return;
|
|
}
|
|
if (index != s->last_index) {
|
|
slots_emit_all(s, index); /* a new call started: earlier ones are done */
|
|
s->last_index = index;
|
|
}
|
|
if (id && !cur->id[0])
|
|
snprintf(cur->id, sizeof(cur->id), "%s", id);
|
|
if (cJSON_IsString(jname) && jname->valuestring[0] && !cur->name[0])
|
|
snprintf(cur->name, sizeof(cur->name), "%s", jname->valuestring);
|
|
if (cur->name[0] && !cur->opened) {
|
|
cur->opened = 1;
|
|
if (s->req->on_native_open)
|
|
s->req->on_native_open(index, cur->id, cur->name, s->req->userdata);
|
|
}
|
|
if (cJSON_IsString(jargs) && jargs->valuestring[0] && !cur->overflow) {
|
|
size_t n = strlen(jargs->valuestring);
|
|
if (cur->args.len + n > CTL_MAX_ARG_BYTES)
|
|
cur->overflow = 1;
|
|
else
|
|
cst_buf_append(&cur->args, jargs->valuestring, n);
|
|
}
|
|
}
|
|
|
|
static void handle_payload(sse_t *s, const char *payload) {
|
|
if (strncmp(payload, "[DONE]", 6) == 0) {
|
|
if (s->in_reason) {
|
|
emit_text(s, "</think>", 8);
|
|
s->in_reason = 0;
|
|
}
|
|
slots_emit_all(s, -1);
|
|
return;
|
|
}
|
|
cJSON *root = cJSON_Parse(payload);
|
|
if (!root)
|
|
return;
|
|
const cJSON *err = cJSON_GetObjectItemCaseSensitive(root, "error");
|
|
if (err && s->st) {
|
|
const cJSON *msg = cJSON_GetObjectItemCaseSensitive(err, "message");
|
|
if (cJSON_IsString(msg))
|
|
snprintf(s->st->error, sizeof(s->st->error), "%s", msg->valuestring);
|
|
else if (cJSON_IsString(err))
|
|
snprintf(s->st->error, sizeof(s->st->error), "%s", err->valuestring);
|
|
}
|
|
const cJSON *usage = cJSON_GetObjectItemCaseSensitive(root, "usage");
|
|
if (cJSON_IsObject(usage) && s->st) {
|
|
const cJSON *pt = cJSON_GetObjectItemCaseSensitive(usage, "prompt_tokens");
|
|
const cJSON *ct =
|
|
cJSON_GetObjectItemCaseSensitive(usage, "completion_tokens");
|
|
/* DeepSeek: prompt_cache_hit/miss_tokens. OpenAI and routers:
|
|
* prompt_tokens_details.cached_tokens (+ cache_write_tokens).
|
|
* Anthropic fields passed through: cache_read/creation_input_tokens. */
|
|
const cJSON *hit =
|
|
cJSON_GetObjectItemCaseSensitive(usage, "prompt_cache_hit_tokens");
|
|
const cJSON *miss =
|
|
cJSON_GetObjectItemCaseSensitive(usage, "prompt_cache_miss_tokens");
|
|
const cJSON *det =
|
|
cJSON_GetObjectItemCaseSensitive(usage, "prompt_tokens_details");
|
|
const cJSON *cached = cJSON_GetObjectItemCaseSensitive(det, "cached_tokens");
|
|
const cJSON *dwrite =
|
|
cJSON_GetObjectItemCaseSensitive(det, "cache_write_tokens");
|
|
const cJSON *aread =
|
|
cJSON_GetObjectItemCaseSensitive(usage, "cache_read_input_tokens");
|
|
const cJSON *awrite =
|
|
cJSON_GetObjectItemCaseSensitive(usage, "cache_creation_input_tokens");
|
|
if (cJSON_IsNumber(pt))
|
|
s->st->prompt_tokens = (long)pt->valuedouble;
|
|
if (cJSON_IsNumber(ct))
|
|
s->st->completion_tokens = (long)ct->valuedouble;
|
|
if (cJSON_IsNumber(hit) || cJSON_IsNumber(cached) || cJSON_IsNumber(aread))
|
|
s->st->cache_reported = 1;
|
|
if (cJSON_IsNumber(hit))
|
|
s->st->cached_tokens = (long)hit->valuedouble;
|
|
else if (cJSON_IsNumber(cached))
|
|
s->st->cached_tokens = (long)cached->valuedouble;
|
|
else if (cJSON_IsNumber(aread))
|
|
s->st->cached_tokens = (long)aread->valuedouble;
|
|
if (cJSON_IsNumber(dwrite))
|
|
s->st->cache_write_tokens = (long)dwrite->valuedouble;
|
|
else if (cJSON_IsNumber(awrite))
|
|
s->st->cache_write_tokens = (long)awrite->valuedouble;
|
|
if (cJSON_IsNumber(miss)) {
|
|
s->st->cache_miss_tokens = (long)miss->valuedouble;
|
|
} else {
|
|
long m = s->st->prompt_tokens - s->st->cached_tokens -
|
|
s->st->cache_write_tokens;
|
|
s->st->cache_miss_tokens = m > 0 ? m : 0;
|
|
}
|
|
}
|
|
const cJSON *choices = cJSON_GetObjectItemCaseSensitive(root, "choices");
|
|
const cJSON *ch = cJSON_IsArray(choices) ? cJSON_GetArrayItem(choices, 0)
|
|
: NULL;
|
|
if (ch) {
|
|
const cJSON *delta = cJSON_GetObjectItemCaseSensitive(ch, "delta");
|
|
const cJSON *rc =
|
|
cJSON_GetObjectItemCaseSensitive(delta, "reasoning_content");
|
|
if (cJSON_IsString(rc) && rc->valuestring[0]) {
|
|
if (!s->in_reason) {
|
|
emit_text(s, "<think>", 7);
|
|
s->in_reason = 1;
|
|
}
|
|
emit_text(s, rc->valuestring, strlen(rc->valuestring));
|
|
}
|
|
const cJSON *c = cJSON_GetObjectItemCaseSensitive(delta, "content");
|
|
if (cJSON_IsString(c) && c->valuestring[0]) {
|
|
if (s->in_reason) {
|
|
emit_text(s, "</think>", 8);
|
|
s->in_reason = 0;
|
|
}
|
|
emit_text(s, c->valuestring, strlen(c->valuestring));
|
|
}
|
|
const cJSON *tcs = cJSON_GetObjectItemCaseSensitive(delta, "tool_calls");
|
|
if (cJSON_IsArray(tcs)) {
|
|
if (s->in_reason) {
|
|
emit_text(s, "</think>", 8);
|
|
s->in_reason = 0;
|
|
}
|
|
const cJSON *tc = NULL;
|
|
cJSON_ArrayForEach(tc, tcs) handle_tool_delta(s, tc);
|
|
}
|
|
const cJSON *fr = cJSON_GetObjectItemCaseSensitive(ch, "finish_reason");
|
|
if (cJSON_IsString(fr) && fr->valuestring[0]) {
|
|
if (s->st)
|
|
snprintf(s->st->finish_reason, sizeof(s->st->finish_reason), "%s",
|
|
fr->valuestring);
|
|
if (s->in_reason) {
|
|
emit_text(s, "</think>", 8);
|
|
s->in_reason = 0;
|
|
}
|
|
slots_emit_all(s, -1);
|
|
}
|
|
}
|
|
cJSON_Delete(root);
|
|
}
|
|
|
|
static int flag_set(volatile int *f) { return f && *f; }
|
|
|
|
/* Feed raw response bytes. Returns -1 when the caller asked to stop. */
|
|
static int sse_feed(sse_t *s, const char *data, size_t n) {
|
|
if (s->st && s->st->t_first_byte_ms < 0)
|
|
s->st->t_first_byte_ms = os_now_ms() - s->t0;
|
|
if (s->rec)
|
|
fwrite(data, 1, n, s->rec);
|
|
if (s->error_body) {
|
|
if (s->errbody.len < ERRBODY_MAX)
|
|
cst_buf_append(&s->errbody, data,
|
|
n < ERRBODY_MAX - s->errbody.len ? n
|
|
: ERRBODY_MAX - s->errbody.len);
|
|
return 0;
|
|
}
|
|
for (size_t i = 0; i < n; i++) {
|
|
if (data[i] != '\n') {
|
|
cst_buf_append(&s->line, &data[i], 1);
|
|
continue;
|
|
}
|
|
char *l = s->line.buf ? s->line.buf : (char *)"";
|
|
size_t ll = s->line.len;
|
|
if (ll && l[ll - 1] == '\r')
|
|
l[--ll] = 0;
|
|
if (ll >= 5 && strncmp(l, "data:", 5) == 0) {
|
|
const char *p = l + 5;
|
|
while (*p == ' ')
|
|
p++;
|
|
handle_payload(s, p);
|
|
}
|
|
s->line.len = 0;
|
|
if (s->line.buf)
|
|
s->line.buf[0] = 0;
|
|
if (flag_set(s->req->stop_flag) || flag_set(s->req->cancel_flag)) {
|
|
if (flag_set(s->req->stop_flag) && s->st)
|
|
s->st->client_stopped = 1;
|
|
s->stopped = 1;
|
|
return -1;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
static size_t write_cb(char *ptr, size_t size, size_t nmemb, void *userdata) {
|
|
sse_t *s = userdata;
|
|
size_t n = size * nmemb;
|
|
if (!s->http_checked) {
|
|
long code = 0;
|
|
curl_easy_getinfo(s->curl, CURLINFO_RESPONSE_CODE, &code);
|
|
s->http_checked = 1;
|
|
if (code >= 400)
|
|
s->error_body = 1;
|
|
}
|
|
if (sse_feed(s, ptr, n) != 0)
|
|
return 0; /* abort transfer */
|
|
return n;
|
|
}
|
|
|
|
static int xferinfo_cb(void *clientp, curl_off_t dltotal, curl_off_t dlnow,
|
|
curl_off_t ultotal, curl_off_t ulnow) {
|
|
(void)dltotal;
|
|
(void)dlnow;
|
|
(void)ultotal;
|
|
(void)ulnow;
|
|
sse_t *s = clientp;
|
|
return (flag_set(s->req->cancel_flag) || flag_set(s->req->stop_flag)) ? 1 : 0;
|
|
}
|
|
|
|
static void sse_reset(sse_t *s) {
|
|
for (int i = 0; i < s->nslots; i++)
|
|
cst_buf_free(&s->slots[i].args);
|
|
free(s->slots);
|
|
s->slots = NULL;
|
|
s->nslots = s->cap = 0;
|
|
cst_buf_free(&s->line);
|
|
cst_buf_free(&s->errbody);
|
|
s->last_index = -1;
|
|
s->in_reason = 0;
|
|
s->emitted_any = 0;
|
|
s->stopped = 0;
|
|
s->http_checked = 0;
|
|
s->error_body = 0;
|
|
}
|
|
|
|
/* --- record / replay ----------------------------------------------------- */
|
|
|
|
static atomic_int g_trace_seq;
|
|
|
|
void cst_llm_trace_reset(void) { atomic_store(&g_trace_seq, 0); }
|
|
|
|
static void trace_path(const char *dir, int seq, const char *ext, char *out,
|
|
size_t n) {
|
|
snprintf(out, n, "%s/%04d.%s", dir, seq, ext);
|
|
}
|
|
|
|
static void format_http_error(sse_t *s, long http, char *err, size_t err_sz);
|
|
|
|
/* NNNN.sse is fed as the stream; with an NNNN.status other than 200 it is
|
|
* the error body of that HTTP status instead. */
|
|
static int replay_run(sse_t *s, const char *dir, int seq, char *err,
|
|
size_t err_sz) {
|
|
char path[OS_PATH_MAX];
|
|
trace_path(dir, seq, "status", path, sizeof(path));
|
|
FILE *sf = fopen(path, "rb");
|
|
long status = 200;
|
|
if (sf) {
|
|
if (fscanf(sf, "%ld", &status) != 1)
|
|
status = 200;
|
|
fclose(sf);
|
|
}
|
|
trace_path(dir, seq, "sse", path, sizeof(path));
|
|
FILE *fp = fopen(path, "rb");
|
|
if (!fp) {
|
|
snprintf(err, err_sz, "replay: missing %s", path);
|
|
return -1;
|
|
}
|
|
if (status != 200) {
|
|
char eb[4096];
|
|
size_t got = fread(eb, 1, sizeof(eb), fp);
|
|
fclose(fp);
|
|
cst_buf_append(&s->errbody, eb, got);
|
|
if (s->st)
|
|
s->st->http_status = status;
|
|
format_http_error(s, status, err, err_sz);
|
|
return -1;
|
|
}
|
|
size_t chunk = 16;
|
|
const char *e = getenv("CST_REPLAY_CHUNK");
|
|
if (e && atoi(e) > 0)
|
|
chunk = (size_t)atoi(e);
|
|
char buf[4096];
|
|
if (chunk > sizeof(buf))
|
|
chunk = sizeof(buf);
|
|
size_t n;
|
|
if (s->st)
|
|
s->st->http_status = 200;
|
|
while ((n = fread(buf, 1, chunk, fp)) > 0) {
|
|
if (sse_feed(s, buf, n) != 0)
|
|
break;
|
|
}
|
|
fclose(fp);
|
|
if (flag_set(s->req->cancel_flag)) {
|
|
snprintf(err, err_sz, "cancelled");
|
|
return -1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
static void sleep_ms_cancellable(int ms, volatile int *cancel) {
|
|
for (int t = 0; t < ms; t += 100) {
|
|
if (flag_set(cancel))
|
|
return;
|
|
#ifdef _WIN32
|
|
Sleep(100);
|
|
#else
|
|
usleep(100000);
|
|
#endif
|
|
}
|
|
}
|
|
|
|
static int retryable(CURLcode rc, long http) {
|
|
if (rc == CURLE_OK)
|
|
return http == 429 || http >= 500;
|
|
return rc == CURLE_COULDNT_CONNECT || rc == CURLE_COULDNT_RESOLVE_HOST ||
|
|
rc == CURLE_OPERATION_TIMEDOUT || rc == CURLE_SEND_ERROR ||
|
|
rc == CURLE_RECV_ERROR || rc == CURLE_GOT_NOTHING ||
|
|
rc == CURLE_SSL_CONNECT_ERROR || rc == CURLE_PARTIAL_FILE;
|
|
}
|
|
|
|
static void format_http_error(sse_t *s, long http, char *err, size_t err_sz) {
|
|
const char *body = s->errbody.buf ? s->errbody.buf : "";
|
|
cJSON *j = cJSON_Parse(body);
|
|
const cJSON *e = cJSON_GetObjectItemCaseSensitive(j, "error");
|
|
const cJSON *m = cJSON_GetObjectItemCaseSensitive(e, "message");
|
|
if (cJSON_IsString(m))
|
|
snprintf(err, err_sz, "http %ld: %s", http, m->valuestring);
|
|
else if (body[0])
|
|
snprintf(err, err_sz, "http %ld: %.400s", http, body);
|
|
else
|
|
snprintf(err, err_sz, "http %ld", http);
|
|
cJSON_Delete(j);
|
|
}
|
|
|
|
int cst_llm_chat_stream(const cst_llm_request_t *req, char *err,
|
|
size_t err_sz) {
|
|
char dummy[8];
|
|
if (!err || !err_sz) {
|
|
err = dummy;
|
|
err_sz = sizeof(dummy);
|
|
}
|
|
err[0] = 0;
|
|
if (!req || !req->api_base || !req->model) {
|
|
snprintf(err, err_sz, "bad request");
|
|
return -1;
|
|
}
|
|
cst_llm_stats_t local_st;
|
|
cst_llm_stats_t *st = req->stats ? req->stats : &local_st;
|
|
memset(st, 0, sizeof(*st));
|
|
st->t_first_byte_ms = -1;
|
|
|
|
char *body = cst_llm_build_body(req);
|
|
if (!body) {
|
|
snprintf(err, err_sz, "cannot build request body");
|
|
return -1;
|
|
}
|
|
|
|
int seq = atomic_fetch_add(&g_trace_seq, 1);
|
|
const char *rec_dir = getenv("CST_RECORD_SSE");
|
|
const char *rep_dir = getenv("CST_REPLAY_SSE");
|
|
if (rec_dir && rec_dir[0]) {
|
|
os_mkdir_p(rec_dir, 0755);
|
|
char p[OS_PATH_MAX];
|
|
trace_path(rec_dir, seq, "req.json", p, sizeof(p));
|
|
FILE *fp = fopen(p, "wb");
|
|
if (fp) {
|
|
fputs(body, fp);
|
|
fclose(fp);
|
|
}
|
|
}
|
|
|
|
sse_t s;
|
|
memset(&s, 0, sizeof(s));
|
|
s.req = req;
|
|
s.st = st;
|
|
s.last_index = -1;
|
|
s.t0 = os_now_ms();
|
|
int ret = 0;
|
|
|
|
if (rep_dir && rep_dir[0]) {
|
|
if (req->request_gate && !req->request_gate(req->request_gate_ud)) {
|
|
snprintf(err, err_sz, "budget: worker request budget exhausted");
|
|
ret = -1;
|
|
goto finish;
|
|
}
|
|
ret = replay_run(&s, rep_dir, seq, err, err_sz);
|
|
goto finish;
|
|
}
|
|
|
|
char url[1024];
|
|
snprintf(url, sizeof(url), "%s/chat/completions", req->api_base);
|
|
struct curl_slist *hdrs = NULL;
|
|
hdrs = curl_slist_append(hdrs, "Content-Type: application/json");
|
|
hdrs = curl_slist_append(hdrs, "Accept: text/event-stream");
|
|
char auth[700];
|
|
if (req->api_key && req->api_key[0]) {
|
|
snprintf(auth, sizeof(auth), "Authorization: Bearer %s", req->api_key);
|
|
hdrs = curl_slist_append(hdrs, auth);
|
|
}
|
|
|
|
for (int attempt = 0;; attempt++) {
|
|
if (req->request_gate && !req->request_gate(req->request_gate_ud)) {
|
|
snprintf(err, err_sz, "budget: worker request budget exhausted");
|
|
ret = -1;
|
|
break;
|
|
}
|
|
CURL *curl = curl_easy_init();
|
|
if (!curl) {
|
|
snprintf(err, err_sz, "curl init failed");
|
|
ret = -1;
|
|
break;
|
|
}
|
|
cst_curl_apply_ca(curl);
|
|
sse_reset(&s);
|
|
s.curl = curl;
|
|
if (rec_dir && rec_dir[0]) {
|
|
char p[OS_PATH_MAX];
|
|
trace_path(rec_dir, seq, "sse", p, sizeof(p));
|
|
s.rec = fopen(p, "wb");
|
|
}
|
|
curl_easy_setopt(curl, CURLOPT_URL, url);
|
|
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs);
|
|
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
|
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
|
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &s);
|
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L);
|
|
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1L);
|
|
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 300L);
|
|
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
|
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
|
|
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, xferinfo_cb);
|
|
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &s);
|
|
|
|
CURLcode rc = curl_easy_perform(curl);
|
|
long http = 0;
|
|
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http);
|
|
st->http_status = http;
|
|
curl_easy_cleanup(curl);
|
|
s.curl = NULL;
|
|
if (s.rec) {
|
|
fclose(s.rec);
|
|
s.rec = NULL;
|
|
}
|
|
|
|
if (s.stopped && !flag_set(req->cancel_flag)) {
|
|
ret = 0; /* client-requested stop */
|
|
break;
|
|
}
|
|
if (flag_set(req->cancel_flag)) {
|
|
snprintf(err, err_sz, "cancelled");
|
|
ret = -1;
|
|
break;
|
|
}
|
|
if (!s.emitted_any && attempt < LLM_MAX_RETRIES && retryable(rc, http)) {
|
|
st->retries++;
|
|
sleep_ms_cancellable(1000 << attempt, req->cancel_flag);
|
|
continue;
|
|
}
|
|
if (rc != CURLE_OK) {
|
|
snprintf(err, err_sz, "curl: %s", curl_easy_strerror(rc));
|
|
ret = -1;
|
|
} else if (http >= 400) {
|
|
format_http_error(&s, http, err, err_sz);
|
|
ret = -1;
|
|
}
|
|
break;
|
|
}
|
|
curl_slist_free_all(hdrs);
|
|
|
|
finish:
|
|
if (ret == 0) {
|
|
if (s.line.len && !s.stopped) { /* last line without trailing newline */
|
|
sse_feed(&s, "\n", 1);
|
|
}
|
|
if (s.in_reason) {
|
|
emit_text(&s, "</think>", 8);
|
|
s.in_reason = 0;
|
|
}
|
|
slots_emit_all(&s, -1);
|
|
if (st->error[0] && !s.emitted_any) {
|
|
snprintf(err, err_sz, "api: %s", st->error);
|
|
ret = -1;
|
|
}
|
|
}
|
|
if (ret != 0 && !st->error[0])
|
|
snprintf(st->error, sizeof(st->error), "%s", err);
|
|
st->t_end_ms = os_now_ms() - s.t0;
|
|
sse_reset(&s);
|
|
free(body);
|
|
return ret;
|
|
}
|
|
|
|
/* --- model listing -------------------------------------------------------- */
|
|
|
|
#define MODELS_BODY_MAX (8 * 1024 * 1024)
|
|
|
|
static size_t models_write_cb(char *ptr, size_t size, size_t nmemb, void *ud) {
|
|
cst_buf_t *b = ud;
|
|
size_t n = size * nmemb;
|
|
if (b->len + n > MODELS_BODY_MAX || cst_buf_append(b, ptr, n) != 0)
|
|
return 0;
|
|
return n;
|
|
}
|
|
|
|
/* A number, or a numeric string (OpenRouter prices are strings). */
|
|
static int json_num(const cJSON *v, double *out) {
|
|
if (cJSON_IsNumber(v)) {
|
|
*out = v->valuedouble;
|
|
return 0;
|
|
}
|
|
if (cJSON_IsString(v) && v->valuestring[0]) {
|
|
char *end;
|
|
double d = strtod(v->valuestring, &end);
|
|
if (end != v->valuestring && !*end) {
|
|
*out = d;
|
|
return 0;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
static int has_image(const cJSON *arr) {
|
|
const cJSON *x;
|
|
cJSON_ArrayForEach(x, arr) {
|
|
if (cJSON_IsString(x) && strcmp(x->valuestring, "image") == 0)
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/* Optional OpenRouter-style metadata: context_length (or context_window /
|
|
* max_model_len), architecture.input_modalities, modalities, and pricing in
|
|
* USD per token. */
|
|
static void model_meta(const cJSON *it, cst_model_meta_t *m) {
|
|
static const char *const ctx_keys[] = {"context_length", "context_window",
|
|
"max_model_len"};
|
|
for (size_t k = 0; k < sizeof(ctx_keys) / sizeof(ctx_keys[0]); k++) {
|
|
double d;
|
|
if (json_num(cJSON_GetObjectItemCaseSensitive(it, ctx_keys[k]), &d) == 0 &&
|
|
d > 0) {
|
|
m->context = (long)d;
|
|
break;
|
|
}
|
|
}
|
|
const cJSON *arch = cJSON_GetObjectItemCaseSensitive(it, "architecture");
|
|
const cJSON *in = cJSON_GetObjectItemCaseSensitive(arch, "input_modalities");
|
|
const cJSON *mods = cJSON_GetObjectItemCaseSensitive(it, "modalities");
|
|
if (cJSON_IsObject(mods))
|
|
mods = cJSON_GetObjectItemCaseSensitive(mods, "input");
|
|
const cJSON *modality = cJSON_GetObjectItemCaseSensitive(arch, "modality");
|
|
if (cJSON_IsArray(in))
|
|
m->vision = has_image(in);
|
|
else if (cJSON_IsArray(mods))
|
|
m->vision = has_image(mods);
|
|
else if (cJSON_IsString(modality)) {
|
|
const char *arrow = strstr(modality->valuestring, "->");
|
|
size_t l = arrow ? (size_t)(arrow - modality->valuestring)
|
|
: strlen(modality->valuestring);
|
|
char left[64];
|
|
snprintf(left, sizeof(left), "%.*s", (int)l, modality->valuestring);
|
|
m->vision = strstr(left, "image") != NULL;
|
|
}
|
|
const cJSON *pr = cJSON_GetObjectItemCaseSensitive(it, "pricing");
|
|
double miss, out;
|
|
if (json_num(cJSON_GetObjectItemCaseSensitive(pr, "prompt"), &miss) == 0 &&
|
|
json_num(cJSON_GetObjectItemCaseSensitive(pr, "completion"), &out) == 0 &&
|
|
miss >= 0 && out >= 0) {
|
|
double hit = miss, write = miss;
|
|
json_num(cJSON_GetObjectItemCaseSensitive(pr, "input_cache_read"), &hit);
|
|
json_num(cJSON_GetObjectItemCaseSensitive(pr, "input_cache_write"), &write);
|
|
m->has_price = 1;
|
|
m->miss = miss * 1e6;
|
|
m->out = out * 1e6;
|
|
m->hit = (hit >= 0 ? hit : miss) * 1e6;
|
|
m->write = (write >= 0 ? write : miss) * 1e6;
|
|
}
|
|
}
|
|
|
|
static int add_model(cst_model_meta_t *out, int n, int max_out,
|
|
const char *id, const cJSON *it) {
|
|
if (!id || !id[0] || n >= max_out || strlen(id) >= sizeof(out->id))
|
|
return n;
|
|
for (int i = 0; i < n; i++)
|
|
if (strcmp(out[i].id, id) == 0)
|
|
return n;
|
|
memset(&out[n], 0, sizeof(out[n]));
|
|
out[n].vision = -1;
|
|
snprintf(out[n].id, sizeof(out[n].id), "%s", id);
|
|
if (cJSON_IsObject(it))
|
|
model_meta(it, &out[n]);
|
|
return n + 1;
|
|
}
|
|
|
|
/* {"data":[{"id":..}]}, {"models":[{"id"|"name":..}]} or a bare array. */
|
|
static int parse_models(const char *json, cst_model_meta_t *out, int max_out) {
|
|
cJSON *root = cJSON_Parse(json);
|
|
if (!root)
|
|
return -1;
|
|
const cJSON *arr = root;
|
|
if (!cJSON_IsArray(arr))
|
|
arr = cJSON_GetObjectItemCaseSensitive(root, "data");
|
|
if (!cJSON_IsArray(arr))
|
|
arr = cJSON_GetObjectItemCaseSensitive(root, "models");
|
|
int n = -1;
|
|
if (cJSON_IsArray(arr)) {
|
|
n = 0;
|
|
const cJSON *it;
|
|
cJSON_ArrayForEach(it, arr) {
|
|
const cJSON *id = cJSON_GetObjectItemCaseSensitive(it, "id");
|
|
if (!cJSON_IsString(id))
|
|
id = cJSON_GetObjectItemCaseSensitive(it, "name");
|
|
if (cJSON_IsString(id))
|
|
n = add_model(out, n, max_out, id->valuestring, it);
|
|
else if (cJSON_IsString(it))
|
|
n = add_model(out, n, max_out, it->valuestring, NULL);
|
|
}
|
|
}
|
|
cJSON_Delete(root);
|
|
return n;
|
|
}
|
|
|
|
int cst_llm_list_models(const char *api_base, const char *api_key,
|
|
char out[][128], int max_out, char *err,
|
|
size_t err_sz) {
|
|
if (!out || max_out <= 0)
|
|
return cst_llm_list_models_meta(api_base, api_key, NULL, 0, err, err_sz);
|
|
cst_model_meta_t *m = calloc((size_t)max_out, sizeof(*m));
|
|
if (!m) {
|
|
if (err && err_sz)
|
|
snprintf(err, err_sz, "out of memory");
|
|
return -1;
|
|
}
|
|
int n = cst_llm_list_models_meta(api_base, api_key, m, max_out, err, err_sz);
|
|
for (int i = 0; i < n; i++)
|
|
snprintf(out[i], 128, "%s", m[i].id);
|
|
free(m);
|
|
return n;
|
|
}
|
|
|
|
int cst_llm_list_models_meta(const char *api_base, const char *api_key,
|
|
cst_model_meta_t *out, int max_out, char *err,
|
|
size_t err_sz) {
|
|
char dummy[8];
|
|
if (!err || !err_sz) {
|
|
err = dummy;
|
|
err_sz = sizeof(dummy);
|
|
}
|
|
err[0] = 0;
|
|
if (!api_base || !api_base[0] || !out || max_out <= 0) {
|
|
snprintf(err, err_sz, "bad request");
|
|
return -1;
|
|
}
|
|
CURL *curl = curl_easy_init();
|
|
if (!curl) {
|
|
snprintf(err, err_sz, "curl init failed");
|
|
return -1;
|
|
}
|
|
cst_curl_apply_ca(curl);
|
|
char url[1024];
|
|
size_t bl = strlen(api_base);
|
|
while (bl && api_base[bl - 1] == '/')
|
|
bl--;
|
|
snprintf(url, sizeof(url), "%.*s/models", (int)bl, api_base);
|
|
struct curl_slist *hdrs = curl_slist_append(NULL, "Accept: application/json");
|
|
char auth[700];
|
|
if (api_key && api_key[0]) {
|
|
snprintf(auth, sizeof(auth), "Authorization: Bearer %s", api_key);
|
|
hdrs = curl_slist_append(hdrs, auth);
|
|
}
|
|
cst_buf_t body = {0};
|
|
curl_easy_setopt(curl, CURLOPT_URL, url);
|
|
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs);
|
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, models_write_cb);
|
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
|
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L);
|
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
|
|
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
|
CURLcode rc = curl_easy_perform(curl);
|
|
long http = 0;
|
|
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http);
|
|
curl_slist_free_all(hdrs);
|
|
curl_easy_cleanup(curl);
|
|
memset(auth, 0, sizeof(auth));
|
|
|
|
int n = -1;
|
|
if (rc != CURLE_OK) {
|
|
snprintf(err, err_sz, "curl: %s", curl_easy_strerror(rc));
|
|
} else if (http == 401 || http == 403) {
|
|
snprintf(err, err_sz, "HTTP %ld: the API key was rejected", http);
|
|
n = CST_MODELS_AUTH;
|
|
} else if (http >= 400) {
|
|
snprintf(err, err_sz, "HTTP %ld from %s", http, url);
|
|
} else {
|
|
n = parse_models(body.buf ? body.buf : "", out, max_out);
|
|
if (n < 0)
|
|
snprintf(err, err_sz, "unexpected /models response");
|
|
}
|
|
cst_buf_free(&body);
|
|
return n;
|
|
}
|