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
224 lines
8.7 KiB
C
224 lines
8.7 KiB
C
/* Model registry: built-in data, longest/most specific match, the user file,
|
|
* the fetched cache and the env overrides. */
|
|
#define _POSIX_C_SOURCE 200809L
|
|
|
|
#include "cJSON.h"
|
|
#include "cst_agent.h"
|
|
#include "cst_models.h"
|
|
#include "cst_test_util.h"
|
|
#include "os_compat.h"
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
static int nfail;
|
|
|
|
#define EXPECT(cond, msg) \
|
|
do { \
|
|
if (!(cond)) { \
|
|
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
|
|
nfail++; \
|
|
} \
|
|
} while (0)
|
|
|
|
static void test_builtin(void) {
|
|
cJSON *doc = cJSON_Parse(cst_models_builtin_json());
|
|
EXPECT(cJSON_IsObject(doc), "embedded built-in JSON parses");
|
|
cJSON_Delete(doc);
|
|
EXPECT(cst_models_load_error()[0] == 0, "no load error");
|
|
EXPECT(cst_provider_count() >= 4, "built-in providers");
|
|
const cst_provider_t *p = cst_provider_at(0);
|
|
EXPECT(p && strcmp(p->name, "minimax") == 0, "file order kept");
|
|
p = cst_provider_find("deepseek");
|
|
EXPECT(p && strcmp(p->model, "deepseek-flash") == 0 &&
|
|
strcmp(p->key_env, "DEEPSEEK_API_KEY") == 0 &&
|
|
strcmp(cst_provider_source(p), "built-in") == 0,
|
|
"deepseek preset");
|
|
|
|
cst_model_info_t mi;
|
|
/* most specific rule per field: price from deepseek-v4-pro, context from
|
|
* deepseek */
|
|
cst_model_info("deepseek", "deepseek-v4-pro", &mi);
|
|
EXPECT(mi.context == 1000000 && mi.vision == 0, "context inherited");
|
|
EXPECT(mi.has_price && mi.price.miss == 9.0 && mi.price.offpeak_half,
|
|
"longer match wins the price");
|
|
EXPECT(strcmp(mi.source, "built-in") == 0, "source built-in");
|
|
cst_model_info(NULL, "gpt-4o-mini-2024", &mi);
|
|
EXPECT(mi.price.miss == 0.15 && mi.context == 128000 && mi.vision == 1,
|
|
"gpt-4o-mini over gpt-4o");
|
|
cst_model_info(NULL, "Qwen/Qwen2.5-VL-72B-Instruct", &mi);
|
|
EXPECT(mi.vision == 1 && mi.context == 131072, "glob *-vl* + qwen prefix");
|
|
cst_model_info(NULL, "zhipu/glm-4.5v", &mi);
|
|
EXPECT(mi.vision == 1 && mi.context == 128000, "glm-*v*");
|
|
cst_model_info(NULL, "glm-4-plus", &mi);
|
|
EXPECT(mi.vision == -1, "glm without v: unknown");
|
|
cst_model_info(NULL, "anthropic/some-new-model", &mi);
|
|
EXPECT(mi.cache_style == CST_CACHE_ANTHROPIC, "vendor rule on the full id");
|
|
cst_model_info(NULL, "totally-unknown", &mi);
|
|
EXPECT(mi.context == 128000 && mi.vision == -1 && !mi.has_price &&
|
|
strcmp(mi.source, "default") == 0,
|
|
"defaults");
|
|
char tag[48];
|
|
cst_model_info(NULL, "gpt-4.1", &mi);
|
|
cst_model_info_label(&mi, tag, sizeof(tag));
|
|
EXPECT(strcmp(tag, "1M · vision") == 0, "label");
|
|
}
|
|
|
|
static const char *USER_JSON =
|
|
"{\"providers\": {"
|
|
" \"deepseek\": {\"api_base\": \"https://proxy.example/v1\"},"
|
|
" \"local\": {\"api_base\": \"http://127.0.0.1:8000/v1\","
|
|
" \"key_env\": \"LOCAL_KEY\", \"default_model\": \"qwen3-32b\","
|
|
" \"models\": [\"qwen3-32b\", \"my-vl\"]}"
|
|
"},"
|
|
"\"models\": ["
|
|
" {\"match\": \"qwen3\", \"context\": 32768},"
|
|
" {\"match\": \"my-vl\", \"vision\": true, \"price\": {\"miss\": 1,"
|
|
" \"out\": 2, \"currency\": \"¥\"}}"
|
|
"]}";
|
|
|
|
static void test_user(void) {
|
|
char err[256];
|
|
EXPECT(cst_models_load_json(cst_models_builtin_json(), USER_JSON, err,
|
|
sizeof(err)) == 0,
|
|
"load with user file");
|
|
const cst_provider_t *ds = cst_provider_find("deepseek");
|
|
EXPECT(ds && strcmp(ds->api_base, "https://proxy.example/v1") == 0 &&
|
|
strcmp(ds->model, "deepseek-flash") == 0 &&
|
|
strcmp(cst_provider_source(ds), "user") == 0,
|
|
"user overrides one field, keeps the rest");
|
|
const cst_provider_t *lp = cst_provider_find("local");
|
|
EXPECT(lp && strcmp(lp->model, "qwen3-32b") == 0 &&
|
|
strcmp(lp->key_env, "LOCAL_KEY") == 0,
|
|
"user adds a provider");
|
|
EXPECT(cst_provider_at(cst_provider_count() - 1) == lp, "appended last");
|
|
|
|
cst_model_info_t mi;
|
|
cst_model_info("local", "qwen3-32b", &mi);
|
|
EXPECT(mi.context == 32768 && strcmp(mi.source, "user") == 0,
|
|
"user rule beats built-in qwen");
|
|
cst_model_info("local", "my-vl", &mi);
|
|
EXPECT(mi.vision == 1 && mi.has_price && mi.price.hit == 1 &&
|
|
mi.price.write == 1 && strcmp(mi.price.currency, "¥") == 0,
|
|
"user price, hit/write default to miss");
|
|
|
|
/* passback follows the provider name even behind a proxy base */
|
|
cst_model_caps_t caps;
|
|
cst_model_caps("deepseek", "https://proxy.example/v1", "deepseek-flash",
|
|
&caps);
|
|
EXPECT(caps.reasoning_passback && caps.prefix_completion, "caps by name");
|
|
|
|
char names[32][128];
|
|
int n = cst_models_list(NULL, names, 32);
|
|
int seen = 0;
|
|
for (int i = 0; i < n; i++)
|
|
seen += strcmp(names[i], "my-vl") == 0;
|
|
EXPECT(seen == 1, "picker list includes user models");
|
|
|
|
EXPECT(cst_models_load_json(cst_models_builtin_json(), "{not json", err,
|
|
sizeof(err)) != 0 &&
|
|
err[0],
|
|
"bad user file reported");
|
|
}
|
|
|
|
static void test_cache(void) {
|
|
cst_models_load_json(cst_models_builtin_json(), NULL, NULL, 0);
|
|
cst_model_meta_t m[3];
|
|
memset(m, 0, sizeof(m));
|
|
snprintf(m[0].id, sizeof(m[0].id), "anthropic/claude-sonnet-4");
|
|
m[0].context = 1000000;
|
|
m[0].vision = -1;
|
|
snprintf(m[1].id, sizeof(m[1].id), "deepseek-flash");
|
|
m[1].vision = 1; /* API claims vision */
|
|
m[1].has_price = 1;
|
|
m[1].miss = 0.5;
|
|
m[1].hit = 0.1;
|
|
m[1].write = 0.5;
|
|
m[1].out = 1;
|
|
snprintf(m[2].id, sizeof(m[2].id), "brand-new");
|
|
m[2].vision = -1;
|
|
EXPECT(cst_models_cache_save("teamorouter", "https://x/v1", m, 3) == 0,
|
|
"cache saved");
|
|
|
|
cst_model_info_t mi;
|
|
cst_model_info("teamorouter", "anthropic/claude-sonnet-4", &mi);
|
|
EXPECT(mi.context == 1000000, "API context beats built-in");
|
|
EXPECT(mi.vision == 1 && mi.cache_style == CST_CACHE_ANTHROPIC,
|
|
"missing API fields fall through to built-in");
|
|
EXPECT(strcmp(mi.source, "api+built-in") == 0, "source lists both");
|
|
cst_model_info("teamorouter", "deepseek-flash", &mi);
|
|
EXPECT(mi.vision == 1 && mi.price.miss == 0.5 &&
|
|
strcmp(mi.price.currency, "$") == 0,
|
|
"API vision and USD price");
|
|
cst_model_info("deepseek", "deepseek-flash", &mi);
|
|
EXPECT(mi.vision == 0 && mi.price.miss == 2.0,
|
|
"another provider's cache is not used");
|
|
EXPECT(cst_model_context_tokens("anthropic/claude-sonnet-4") == 1000000,
|
|
"no provider: any cache, exact id");
|
|
|
|
cst_model_meta_t back[8];
|
|
long age = -1;
|
|
int n = cst_models_cache_load("teamorouter", back, 8, &age);
|
|
EXPECT(n == 3 && age >= 0 && age < 60 &&
|
|
strcmp(back[2].id, "brand-new") == 0,
|
|
"cache reads back");
|
|
EXPECT(cst_models_cache_load("openai", back, 8, &age) == -1, "no cache");
|
|
EXPECT(cst_models_cache_save("../evil", NULL, m, 1) != 0,
|
|
"provider name cannot leave the cache dir");
|
|
|
|
/* a fresh process (reload) sees the file */
|
|
cst_models_reload();
|
|
cst_model_info("teamorouter", "anthropic/claude-sonnet-4", &mi);
|
|
EXPECT(mi.context == 1000000, "cache survives a reload");
|
|
}
|
|
|
|
static void test_env(void) {
|
|
cst_models_load_json(cst_models_builtin_json(), NULL, NULL, 0);
|
|
setenv("CST_CTX_TOKENS", "4242", 1);
|
|
setenv("CST_PRICE", "1,0.5,3", 1);
|
|
cst_model_info_t mi;
|
|
cst_model_info(NULL, "deepseek-flash", &mi);
|
|
EXPECT(mi.context == 4242 && mi.price.miss == 1 && mi.price.write == 1 &&
|
|
strstr(mi.source, "env"),
|
|
"env overrides");
|
|
unsetenv("CST_CTX_TOKENS");
|
|
unsetenv("CST_PRICE");
|
|
setenv("CST_NO_CACHE_HINTS", "1", 1);
|
|
cst_model_caps_t caps;
|
|
cst_model_caps("openai", NULL, "gpt-4.1", &caps);
|
|
EXPECT(caps.cache_style == CST_CACHE_AUTO && caps.vision == 1,
|
|
"no cache hints, vision kept");
|
|
unsetenv("CST_NO_CACHE_HINTS");
|
|
setenv("CST_MODELS", "a, b:c", 1);
|
|
char names[8][128];
|
|
int n = cst_models_list("cur", names, 8);
|
|
EXPECT(n == 4 && strcmp(names[1], "b") == 0 && strcmp(names[3], "cur") == 0,
|
|
"CST_MODELS list");
|
|
unsetenv("CST_MODELS");
|
|
}
|
|
|
|
int main(void) {
|
|
char root[1024];
|
|
if (test_tmpdir(root, sizeof(root), "cst-models") != 0) {
|
|
fprintf(stderr, "cannot create a scratch dir\n");
|
|
return 1;
|
|
}
|
|
setenv("XDG_CONFIG_HOME", root, 1); /* no real models.json or caches */
|
|
unsetenv("CST_CTX_TOKENS");
|
|
unsetenv("CST_PRICE");
|
|
unsetenv("CST_MODELS");
|
|
unsetenv("CST_NO_CACHE_HINTS");
|
|
test_builtin();
|
|
test_user();
|
|
test_cache();
|
|
test_env();
|
|
test_rmrf(root);
|
|
if (nfail) {
|
|
fprintf(stderr, "test_model_registry: %d failed\n", nfail);
|
|
return 1;
|
|
}
|
|
printf("test_model_registry: OK\n");
|
|
return 0;
|
|
}
|