The settings dialog could pick a preset, a model and a key and nothing else.
Two things were missing: a provider the presets do not know, and what the
model is asked to do while it answers. Both live under "Advanced options"
now, next to the entry they belong to.
Custom providers (engine)
- cst_models_user_set_provider() / cst_models_user_drop_provider() write the
engine's own user layer, <config>/cst-agent/models.json:
{"providers": {"myllm": {"api_base": ..., "key_env": ...,
"default_model": ..., "models": [...]}}}
and reload the registry, so the provider is at once in the provider list,
in the wire models[] and in the CLI's /provider -- the file a hand-written
override would go into is the same one, so the GUI and an editor agree.
The name is checked (1-31 of letters, digits, '.', '_', '-'), a preset's
name overrides that preset (a self-hosted endpoint, a proxy), everything
else in the file (model rules) is kept, and a file that is not a JSON
object is never overwritten. Dropping only removes the user's own entry.
- AgentCore::providers() gained "custom" (source == "user"), and
addProvider() / removeProvider() drive the two calls above.
Model parameters (engine + wire)
- cst_agent_config_t and cst_llm_request_t carry temperature, top_p and
max_tokens. 0 means the field is not sent at all -- an absent field is the
provider's own default, which "temperature": 0 is not (deterministic
decoding is a real choice), so cst_llm_build_body() only adds what was set.
tests/test_llm_body.c:test_gen_params pins both halves.
- settings.json keeps them per provider ("params": {...}) via
cst_settings_remember_params(); cst_settings_remember() carries them along
with the provider choice, and cst_agent_create() reads them, so a CLI or
TUI run sends what the window was set to. An all-zero config (the CLI)
leaves what the GUI saved alone.
- AgentCore::genParams() / setGenParams(), and useProvider() applies both the
remembered parameters of the provider it lands on and a base URL that was
edited in the dialog.
The dialog
- Rewritten around one scrolling body with a fixed footer: a long model list
used to push the key field and the buttons out of the dialog. The model
chips are filtered (a filter field appears past twelve) and capped at 40
with "... +N more", which is what a router with hundreds of models needs.
- "Advanced options" folds out the provider entry (name, base URL, key
variable, default model, models; Save provider / Remove from models.json)
and the model parameters (temperature, top-p, max tokens; empty = not
sent). The fields follow the picked provider and are what gets saved, so a
hand-typed edit is never lost to a stale property.
Verified: --uitest 67/67 (15 new checks: parameters round-trip through the
engine, a provider added through the form and removed again, a bad name
refused, a preset not removable), --selftest, ctest cst-app-selftest, and
make check with the new body test.
Version 0.8.15.
mode = coding
977 lines
28 KiB
C
977 lines
28 KiB
C
#include "cst_models.h"
|
|
#include "cst_util.h"
|
|
|
|
#include "cJSON.h"
|
|
#include "os_compat.h"
|
|
|
|
#include <ctype.h>
|
|
#include <errno.h>
|
|
#include <pthread.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <time.h>
|
|
|
|
static const char BUILTIN[] = {
|
|
#include "models_builtin.inc"
|
|
0};
|
|
|
|
const char *cst_models_builtin_json(void) { return BUILTIN; }
|
|
|
|
#define MAX_PROV 32
|
|
#define MAX_PMODELS 16
|
|
#define MAX_RULES 256
|
|
|
|
typedef struct {
|
|
cst_provider_t pub; /* points into the buffers below */
|
|
char name[64];
|
|
char api_base[256];
|
|
char model[128];
|
|
char key_env[64];
|
|
int passback, prefix;
|
|
char effort[16]; /* "", "passthrough", "openai" */
|
|
char models[MAX_PMODELS][128];
|
|
int nmodels;
|
|
const char *source;
|
|
} prov_t;
|
|
|
|
enum { F_CONTEXT = 1, F_VISION = 2, F_CACHE = 4, F_REASONING = 8, F_PRICE = 16 };
|
|
|
|
typedef struct {
|
|
char match[128]; /* lowercase */
|
|
int spec; /* literal characters: higher is more specific */
|
|
unsigned has; /* F_* set by this rule */
|
|
long context;
|
|
int vision, cache, reasoning;
|
|
cst_price_t price;
|
|
} rule_t;
|
|
|
|
typedef struct {
|
|
char provider[64];
|
|
long fetched_at;
|
|
cst_model_meta_t m;
|
|
} cached_t;
|
|
|
|
static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER;
|
|
|
|
static struct {
|
|
int loaded;
|
|
prov_t prov[MAX_PROV];
|
|
int nprov;
|
|
rule_t builtin[MAX_RULES];
|
|
int nbuiltin;
|
|
rule_t user[MAX_RULES];
|
|
int nuser;
|
|
cached_t *cache;
|
|
int ncache;
|
|
char err[1400];
|
|
} R;
|
|
|
|
/* --- parsing ------------------------------------------------------------- */
|
|
|
|
static void lower_copy(char *out, size_t n, const char *s) {
|
|
size_t i = 0;
|
|
for (; s && s[i] && i + 1 < n; i++)
|
|
out[i] = (char)tolower((unsigned char)s[i]);
|
|
out[i] = 0;
|
|
}
|
|
|
|
static const char *jstr(const cJSON *o, const char *k) {
|
|
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, k);
|
|
return cJSON_IsString(v) ? v->valuestring : NULL;
|
|
}
|
|
|
|
static void fix_pub(prov_t *p) {
|
|
p->pub.name = p->name;
|
|
p->pub.api_base = p->api_base;
|
|
p->pub.model = p->model;
|
|
p->pub.key_env = p->key_env;
|
|
}
|
|
|
|
static prov_t *prov_by_name(const char *name) {
|
|
for (int i = 0; i < R.nprov; i++)
|
|
if (strcmp(R.prov[i].name, name) == 0)
|
|
return &R.prov[i];
|
|
return NULL;
|
|
}
|
|
|
|
static void parse_providers(const cJSON *obj, const char *source) {
|
|
const cJSON *it = NULL;
|
|
cJSON_ArrayForEach(it, obj) {
|
|
if (!cJSON_IsObject(it) || !it->string || !it->string[0])
|
|
continue;
|
|
prov_t *p = prov_by_name(it->string);
|
|
if (!p) {
|
|
if (R.nprov >= MAX_PROV)
|
|
continue;
|
|
p = &R.prov[R.nprov++];
|
|
memset(p, 0, sizeof(*p));
|
|
snprintf(p->name, sizeof(p->name), "%s", it->string);
|
|
fix_pub(p);
|
|
}
|
|
p->source = source;
|
|
const char *s;
|
|
if ((s = jstr(it, "api_base")))
|
|
snprintf(p->api_base, sizeof(p->api_base), "%s", s);
|
|
if ((s = jstr(it, "key_env")))
|
|
snprintf(p->key_env, sizeof(p->key_env), "%s", s);
|
|
if ((s = jstr(it, "default_model")))
|
|
snprintf(p->model, sizeof(p->model), "%s", s);
|
|
if ((s = jstr(it, "effort")))
|
|
snprintf(p->effort, sizeof(p->effort), "%s", s);
|
|
const cJSON *b = cJSON_GetObjectItemCaseSensitive(it, "reasoning_passback");
|
|
if (cJSON_IsBool(b))
|
|
p->passback = cJSON_IsTrue(b);
|
|
b = cJSON_GetObjectItemCaseSensitive(it, "prefix_completion");
|
|
if (cJSON_IsBool(b))
|
|
p->prefix = cJSON_IsTrue(b);
|
|
const cJSON *ms = cJSON_GetObjectItemCaseSensitive(it, "models");
|
|
if (cJSON_IsArray(ms)) {
|
|
p->nmodels = 0;
|
|
const cJSON *m = NULL;
|
|
cJSON_ArrayForEach(m, ms) {
|
|
if (cJSON_IsString(m) && m->valuestring[0] && p->nmodels < MAX_PMODELS)
|
|
snprintf(p->models[p->nmodels++], 128, "%s", m->valuestring);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
static int parse_price(const cJSON *o, cst_price_t *pr) {
|
|
const cJSON *miss = cJSON_GetObjectItemCaseSensitive(o, "miss");
|
|
const cJSON *out = cJSON_GetObjectItemCaseSensitive(o, "out");
|
|
if (!cJSON_IsNumber(miss) || !cJSON_IsNumber(out))
|
|
return -1;
|
|
memset(pr, 0, sizeof(*pr));
|
|
pr->miss = miss->valuedouble;
|
|
pr->out = out->valuedouble;
|
|
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, "hit");
|
|
pr->hit = cJSON_IsNumber(v) ? v->valuedouble : pr->miss;
|
|
v = cJSON_GetObjectItemCaseSensitive(o, "write");
|
|
pr->write = cJSON_IsNumber(v) ? v->valuedouble : pr->miss;
|
|
const char *c = jstr(o, "currency");
|
|
snprintf(pr->currency, sizeof(pr->currency), "%s", c ? c : "$");
|
|
pr->offpeak_half =
|
|
cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(o, "offpeak_half"));
|
|
return 0;
|
|
}
|
|
|
|
static int parse_rule(const cJSON *o, rule_t *r) {
|
|
const char *m = jstr(o, "match");
|
|
if (!m || !m[0])
|
|
return -1;
|
|
memset(r, 0, sizeof(*r));
|
|
lower_copy(r->match, sizeof(r->match), m);
|
|
for (const char *q = r->match; *q; q++)
|
|
if (*q != '*')
|
|
r->spec++;
|
|
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, "context");
|
|
if (cJSON_IsNumber(v) && v->valuedouble > 0) {
|
|
r->context = (long)v->valuedouble;
|
|
r->has |= F_CONTEXT;
|
|
}
|
|
v = cJSON_GetObjectItemCaseSensitive(o, "vision");
|
|
if (cJSON_IsBool(v)) {
|
|
r->vision = cJSON_IsTrue(v);
|
|
r->has |= F_VISION;
|
|
}
|
|
v = cJSON_GetObjectItemCaseSensitive(o, "reasoning");
|
|
if (cJSON_IsBool(v)) {
|
|
r->reasoning = cJSON_IsTrue(v);
|
|
r->has |= F_REASONING;
|
|
}
|
|
const char *c = jstr(o, "cache");
|
|
if (c) {
|
|
if (strcmp(c, "openai") == 0)
|
|
r->cache = CST_CACHE_OPENAI;
|
|
else if (strcmp(c, "anthropic") == 0)
|
|
r->cache = CST_CACHE_ANTHROPIC;
|
|
else
|
|
r->cache = CST_CACHE_AUTO;
|
|
r->has |= F_CACHE;
|
|
}
|
|
v = cJSON_GetObjectItemCaseSensitive(o, "price");
|
|
if (cJSON_IsObject(v) && parse_price(v, &r->price) == 0)
|
|
r->has |= F_PRICE;
|
|
return 0;
|
|
}
|
|
|
|
static int parse_doc(const char *json, int user, char *err, size_t err_sz) {
|
|
cJSON *doc = cJSON_Parse(json);
|
|
if (!cJSON_IsObject(doc)) {
|
|
cJSON_Delete(doc);
|
|
snprintf(err, err_sz, "not a JSON object");
|
|
return -1;
|
|
}
|
|
parse_providers(cJSON_GetObjectItemCaseSensitive(doc, "providers"),
|
|
user ? "user" : "built-in");
|
|
rule_t *rules = user ? R.user : R.builtin;
|
|
int *n = user ? &R.nuser : &R.nbuiltin;
|
|
const cJSON *it = NULL;
|
|
cJSON_ArrayForEach(it, cJSON_GetObjectItemCaseSensitive(doc, "models")) {
|
|
if (*n < MAX_RULES && parse_rule(it, &rules[*n]) == 0)
|
|
(*n)++;
|
|
}
|
|
cJSON_Delete(doc);
|
|
return 0;
|
|
}
|
|
|
|
static char *read_file(const char *path) {
|
|
FILE *f = fopen(path, "rb");
|
|
if (!f)
|
|
return NULL;
|
|
char *buf = NULL;
|
|
size_t len = 0;
|
|
char chunk[8192];
|
|
size_t got;
|
|
while ((got = fread(chunk, 1, sizeof(chunk), f)) > 0 && len < (8u << 20)) {
|
|
char *nb = realloc(buf, len + got + 1);
|
|
if (!nb)
|
|
break;
|
|
buf = nb;
|
|
memcpy(buf + len, chunk, got);
|
|
len += got;
|
|
}
|
|
fclose(f);
|
|
if (buf)
|
|
buf[len] = 0;
|
|
return buf;
|
|
}
|
|
|
|
static int cache_path(const char *provider, char *out, size_t n) {
|
|
char dir[1024];
|
|
if (!provider || !provider[0] || os_config_dir(dir, sizeof(dir)) != 0)
|
|
return -1;
|
|
for (const char *q = provider; *q; q++)
|
|
if (!isalnum((unsigned char)*q) && !strchr("._-", *q))
|
|
return -1;
|
|
int w = snprintf(out, n, "%s/models-cache/%s.json", dir, provider);
|
|
return w > 0 && (size_t)w < n ? 0 : -1;
|
|
}
|
|
|
|
static void meta_from_json(const cJSON *o, cst_model_meta_t *m) {
|
|
memset(m, 0, sizeof(*m));
|
|
m->vision = -1;
|
|
const char *id = jstr(o, "id");
|
|
snprintf(m->id, sizeof(m->id), "%s", id ? id : "");
|
|
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, "context");
|
|
if (cJSON_IsNumber(v) && v->valuedouble > 0)
|
|
m->context = (long)v->valuedouble;
|
|
v = cJSON_GetObjectItemCaseSensitive(o, "vision");
|
|
if (cJSON_IsBool(v))
|
|
m->vision = cJSON_IsTrue(v);
|
|
v = cJSON_GetObjectItemCaseSensitive(o, "price");
|
|
cst_price_t pr;
|
|
if (cJSON_IsObject(v) && parse_price(v, &pr) == 0) {
|
|
m->has_price = 1;
|
|
m->miss = pr.miss;
|
|
m->hit = pr.hit;
|
|
m->write = pr.write;
|
|
m->out = pr.out;
|
|
}
|
|
}
|
|
|
|
/* Appends provider's cache file to R.cache (caller holds the lock). */
|
|
static void load_cache(const char *provider) {
|
|
char path[1100];
|
|
if (cache_path(provider, path, sizeof(path)) != 0)
|
|
return;
|
|
char *txt = read_file(path);
|
|
cJSON *doc = txt ? cJSON_Parse(txt) : NULL;
|
|
free(txt);
|
|
const cJSON *fa = cJSON_GetObjectItemCaseSensitive(doc, "fetched_at");
|
|
long at = cJSON_IsNumber(fa) ? (long)fa->valuedouble : 0;
|
|
const cJSON *it = NULL;
|
|
cJSON_ArrayForEach(it, cJSON_GetObjectItemCaseSensitive(doc, "models")) {
|
|
cst_model_meta_t m;
|
|
meta_from_json(it, &m);
|
|
if (!m.id[0])
|
|
continue;
|
|
cached_t *nc = realloc(R.cache, (size_t)(R.ncache + 1) * sizeof(*nc));
|
|
if (!nc)
|
|
break;
|
|
R.cache = nc;
|
|
cached_t *c = &R.cache[R.ncache++];
|
|
snprintf(c->provider, sizeof(c->provider), "%s", provider);
|
|
c->fetched_at = at;
|
|
c->m = m;
|
|
}
|
|
cJSON_Delete(doc);
|
|
}
|
|
|
|
static void drop_cache(const char *provider) {
|
|
int w = 0;
|
|
for (int i = 0; i < R.ncache; i++)
|
|
if (provider && strcmp(R.cache[i].provider, provider) != 0)
|
|
R.cache[w++] = R.cache[i];
|
|
R.ncache = w;
|
|
}
|
|
|
|
static void reset_locked(void) {
|
|
R.nprov = 0;
|
|
R.nbuiltin = 0;
|
|
R.nuser = 0;
|
|
drop_cache(NULL);
|
|
R.err[0] = 0;
|
|
}
|
|
|
|
static void load_caches_locked(void) {
|
|
for (int i = 0; i < R.nprov; i++)
|
|
load_cache(R.prov[i].name);
|
|
}
|
|
|
|
int cst_models_user_path(char *out, size_t out_sz) {
|
|
char dir[1024];
|
|
if (os_config_dir(dir, sizeof(dir)) != 0)
|
|
return -1;
|
|
int w = snprintf(out, out_sz, "%s/models.json", dir);
|
|
return w > 0 && (size_t)w < out_sz ? 0 : -1;
|
|
}
|
|
|
|
/* --- the user file: providers the presets do not know -------------------- */
|
|
|
|
static int valid_provider_name(const char *name) {
|
|
if (!name || !name[0] || strlen(name) >= 32)
|
|
return 0;
|
|
for (const char *q = name; *q; q++)
|
|
if (!isalnum((unsigned char)*q) && *q != '.' && *q != '_' && *q != '-')
|
|
return 0;
|
|
return 1;
|
|
}
|
|
|
|
/* The user file as a JSON object: an empty one when there is no file, NULL
|
|
* when there is one that is not an object (never overwrite somebody else's
|
|
* file, their edit is not worth losing). */
|
|
static cJSON *read_user_doc(char *path, size_t path_sz) {
|
|
if (cst_models_user_path(path, path_sz) != 0)
|
|
return NULL;
|
|
char *text = read_file(path);
|
|
if (!text)
|
|
return cJSON_CreateObject();
|
|
cJSON *doc = cJSON_Parse(text + cst_bom_len(text, strlen(text)));
|
|
free(text);
|
|
if (!cJSON_IsObject(doc)) {
|
|
cJSON_Delete(doc);
|
|
return NULL;
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
static int write_user_doc(cJSON *doc, const char *path, char *err,
|
|
size_t err_sz) {
|
|
char *text = cJSON_Print(doc);
|
|
if (!text) {
|
|
snprintf(err, err_sz, "out of memory");
|
|
return -1;
|
|
}
|
|
char tmp[1200];
|
|
if (snprintf(tmp, sizeof(tmp), "%s.tmp", path) >= (int)sizeof(tmp)) {
|
|
cJSON_free(text);
|
|
snprintf(err, err_sz, "path too long");
|
|
return -1;
|
|
}
|
|
int rc = -1;
|
|
FILE *f = fopen(tmp, "wb");
|
|
if (!f) {
|
|
snprintf(err, err_sz, "%s: cannot write (%s)", tmp, strerror(errno));
|
|
} else {
|
|
const int ok = fputs(text, f) >= 0 && fputc('\n', f) != EOF;
|
|
const int closed = fclose(f);
|
|
if (ok && closed == 0 &&
|
|
#ifdef _WIN32
|
|
(remove(path), 1) &&
|
|
#endif
|
|
rename(tmp, path) == 0)
|
|
rc = 0;
|
|
else
|
|
snprintf(err, err_sz, "%s: cannot replace (%s)", path, strerror(errno));
|
|
if (rc != 0)
|
|
remove(tmp);
|
|
}
|
|
cJSON_free(text);
|
|
return rc;
|
|
}
|
|
|
|
int cst_models_user_set_provider(const char *name, const char *api_base,
|
|
const char *key_env, const char *default_model,
|
|
const char *const *models, int nmodels,
|
|
char *err, size_t err_sz) {
|
|
char dummy[8];
|
|
if (!err || !err_sz) {
|
|
err = dummy;
|
|
err_sz = sizeof(dummy);
|
|
}
|
|
err[0] = 0;
|
|
if (!valid_provider_name(name)) {
|
|
snprintf(err, err_sz,
|
|
"a provider name is 1-31 of letters, digits, '.', '_' or '-'");
|
|
return -1;
|
|
}
|
|
if (!api_base || !api_base[0]) {
|
|
snprintf(err, err_sz, "a base URL is needed");
|
|
return -1;
|
|
}
|
|
char path[1100];
|
|
pthread_mutex_lock(&g_mu);
|
|
cJSON *doc = read_user_doc(path, sizeof(path));
|
|
if (!doc) {
|
|
snprintf(err, err_sz, "%s: not a JSON object, left alone", path);
|
|
pthread_mutex_unlock(&g_mu);
|
|
return -1;
|
|
}
|
|
cJSON *providers = cJSON_GetObjectItemCaseSensitive(doc, "providers");
|
|
if (!cJSON_IsObject(providers))
|
|
providers = cJSON_AddObjectToObject(doc, "providers");
|
|
cJSON *entry = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(entry, "api_base", api_base);
|
|
if (key_env && key_env[0])
|
|
cJSON_AddStringToObject(entry, "key_env", key_env);
|
|
if (default_model && default_model[0])
|
|
cJSON_AddStringToObject(entry, "default_model", default_model);
|
|
if (models && nmodels > 0) {
|
|
cJSON *arr = cJSON_AddArrayToObject(entry, "models");
|
|
for (int i = 0; i < nmodels; i++)
|
|
if (models[i] && models[i][0])
|
|
cJSON_AddItemToArray(arr, cJSON_CreateString(models[i]));
|
|
}
|
|
cJSON_DeleteItemFromObjectCaseSensitive(providers, name);
|
|
cJSON_AddItemToObject(providers, name, entry);
|
|
const int rc = write_user_doc(doc, path, err, err_sz);
|
|
cJSON_Delete(doc);
|
|
pthread_mutex_unlock(&g_mu);
|
|
if (rc == 0)
|
|
cst_models_reload(); /* the provider is there from now on */
|
|
return rc;
|
|
}
|
|
|
|
int cst_models_user_drop_provider(const char *name, char *err, size_t err_sz) {
|
|
char dummy[8];
|
|
if (!err || !err_sz) {
|
|
err = dummy;
|
|
err_sz = sizeof(dummy);
|
|
}
|
|
err[0] = 0;
|
|
if (!valid_provider_name(name)) {
|
|
snprintf(err, err_sz, "not a provider name");
|
|
return -1;
|
|
}
|
|
char path[1100];
|
|
if (cst_models_user_path(path, sizeof(path)) != 0) {
|
|
snprintf(err, err_sz, "no config directory");
|
|
return -1;
|
|
}
|
|
FILE *probe = fopen(path, "rb");
|
|
if (!probe)
|
|
return 0; /* no user file: nothing of ours to drop */
|
|
fclose(probe);
|
|
pthread_mutex_lock(&g_mu);
|
|
cJSON *doc = read_user_doc(path, sizeof(path));
|
|
if (!doc) {
|
|
snprintf(err, err_sz, "%s: not a JSON object, left alone", path);
|
|
pthread_mutex_unlock(&g_mu);
|
|
return -1;
|
|
}
|
|
cJSON *providers = cJSON_GetObjectItemCaseSensitive(doc, "providers");
|
|
const int found = cJSON_IsObject(providers) &&
|
|
cJSON_GetObjectItemCaseSensitive(providers, name) != NULL;
|
|
if (found)
|
|
cJSON_DeleteItemFromObjectCaseSensitive(providers, name);
|
|
const int rc = found ? write_user_doc(doc, path, err, err_sz) : 0;
|
|
cJSON_Delete(doc);
|
|
pthread_mutex_unlock(&g_mu);
|
|
if (rc == 0 && found)
|
|
cst_models_reload();
|
|
return rc == 0 ? found : -1;
|
|
}
|
|
|
|
static void load_default_locked(void) {
|
|
reset_locked();
|
|
char e[128];
|
|
if (parse_doc(BUILTIN, 0, e, sizeof(e)) != 0)
|
|
snprintf(R.err, sizeof(R.err), "built-in model data: %s", e);
|
|
char path[1100];
|
|
char *user = cst_models_user_path(path, sizeof(path)) == 0
|
|
? read_file(path)
|
|
: NULL;
|
|
if (user && parse_doc(user, 1, e, sizeof(e)) != 0)
|
|
snprintf(R.err, sizeof(R.err), "%s: %s", path, e);
|
|
free(user);
|
|
load_caches_locked();
|
|
R.loaded = 1;
|
|
}
|
|
|
|
static void ensure_locked(void) {
|
|
if (!R.loaded)
|
|
load_default_locked();
|
|
}
|
|
|
|
void cst_models_reload(void) {
|
|
pthread_mutex_lock(&g_mu);
|
|
load_default_locked();
|
|
pthread_mutex_unlock(&g_mu);
|
|
}
|
|
|
|
int cst_models_load_json(const char *builtin_json, const char *user_json,
|
|
char *err, size_t err_sz) {
|
|
char dummy[8];
|
|
if (!err || !err_sz) {
|
|
err = dummy;
|
|
err_sz = sizeof(dummy);
|
|
}
|
|
err[0] = 0;
|
|
pthread_mutex_lock(&g_mu);
|
|
reset_locked();
|
|
int rc = 0;
|
|
if (builtin_json && parse_doc(builtin_json, 0, err, err_sz) != 0)
|
|
rc = -1;
|
|
if (rc == 0 && user_json && parse_doc(user_json, 1, err, err_sz) != 0)
|
|
rc = -1;
|
|
load_caches_locked();
|
|
R.loaded = 1;
|
|
pthread_mutex_unlock(&g_mu);
|
|
return rc;
|
|
}
|
|
|
|
const char *cst_models_load_error(void) {
|
|
pthread_mutex_lock(&g_mu);
|
|
ensure_locked();
|
|
pthread_mutex_unlock(&g_mu);
|
|
return R.err;
|
|
}
|
|
|
|
/* --- providers ----------------------------------------------------------- */
|
|
|
|
const cst_provider_t *cst_provider_find(const char *name) {
|
|
if (!name || !name[0])
|
|
return NULL;
|
|
pthread_mutex_lock(&g_mu);
|
|
ensure_locked();
|
|
prov_t *p = prov_by_name(name);
|
|
pthread_mutex_unlock(&g_mu);
|
|
return p ? &p->pub : NULL;
|
|
}
|
|
|
|
const cst_provider_t *cst_provider_default(void) {
|
|
const cst_provider_t *p = cst_provider_find(getenv("CST_PROVIDER"));
|
|
return p ? p : cst_provider_at(0);
|
|
}
|
|
|
|
int cst_provider_count(void) {
|
|
pthread_mutex_lock(&g_mu);
|
|
ensure_locked();
|
|
int n = R.nprov;
|
|
pthread_mutex_unlock(&g_mu);
|
|
return n;
|
|
}
|
|
|
|
const cst_provider_t *cst_provider_at(int i) {
|
|
pthread_mutex_lock(&g_mu);
|
|
ensure_locked();
|
|
const cst_provider_t *p = i >= 0 && i < R.nprov ? &R.prov[i].pub : NULL;
|
|
pthread_mutex_unlock(&g_mu);
|
|
return p;
|
|
}
|
|
|
|
const char *cst_provider_source(const cst_provider_t *p) {
|
|
for (int i = 0; p && i < MAX_PROV; i++)
|
|
if (p == &R.prov[i].pub)
|
|
return R.prov[i].source ? R.prov[i].source : "built-in";
|
|
return "";
|
|
}
|
|
|
|
int cst_provider_models(const cst_provider_t *p, char out[][128], int max) {
|
|
int n = 0;
|
|
pthread_mutex_lock(&g_mu);
|
|
for (int i = 0; p && i < R.nprov; i++) {
|
|
if (p != &R.prov[i].pub)
|
|
continue;
|
|
for (int k = 0; k < R.prov[i].nmodels && n < max; k++)
|
|
snprintf(out[n++], 128, "%s", R.prov[i].models[k]);
|
|
}
|
|
pthread_mutex_unlock(&g_mu);
|
|
return n;
|
|
}
|
|
|
|
static int push_name(char out[][128], int *n, int max, const char *name) {
|
|
if (!name || !name[0] || *n >= max)
|
|
return 0;
|
|
for (int i = 0; i < *n; i++)
|
|
if (strcmp(out[i], name) == 0)
|
|
return 0;
|
|
snprintf(out[(*n)++], 128, "%s", name);
|
|
return 1;
|
|
}
|
|
|
|
int cst_models_list(const char *current, char out[][128], int max_out) {
|
|
if (!out || max_out <= 0)
|
|
return 0;
|
|
int n = 0;
|
|
const char *env = getenv("CST_MODELS");
|
|
if (env && env[0]) {
|
|
char buf[1024];
|
|
snprintf(buf, sizeof(buf), "%s", env);
|
|
for (char *tok = buf; *tok;) {
|
|
while (*tok == ':' || *tok == ',' || *tok == ' ')
|
|
tok++;
|
|
char *end = tok;
|
|
while (*end && *end != ':' && *end != ',')
|
|
end++;
|
|
char save = *end;
|
|
*end = 0;
|
|
char *t = end;
|
|
while (t > tok && (t[-1] == ' ' || t[-1] == '\t'))
|
|
*--t = 0;
|
|
push_name(out, &n, max_out, tok);
|
|
*end = save;
|
|
tok = *end ? end + 1 : end;
|
|
}
|
|
} else {
|
|
pthread_mutex_lock(&g_mu);
|
|
ensure_locked();
|
|
for (int i = 0; i < R.nprov; i++)
|
|
for (int k = 0; k < R.prov[i].nmodels; k++)
|
|
push_name(out, &n, max_out, R.prov[i].models[k]);
|
|
pthread_mutex_unlock(&g_mu);
|
|
}
|
|
push_name(out, &n, max_out, current);
|
|
return n;
|
|
}
|
|
|
|
/* --- model lookup -------------------------------------------------------- */
|
|
|
|
/* '*' matches any run; without one the pattern is a prefix. */
|
|
static int glob(const char *p, const char *s) {
|
|
if (!strchr(p, '*'))
|
|
return strncmp(s, p, strlen(p)) == 0;
|
|
while (*p) {
|
|
if (*p == '*') {
|
|
while (*p == '*')
|
|
p++;
|
|
if (!*p)
|
|
return 1;
|
|
for (; *s; s++)
|
|
if (glob(p, s))
|
|
return 1;
|
|
return 0;
|
|
}
|
|
if (*p != *s)
|
|
return 0;
|
|
p++;
|
|
s++;
|
|
}
|
|
return !*s;
|
|
}
|
|
|
|
static void set_field(cst_model_info_t *o, unsigned f, const rule_t *r) {
|
|
switch (f) {
|
|
case F_CONTEXT:
|
|
o->context = r->context;
|
|
break;
|
|
case F_VISION:
|
|
o->vision = r->vision;
|
|
break;
|
|
case F_CACHE:
|
|
o->cache_style = r->cache;
|
|
break;
|
|
case F_REASONING:
|
|
o->reasoning = r->reasoning;
|
|
break;
|
|
case F_PRICE:
|
|
o->price = r->price;
|
|
o->has_price = 1;
|
|
break;
|
|
}
|
|
}
|
|
|
|
static void add_source(cst_model_info_t *o, const char *src) {
|
|
if (strstr(o->source, src))
|
|
return;
|
|
size_t l = strlen(o->source);
|
|
snprintf(o->source + l, sizeof(o->source) - l, "%s%s", l ? "+" : "", src);
|
|
}
|
|
|
|
static void apply_rules(const rule_t *rules, int n, const char *full,
|
|
const char *bare, unsigned *have, cst_model_info_t *o,
|
|
const char *src) {
|
|
for (unsigned f = 1; f <= F_PRICE; f <<= 1) {
|
|
if (*have & f)
|
|
continue;
|
|
const rule_t *best = NULL;
|
|
for (int i = 0; i < n; i++) {
|
|
const rule_t *r = &rules[i];
|
|
if (!(r->has & f) || (best && r->spec <= best->spec))
|
|
continue;
|
|
if (glob(r->match, strchr(r->match, '/') ? full : bare))
|
|
best = r;
|
|
}
|
|
if (best) {
|
|
set_field(o, f, best);
|
|
*have |= f;
|
|
add_source(o, src);
|
|
}
|
|
}
|
|
}
|
|
|
|
static void apply_cache(const char *provider, const char *model,
|
|
unsigned *have, cst_model_info_t *o) {
|
|
for (int i = 0; i < R.ncache; i++) {
|
|
const cached_t *c = &R.cache[i];
|
|
if ((provider && strcmp(c->provider, provider) != 0) ||
|
|
strcmp(c->m.id, model) != 0)
|
|
continue;
|
|
unsigned before = *have;
|
|
if (!(*have & F_CONTEXT) && c->m.context > 0) {
|
|
o->context = c->m.context;
|
|
*have |= F_CONTEXT;
|
|
}
|
|
if (!(*have & F_VISION) && c->m.vision >= 0) {
|
|
o->vision = c->m.vision;
|
|
*have |= F_VISION;
|
|
}
|
|
if (!(*have & F_PRICE) && c->m.has_price) {
|
|
memset(&o->price, 0, sizeof(o->price));
|
|
snprintf(o->price.currency, sizeof(o->price.currency), "$");
|
|
o->price.miss = c->m.miss;
|
|
o->price.hit = c->m.hit;
|
|
o->price.write = c->m.write;
|
|
o->price.out = c->m.out;
|
|
o->has_price = 1;
|
|
*have |= F_PRICE;
|
|
}
|
|
if (*have != before)
|
|
add_source(o, "api");
|
|
return;
|
|
}
|
|
}
|
|
|
|
static int env_price(cst_price_t *out) {
|
|
const char *e = getenv("CST_PRICE");
|
|
if (!e || !e[0])
|
|
return -1;
|
|
memset(out, 0, sizeof(*out));
|
|
char cur[8] = "";
|
|
int n = sscanf(e, "%lf,%lf,%lf,%lf,%7s", &out->miss, &out->hit, &out->out,
|
|
&out->write, cur);
|
|
if (n < 3)
|
|
return -1;
|
|
if (n < 4)
|
|
out->write = out->miss;
|
|
snprintf(out->currency, sizeof(out->currency), "%s", cur);
|
|
return 0;
|
|
}
|
|
|
|
void cst_model_info(const char *provider, const char *model,
|
|
cst_model_info_t *out) {
|
|
memset(out, 0, sizeof(*out));
|
|
out->vision = -1;
|
|
unsigned have = 0;
|
|
const char *e = getenv("CST_CTX_TOKENS");
|
|
if (e && atol(e) > 0) {
|
|
out->context = atol(e);
|
|
have |= F_CONTEXT;
|
|
add_source(out, "env");
|
|
}
|
|
if (env_price(&out->price) == 0) {
|
|
out->has_price = 1;
|
|
have |= F_PRICE;
|
|
add_source(out, "env");
|
|
}
|
|
if (model && model[0]) {
|
|
char full[256], bare[256];
|
|
lower_copy(full, sizeof(full), model);
|
|
const char *slash = strrchr(full, '/');
|
|
snprintf(bare, sizeof(bare), "%s", slash && slash[1] ? slash + 1 : full);
|
|
pthread_mutex_lock(&g_mu);
|
|
ensure_locked();
|
|
apply_rules(R.user, R.nuser, full, bare, &have, out, "user");
|
|
if (provider && !prov_by_name(provider))
|
|
provider = NULL;
|
|
apply_cache(provider, model, &have, out);
|
|
apply_rules(R.builtin, R.nbuiltin, full, bare, &have, out, "built-in");
|
|
pthread_mutex_unlock(&g_mu);
|
|
}
|
|
if (!(have & F_CONTEXT)) {
|
|
out->context = 128000;
|
|
add_source(out, "default");
|
|
}
|
|
}
|
|
|
|
void cst_model_info_label(const cst_model_info_t *mi, char *out, size_t n) {
|
|
char ctx[32];
|
|
if (mi->context >= 1000000)
|
|
snprintf(ctx, sizeof(ctx), "%.0fM", (double)mi->context / 1e6);
|
|
else
|
|
snprintf(ctx, sizeof(ctx), "%ldK", (mi->context + 500) / 1000);
|
|
snprintf(out, n, "%s%s", ctx,
|
|
mi->vision == 1 ? " · vision"
|
|
: mi->vision == 0 ? " · text only"
|
|
: "");
|
|
}
|
|
|
|
/* --- wrappers kept for the rest of the code ------------------------------ */
|
|
|
|
long cst_model_context_tokens(const char *model) {
|
|
cst_model_info_t i;
|
|
cst_model_info(NULL, model, &i);
|
|
return i.context;
|
|
}
|
|
|
|
int cst_model_price(const char *model, cst_price_t *out) {
|
|
cst_model_info_t i;
|
|
cst_model_info(NULL, model, &i);
|
|
*out = i.price;
|
|
if (!i.has_price)
|
|
memset(out, 0, sizeof(*out));
|
|
return i.has_price ? 0 : -1;
|
|
}
|
|
|
|
/* "https://api.deepseek.com/v1" -> "api.deepseek.com" */
|
|
static void host_of(const char *url, char *out, size_t n) {
|
|
const char *s = strstr(url, "://");
|
|
s = s ? s + 3 : url;
|
|
size_t i = 0;
|
|
for (; s[i] && s[i] != '/' && i + 1 < n; i++)
|
|
out[i] = s[i];
|
|
out[i] = 0;
|
|
}
|
|
|
|
void cst_model_caps(const char *provider, const char *api_base,
|
|
const char *model, cst_model_caps_t *out) {
|
|
memset(out, 0, sizeof(*out));
|
|
pthread_mutex_lock(&g_mu);
|
|
ensure_locked();
|
|
/* provider extensions follow the provider, or its host behind any name */
|
|
for (int i = 0; i < R.nprov; i++) {
|
|
const prov_t *p = &R.prov[i];
|
|
if (!p->passback && !p->prefix)
|
|
continue;
|
|
char host[256];
|
|
host_of(p->api_base, host, sizeof(host));
|
|
if ((provider && strcmp(provider, p->name) == 0) ||
|
|
(api_base && host[0] && strstr(api_base, host))) {
|
|
out->reasoning_passback |= p->passback;
|
|
out->prefix_completion |= p->prefix;
|
|
}
|
|
}
|
|
pthread_mutex_unlock(&g_mu);
|
|
cst_model_info_t mi;
|
|
cst_model_info(provider, model, &mi);
|
|
out->vision = mi.vision;
|
|
const char *e = getenv("CST_NO_CACHE_HINTS");
|
|
if ((e && e[0] == '1') || !model)
|
|
return;
|
|
out->cache_style = mi.cache_style;
|
|
}
|
|
|
|
const char *cst_effort_wire(const char *provider, const char *model,
|
|
const char *effort) {
|
|
if (!cst_effort_valid(effort) || !provider || !model)
|
|
return NULL;
|
|
char mode[16] = "";
|
|
pthread_mutex_lock(&g_mu);
|
|
ensure_locked();
|
|
const prov_t *p = prov_by_name(provider);
|
|
if (p)
|
|
snprintf(mode, sizeof(mode), "%s", p->effort);
|
|
pthread_mutex_unlock(&g_mu);
|
|
if (strcmp(mode, "passthrough") == 0)
|
|
return effort;
|
|
if (strcmp(mode, "openai") == 0) {
|
|
cst_model_info_t mi;
|
|
cst_model_info(provider, model, &mi);
|
|
if (mi.reasoning)
|
|
return strcmp(effort, "max") == 0 ? "high" : effort;
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
/* --- fetched cache ------------------------------------------------------- */
|
|
|
|
int cst_models_cache_save(const char *provider, const char *api_base,
|
|
const cst_model_meta_t *list, int n) {
|
|
char path[1100];
|
|
if (!list || n < 0 || cache_path(provider, path, sizeof(path)) != 0)
|
|
return -1;
|
|
char dir[1100];
|
|
snprintf(dir, sizeof(dir), "%s", path);
|
|
char *sl = strrchr(dir, '/');
|
|
if (sl)
|
|
*sl = 0;
|
|
os_mkdir_p(dir, 0700);
|
|
|
|
long now = (long)time(NULL);
|
|
cJSON *doc = cJSON_CreateObject();
|
|
cJSON_AddNumberToObject(doc, "fetched_at", (double)now);
|
|
if (api_base)
|
|
cJSON_AddStringToObject(doc, "api_base", api_base);
|
|
cJSON *arr = cJSON_AddArrayToObject(doc, "models");
|
|
for (int i = 0; i < n; i++) {
|
|
const cst_model_meta_t *m = &list[i];
|
|
cJSON *o = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(o, "id", m->id);
|
|
if (m->context > 0)
|
|
cJSON_AddNumberToObject(o, "context", (double)m->context);
|
|
if (m->vision >= 0)
|
|
cJSON_AddBoolToObject(o, "vision", m->vision);
|
|
if (m->has_price) {
|
|
cJSON *p = cJSON_AddObjectToObject(o, "price");
|
|
cJSON_AddStringToObject(p, "currency", "$");
|
|
cJSON_AddNumberToObject(p, "miss", m->miss);
|
|
cJSON_AddNumberToObject(p, "hit", m->hit);
|
|
cJSON_AddNumberToObject(p, "write", m->write);
|
|
cJSON_AddNumberToObject(p, "out", m->out);
|
|
}
|
|
cJSON_AddItemToArray(arr, o);
|
|
}
|
|
char *txt = cJSON_Print(doc);
|
|
cJSON_Delete(doc);
|
|
if (!txt)
|
|
return -1;
|
|
char tmp[1200];
|
|
snprintf(tmp, sizeof(tmp), "%s.tmp", path);
|
|
FILE *f = fopen(tmp, "wb");
|
|
int rc = -1;
|
|
if (f) {
|
|
size_t len = strlen(txt);
|
|
rc = fwrite(txt, 1, len, f) == len ? 0 : -1;
|
|
if (fclose(f) != 0)
|
|
rc = -1;
|
|
if (rc == 0)
|
|
rc = os_rename_replace(tmp, path);
|
|
if (rc != 0)
|
|
remove(tmp);
|
|
}
|
|
free(txt);
|
|
if (rc == 0) {
|
|
pthread_mutex_lock(&g_mu);
|
|
ensure_locked();
|
|
drop_cache(provider);
|
|
load_cache(provider);
|
|
pthread_mutex_unlock(&g_mu);
|
|
}
|
|
return rc;
|
|
}
|
|
|
|
int cst_models_cache_load(const char *provider, cst_model_meta_t *out, int max,
|
|
long *age_s) {
|
|
if (age_s)
|
|
*age_s = 0;
|
|
if (!provider)
|
|
return -1;
|
|
pthread_mutex_lock(&g_mu);
|
|
ensure_locked();
|
|
int n = 0, found = 0;
|
|
for (int i = 0; i < R.ncache; i++) {
|
|
const cached_t *c = &R.cache[i];
|
|
if (strcmp(c->provider, provider) != 0)
|
|
continue;
|
|
if (!found && age_s)
|
|
*age_s = (long)time(NULL) - c->fetched_at;
|
|
found = 1;
|
|
if (out && n < max)
|
|
out[n++] = c->m;
|
|
}
|
|
pthread_mutex_unlock(&g_mu);
|
|
return found ? n : -1;
|
|
}
|