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:
@@ -159,7 +159,177 @@ static void test_tool_cap_head_tail(void) {
|
||||
free(big);
|
||||
}
|
||||
|
||||
static cJSON *build_req(const cst_llm_request_t *r) {
|
||||
char *body = cst_llm_build_body(r);
|
||||
EXPECT(body != NULL, "body built");
|
||||
cJSON *j = body ? cJSON_Parse(body) : NULL;
|
||||
EXPECT(j != NULL, "body is valid JSON");
|
||||
free(body);
|
||||
return j;
|
||||
}
|
||||
|
||||
static const cJSON *msg_at(const cJSON *root, int i) {
|
||||
return cJSON_GetArrayItem(cJSON_GetObjectItemCaseSensitive(root, "messages"),
|
||||
i);
|
||||
}
|
||||
|
||||
static const cJSON *cache_mark(const cJSON *m) {
|
||||
const cJSON *c = cJSON_GetObjectItemCaseSensitive(m, "content");
|
||||
if (!cJSON_IsArray(c))
|
||||
return NULL;
|
||||
const cJSON *last = cJSON_GetArrayItem(c, cJSON_GetArraySize(c) - 1);
|
||||
return cJSON_GetObjectItemCaseSensitive(last, "cache_control");
|
||||
}
|
||||
|
||||
static void test_reasoning_and_prefix(void) {
|
||||
cst_message_t m[4];
|
||||
m[0] = msg(CST_ROLE_SYSTEM, "sys");
|
||||
m[1] = msg(CST_ROLE_USER, "go");
|
||||
m[2] = msg(CST_ROLE_ASSISTANT, "partial answer");
|
||||
m[2].reasoning = "thinking it over";
|
||||
m[3] = msg(CST_ROLE_ASSISTANT, "cut off here");
|
||||
m[3].prefix = 1;
|
||||
cst_llm_request_t r;
|
||||
memset(&r, 0, sizeof(r));
|
||||
r.model = "deepseek-flash";
|
||||
r.msgs = m;
|
||||
r.nmsgs = 4;
|
||||
cJSON *j = build_req(&r);
|
||||
EXPECT(!cJSON_GetObjectItemCaseSensitive(msg_at(j, 2), "reasoning_content"),
|
||||
"reasoning only when asked for");
|
||||
const cJSON *p = cJSON_GetObjectItemCaseSensitive(msg_at(j, 3), "prefix");
|
||||
EXPECT(cJSON_IsTrue(p), "prefix:true on the continued message");
|
||||
EXPECT(!cJSON_GetObjectItemCaseSensitive(msg_at(j, 2), "prefix"),
|
||||
"no prefix on history");
|
||||
cJSON_Delete(j);
|
||||
|
||||
r.send_reasoning = 1;
|
||||
j = build_req(&r);
|
||||
const cJSON *rc =
|
||||
cJSON_GetObjectItemCaseSensitive(msg_at(j, 2), "reasoning_content");
|
||||
EXPECT(cJSON_IsString(rc) && strcmp(rc->valuestring, "thinking it over") == 0,
|
||||
"reasoning_content passed back");
|
||||
EXPECT(!cJSON_GetObjectItemCaseSensitive(j, "prompt_cache_key"),
|
||||
"no cache key without a cache style");
|
||||
cJSON_Delete(j);
|
||||
}
|
||||
|
||||
static void test_cache_hints(void) {
|
||||
cst_message_t m[4];
|
||||
m[0] = msg(CST_ROLE_SYSTEM, "sys");
|
||||
m[1] = msg(CST_ROLE_USER, "first");
|
||||
m[2] = msg(CST_ROLE_ASSISTANT, "reply");
|
||||
m[3] = msg(CST_ROLE_USER, "second");
|
||||
cst_llm_request_t r;
|
||||
memset(&r, 0, sizeof(r));
|
||||
r.model = "gpt-4.1";
|
||||
r.msgs = m;
|
||||
r.nmsgs = 4;
|
||||
r.cache_style = CST_CACHE_OPENAI;
|
||||
r.cache_key = "sess-1";
|
||||
cJSON *j = build_req(&r);
|
||||
const cJSON *k = cJSON_GetObjectItemCaseSensitive(j, "prompt_cache_key");
|
||||
EXPECT(cJSON_IsString(k) && strcmp(k->valuestring, "sess-1") == 0,
|
||||
"prompt_cache_key");
|
||||
EXPECT(content_at(j, 3) && !cache_mark(msg_at(j, 3)),
|
||||
"OpenAI style leaves content as strings");
|
||||
cJSON_Delete(j);
|
||||
|
||||
r.model = "anthropic/claude-sonnet-4";
|
||||
r.cache_style = CST_CACHE_ANTHROPIC;
|
||||
r.cache_ttl = "1h";
|
||||
j = build_req(&r);
|
||||
EXPECT(!cJSON_GetObjectItemCaseSensitive(j, "prompt_cache_key"),
|
||||
"no prompt_cache_key for Anthropic");
|
||||
const cJSON *cs = cache_mark(msg_at(j, 0));
|
||||
const cJSON *cl = cache_mark(msg_at(j, 3));
|
||||
const cJSON *cu = cache_mark(msg_at(j, 1));
|
||||
EXPECT(cs && cl && cu, "breakpoints on system, newest, previous user");
|
||||
EXPECT(!cache_mark(msg_at(j, 2)), "no breakpoint on the assistant reply");
|
||||
const cJSON *ttl = cJSON_GetObjectItemCaseSensitive(cl, "ttl");
|
||||
EXPECT(cJSON_IsString(ttl) && strcmp(ttl->valuestring, "1h") == 0, "ttl");
|
||||
const cJSON *parts =
|
||||
cJSON_GetObjectItemCaseSensitive(msg_at(j, 3), "content");
|
||||
const cJSON *txt =
|
||||
cJSON_GetObjectItemCaseSensitive(cJSON_GetArrayItem(parts, 0), "text");
|
||||
EXPECT(cJSON_IsString(txt) && strcmp(txt->valuestring, "second") == 0,
|
||||
"marked content keeps its text");
|
||||
cJSON_Delete(j);
|
||||
}
|
||||
|
||||
static void test_tools_snapshot(void) {
|
||||
cst_message_t m[1] = {msg(CST_ROLE_USER, "go")};
|
||||
cst_llm_request_t r;
|
||||
memset(&r, 0, sizeof(r));
|
||||
r.model = "test-model";
|
||||
r.msgs = m;
|
||||
r.nmsgs = 1;
|
||||
r.enable_tools = 1;
|
||||
r.tools_json = "[{\"type\":\"function\",\"function\":{\"name\":\"only_me\","
|
||||
"\"parameters\":{\"type\":\"object\"}}}]";
|
||||
cJSON *j = build_req(&r);
|
||||
const cJSON *tools = cJSON_GetObjectItemCaseSensitive(j, "tools");
|
||||
EXPECT(cJSON_IsArray(tools) && cJSON_GetArraySize(tools) == 1,
|
||||
"tools[] taken from the snapshot");
|
||||
const cJSON *nm = cJSON_GetObjectItemCaseSensitive(
|
||||
cJSON_GetObjectItemCaseSensitive(cJSON_GetArrayItem(tools, 0),
|
||||
"function"),
|
||||
"name");
|
||||
EXPECT(cJSON_IsString(nm) && strcmp(nm->valuestring, "only_me") == 0,
|
||||
"snapshot tool name");
|
||||
cJSON_Delete(j);
|
||||
}
|
||||
|
||||
static void test_images_off(void) {
|
||||
cst_attachment_t img;
|
||||
memset(&img, 0, sizeof(img));
|
||||
snprintf(img.id, sizeof(img.id), "img-1");
|
||||
snprintf(img.rel, sizeof(img.rel), "img-1.png");
|
||||
snprintf(img.name, sizeof(img.name), "shot.png");
|
||||
snprintf(img.mime, sizeof(img.mime), "image/png");
|
||||
img.width = 640;
|
||||
img.height = 480;
|
||||
img.bytes = 2048;
|
||||
cst_message_t m[4];
|
||||
m[0] = msg(CST_ROLE_USER, "what is this");
|
||||
m[0].attach = &img;
|
||||
m[0].nattach = 1;
|
||||
m[1] = msg(CST_ROLE_ASSISTANT, "");
|
||||
m[1].tool_calls_json = "[{\"id\":\"c1\",\"type\":\"function\",\"function\":"
|
||||
"{\"name\":\"view_image\",\"arguments\":\"{}\"}}]";
|
||||
m[2] = msg(CST_ROLE_TOOL, "view_image shot.png");
|
||||
snprintf(m[2].tool_call_id, sizeof(m[2].tool_call_id), "c1");
|
||||
m[2].attach = &img;
|
||||
m[2].nattach = 1;
|
||||
m[3] = msg(CST_ROLE_USER, "and now?");
|
||||
cst_llm_request_t r;
|
||||
memset(&r, 0, sizeof(r));
|
||||
r.model = "deepseek-flash";
|
||||
r.msgs = m;
|
||||
r.nmsgs = 4;
|
||||
r.images_off = 1;
|
||||
r.session_path = "/nonexistent/s.jsonl";
|
||||
cJSON *j = build_req(&r);
|
||||
char *s = cJSON_PrintUnformatted(j);
|
||||
EXPECT(s && !strstr(s, "image_url"), "no image parts when images are off");
|
||||
free(s);
|
||||
const char *c0 = content_at(j, 0);
|
||||
EXPECT(c0 && strstr(c0, "what is this") &&
|
||||
strstr(c0, "[image \"shot.png\" (640x480") &&
|
||||
strstr(c0, "not sent: this model does not take images"),
|
||||
"user image becomes a plain-text note");
|
||||
const char *c2 = content_at(j, 2);
|
||||
EXPECT(c2 && strstr(c2, "view_image shot.png") && strstr(c2, "not sent"),
|
||||
"tool image noted inside the tool result");
|
||||
EXPECT(nmsgs(j) == 4, "no extra user message for deferred tool images");
|
||||
cJSON_Delete(j);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
test_images_off();
|
||||
test_reasoning_and_prefix();
|
||||
test_cache_hints();
|
||||
test_tools_snapshot();
|
||||
test_escaping();
|
||||
test_invalid_utf8();
|
||||
test_tool_calls_and_null_content();
|
||||
|
||||
Reference in New Issue
Block a user