v0.7.0: image input, model registry, prefix-cache savings
Images - vision gate: text-only models get a note instead of image parts and no view_image tool; unknown models retry once without images on HTTP 400 - paste: Ctrl+V and /attach paste read the clipboard (wl-paste, xclip, pngpaste/osascript, Win32 PNG or CF_DIB converted to PNG); pasting image file paths attaches them - MCP image content is stored in the session and sent with the tool result - attach: rel path validation, base64 decode, /retry keeps images Model registry - built-in src/models_builtin.json embedded at build time, merged with <config>/models.json and cached GET /models metadata (context, vision, pricing); most specific rule wins per field - /model labels, /status source, --list-models [provider] Cache and cost - stable request prefix (frozen tools, notes as messages), DeepSeek prefix completion, OpenAI/Anthropic cache hints, /cost and hit rate in the status
This commit is contained in:
+444
-26
@@ -61,6 +61,9 @@ static void llm_apply_ca_bundle(CURL *curl) {
|
||||
#endif
|
||||
}
|
||||
|
||||
#include "cst_attach.h"
|
||||
#include "cst_models.h"
|
||||
|
||||
/* --- wire body ----------------------------------------------------------- */
|
||||
|
||||
static size_t wire_tool_cap(const cst_llm_request_t *req) {
|
||||
@@ -116,6 +119,210 @@ static cJSON *clean_string(const char *s, size_t cap) {
|
||||
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;
|
||||
}
|
||||
cJSON_AddItemToArray(parts, txt);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Append every attachment of m as image parts (or a note saying why not). */
|
||||
static void parts_add_images(const cst_llm_request_t *req, cJSON *parts,
|
||||
const cst_message_t *m, size_t *budget) {
|
||||
int n = m->nattach;
|
||||
if (n > CST_ATTACH_MAX_COUNT) {
|
||||
char note[160];
|
||||
snprintf(note, sizeof(note), "[%d of %d images sent]", CST_ATTACH_MAX_COUNT,
|
||||
n);
|
||||
parts_add_text(parts, cJSON_CreateString(note));
|
||||
n = CST_ATTACH_MAX_COUNT;
|
||||
}
|
||||
for (int i = 0; i < n; i++) {
|
||||
const cst_attachment_t *a = &m->attach[i];
|
||||
char why[256];
|
||||
cJSON *p = image_part(req, a, budget, why, sizeof(why));
|
||||
if (p) {
|
||||
cJSON_AddItemToArray(parts, p);
|
||||
continue;
|
||||
}
|
||||
char note[512];
|
||||
snprintf(note, sizeof(note), "[image \"%.120s\" not sent: %.200s]",
|
||||
a->name, why);
|
||||
parts_add_text(parts, cJSON_CreateString(note));
|
||||
}
|
||||
}
|
||||
|
||||
/* 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_images(const cst_llm_request_t *req, const cst_message_t *m,
|
||||
cJSON **pending, size_t *budget) {
|
||||
cJSON *p = *pending;
|
||||
if (!p) {
|
||||
p = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p, "role", "user");
|
||||
cJSON_AddArrayToObject(p, "content");
|
||||
*pending = p;
|
||||
}
|
||||
parts_add_images(req, cJSON_GetObjectItemCaseSensitive(p, "content"), m,
|
||||
budget);
|
||||
}
|
||||
|
||||
/* m's text plus one line per image it would have carried (malloc'd). */
|
||||
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++) {
|
||||
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:
|
||||
@@ -146,34 +353,80 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
|
||||
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;
|
||||
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++) {
|
||||
const cst_message_t *m = &req->msgs[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 has_img = m->attach && m->nattach > 0;
|
||||
char *noted = NULL;
|
||||
if (has_img && img_off) {
|
||||
/* plain text: text-only APIs may refuse content arrays */
|
||||
noted = image_notes(m, img_off);
|
||||
if (noted)
|
||||
mm.content = noted;
|
||||
has_img = 0;
|
||||
}
|
||||
|
||||
/* 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 *c = clean_string(m->content, 0);
|
||||
if (prev_user) {
|
||||
cJSON *pc = cJSON_GetObjectItemCaseSensitive(prev_user, "content");
|
||||
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);
|
||||
cJSON *pc = prev_user
|
||||
? cJSON_GetObjectItemCaseSensitive(prev_user, "content")
|
||||
: NULL;
|
||||
if (!has_img && !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 = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(o, "role", "user");
|
||||
cJSON_AddItemToObject(o, "content", c);
|
||||
cJSON_AddItemToArray(msgs, o);
|
||||
prev_user = o;
|
||||
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 (has_img)
|
||||
parts_add_images(req, parts, m, &img_budget);
|
||||
free(noted);
|
||||
continue;
|
||||
}
|
||||
prev_user = NULL;
|
||||
@@ -198,17 +451,39 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
|
||||
} 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)
|
||||
cJSON_AddTrueToObject(o, "prefix");
|
||||
}
|
||||
cJSON_AddItemToArray(msgs, o);
|
||||
if (role == CST_ROLE_TOOL && has_img)
|
||||
defer_images(req, m, &pending, &img_budget);
|
||||
free(noted);
|
||||
}
|
||||
if (pending)
|
||||
cJSON_AddItemToArray(msgs, pending);
|
||||
|
||||
if (req->enable_tools) {
|
||||
cJSON *tools = cst_tools_schema_cfg(req->tool_cfg);
|
||||
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;
|
||||
@@ -371,11 +646,22 @@ static void handle_payload(sse_t *s, const char *payload) {
|
||||
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))
|
||||
@@ -384,6 +670,19 @@ static void handle_payload(sse_t *s, const char *payload) {
|
||||
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)
|
||||
@@ -526,15 +825,37 @@ static void trace_path(const char *dir, int seq, const char *ext, char *out,
|
||||
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)
|
||||
@@ -742,7 +1063,7 @@ finish:
|
||||
|
||||
/* --- model listing -------------------------------------------------------- */
|
||||
|
||||
#define MODELS_BODY_MAX (2 * 1024 * 1024)
|
||||
#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;
|
||||
@@ -752,18 +1073,97 @@ static size_t models_write_cb(char *ptr, size_t size, size_t nmemb, void *ud) {
|
||||
return n;
|
||||
}
|
||||
|
||||
static int add_model(char out[][128], int n, int max_out, const char *id) {
|
||||
if (!id || !id[0] || n >= max_out || strlen(id) >= 128)
|
||||
/* 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) == 0)
|
||||
if (strcmp(out[i].id, id) == 0)
|
||||
return n;
|
||||
snprintf(out[n], 128, "%s", id);
|
||||
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, char out[][128], int max_out) {
|
||||
static int parse_models(const char *json, cst_model_meta_t *out, int max_out) {
|
||||
cJSON *root = cJSON_Parse(json);
|
||||
if (!root)
|
||||
return -1;
|
||||
@@ -781,9 +1181,9 @@ static int parse_models(const char *json, char out[][128], int max_out) {
|
||||
if (!cJSON_IsString(id))
|
||||
id = cJSON_GetObjectItemCaseSensitive(it, "name");
|
||||
if (cJSON_IsString(id))
|
||||
n = add_model(out, n, max_out, id->valuestring);
|
||||
n = add_model(out, n, max_out, id->valuestring, it);
|
||||
else if (cJSON_IsString(it))
|
||||
n = add_model(out, n, max_out, it->valuestring);
|
||||
n = add_model(out, n, max_out, it->valuestring, NULL);
|
||||
}
|
||||
}
|
||||
cJSON_Delete(root);
|
||||
@@ -793,6 +1193,24 @@ static int parse_models(const char *json, char out[][128], int max_out) {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user