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:
@@ -0,0 +1,543 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "cst_attach.h"
|
||||
#include "cst_agent.h"
|
||||
#include "cst_clipboard.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 put_be32(unsigned char *p, unsigned v) {
|
||||
p[0] = (unsigned char)(v >> 24);
|
||||
p[1] = (unsigned char)(v >> 16);
|
||||
p[2] = (unsigned char)(v >> 8);
|
||||
p[3] = (unsigned char)v;
|
||||
}
|
||||
|
||||
static void put_le16(unsigned char *p, unsigned v) {
|
||||
p[0] = (unsigned char)(v & 0xFF);
|
||||
p[1] = (unsigned char)((v >> 8) & 0xFF);
|
||||
}
|
||||
|
||||
/* 8 signature + 4 length + "IHDR" + w + h (what cst_attach_dimensions reads). */
|
||||
static size_t png_hdr(unsigned char *b, unsigned w, unsigned h) {
|
||||
static const unsigned char sig[8] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
|
||||
memcpy(b, sig, 8);
|
||||
b[8] = b[9] = b[10] = 0;
|
||||
b[11] = 13;
|
||||
memcpy(b + 12, "IHDR", 4);
|
||||
put_be32(b + 16, w);
|
||||
put_be32(b + 20, h);
|
||||
return 32;
|
||||
}
|
||||
|
||||
static size_t gif_hdr(unsigned char *b, unsigned w, unsigned h) {
|
||||
memcpy(b, "GIF89a", 6);
|
||||
put_le16(b + 6, w);
|
||||
put_le16(b + 8, h);
|
||||
return 16;
|
||||
}
|
||||
|
||||
static void put_le32(unsigned char *p, unsigned v) {
|
||||
p[0] = (unsigned char)(v & 0xFF);
|
||||
p[1] = (unsigned char)((v >> 8) & 0xFF);
|
||||
p[2] = (unsigned char)((v >> 16) & 0xFF);
|
||||
p[3] = (unsigned char)((v >> 24) & 0xFF);
|
||||
}
|
||||
|
||||
static size_t bmp_hdr(unsigned char *b, int w, int h) {
|
||||
memset(b, 0, 64);
|
||||
b[0] = 'B';
|
||||
b[1] = 'M';
|
||||
put_le32(b + 18, (unsigned)w);
|
||||
put_le32(b + 22, (unsigned)h);
|
||||
return 64;
|
||||
}
|
||||
|
||||
/* FFD8 + a filler APP0 segment + SOF0 carrying the frame size. */
|
||||
static size_t jpeg_hdr(unsigned char *b, unsigned w, unsigned h) {
|
||||
size_t i = 0;
|
||||
b[i++] = 0xFF;
|
||||
b[i++] = 0xD8;
|
||||
b[i++] = 0xFF;
|
||||
b[i++] = 0xE0;
|
||||
b[i++] = 0x00;
|
||||
b[i++] = 0x04;
|
||||
b[i++] = 0x00;
|
||||
b[i++] = 0x00;
|
||||
b[i++] = 0xFF;
|
||||
b[i++] = 0xC0;
|
||||
b[i++] = 0x00;
|
||||
b[i++] = 0x0B;
|
||||
b[i++] = 0x08;
|
||||
b[i++] = (unsigned char)(h >> 8);
|
||||
b[i++] = (unsigned char)h;
|
||||
b[i++] = (unsigned char)(w >> 8);
|
||||
b[i++] = (unsigned char)w;
|
||||
b[i++] = 0x01; /* one component */
|
||||
b[i++] = 0x01; /* component id */
|
||||
b[i++] = 0x11; /* sampling */
|
||||
b[i++] = 0x00; /* quant table */
|
||||
return i;
|
||||
}
|
||||
|
||||
static void test_sniff(void) {
|
||||
unsigned char b[64];
|
||||
char mime[32];
|
||||
size_t n;
|
||||
|
||||
n = png_hdr(b, 32, 24);
|
||||
EXPECT(cst_attach_sniff(b, n, mime, sizeof(mime)) == 0 &&
|
||||
strcmp(mime, "image/png") == 0, "PNG sniffed");
|
||||
n = jpeg_hdr(b, 20, 10);
|
||||
EXPECT(cst_attach_sniff(b, n, mime, sizeof(mime)) == 0 &&
|
||||
strcmp(mime, "image/jpeg") == 0, "JPEG sniffed");
|
||||
n = gif_hdr(b, 3, 5);
|
||||
EXPECT(cst_attach_sniff(b, n, mime, sizeof(mime)) == 0 &&
|
||||
strcmp(mime, "image/gif") == 0, "GIF sniffed");
|
||||
memcpy(b, "RIFF\0\0\0\0WEBPVP8 ", 16);
|
||||
EXPECT(cst_attach_sniff(b, 30, mime, sizeof(mime)) == 0 &&
|
||||
strcmp(mime, "image/webp") == 0, "WebP sniffed");
|
||||
n = bmp_hdr(b, 7, 9);
|
||||
EXPECT(cst_attach_sniff(b, n, mime, sizeof(mime)) == 0 &&
|
||||
strcmp(mime, "image/bmp") == 0, "BMP sniffed");
|
||||
|
||||
/* an extension never decides: text and a PDF are not images */
|
||||
EXPECT(cst_attach_sniff("hello, world", 12, mime, sizeof(mime)) != 0,
|
||||
"text rejected");
|
||||
EXPECT(cst_attach_sniff("%PDF-1.7\n", 9, mime, sizeof(mime)) != 0,
|
||||
"PDF rejected");
|
||||
EXPECT(cst_attach_sniff("\x89PNG", 4, mime, sizeof(mime)) != 0,
|
||||
"truncated PNG rejected");
|
||||
EXPECT(cst_attach_sniff("", 0, mime, sizeof(mime)) != 0, "empty rejected");
|
||||
}
|
||||
|
||||
static void test_dimensions(void) {
|
||||
unsigned char b[64];
|
||||
int w = 0, h = 0;
|
||||
size_t n;
|
||||
|
||||
n = png_hdr(b, 1280, 720);
|
||||
EXPECT(cst_attach_dimensions(b, n, &w, &h) == 0 && w == 1280 && h == 720,
|
||||
"PNG dimensions");
|
||||
n = gif_hdr(b, 16, 40);
|
||||
EXPECT(cst_attach_dimensions(b, n, &w, &h) == 0 && w == 16 && h == 40,
|
||||
"GIF dimensions");
|
||||
n = bmp_hdr(b, 7, 9);
|
||||
EXPECT(cst_attach_dimensions(b, n, &w, &h) == 0 && w == 7 && h == 9,
|
||||
"BMP dimensions");
|
||||
n = jpeg_hdr(b, 640, 480);
|
||||
EXPECT(cst_attach_dimensions(b, n, &w, &h) == 0 && w == 640 && h == 480,
|
||||
"JPEG dimensions");
|
||||
|
||||
w = h = -1;
|
||||
EXPECT(cst_attach_dimensions("nope", 4, &w, &h) != 0 && w == 0 && h == 0,
|
||||
"unknown format gives 0x0");
|
||||
}
|
||||
|
||||
static void test_base64(void) {
|
||||
char *s = cst_base64_encode("Man", 3);
|
||||
EXPECT(s && strcmp(s, "TWFu") == 0, "base64 <Man>");
|
||||
free(s);
|
||||
s = cst_base64_encode("Ma", 2);
|
||||
EXPECT(s && strcmp(s, "TWE=") == 0, "base64 padding 1");
|
||||
free(s);
|
||||
s = cst_base64_encode("M", 1);
|
||||
EXPECT(s && strcmp(s, "TQ==") == 0, "base64 padding 2");
|
||||
free(s);
|
||||
s = cst_base64_encode("\x00\xff\xfe", 3);
|
||||
EXPECT(s && strcmp(s, "AP/+") == 0, "base64 binary");
|
||||
free(s);
|
||||
|
||||
size_t n = 0;
|
||||
char *d = cst_base64_decode("TWFu", 4, &n);
|
||||
EXPECT(d && n == 3 && memcmp(d, "Man", 3) == 0, "decode <Man>");
|
||||
free(d);
|
||||
d = cst_base64_decode("TQ==", 4, &n);
|
||||
EXPECT(d && n == 1 && d[0] == 'M', "decode with padding");
|
||||
free(d);
|
||||
d = cst_base64_decode("AP/+\n", 5, &n);
|
||||
EXPECT(d && n == 3 && memcmp(d, "\x00\xff\xfe", 3) == 0,
|
||||
"decode binary, newline ignored");
|
||||
free(d);
|
||||
d = cst_base64_decode("AP_-", 4, &n);
|
||||
EXPECT(d && n == 3 && memcmp(d, "\x00\xff\xfe", 3) == 0, "URL-safe alphabet");
|
||||
free(d);
|
||||
EXPECT(cst_base64_decode("A*B=", 4, &n) == NULL, "bad character refused");
|
||||
|
||||
unsigned char raw[200];
|
||||
for (int i = 0; i < 200; i++)
|
||||
raw[i] = (unsigned char)(i * 37);
|
||||
for (size_t len = 0; len < 7; len++) {
|
||||
char *e = cst_base64_encode(raw, 190 + len);
|
||||
d = e ? cst_base64_decode(e, strlen(e), &n) : NULL;
|
||||
EXPECT(d && n == 190 + len && memcmp(d, raw, n) == 0, "round trip");
|
||||
free(e);
|
||||
free(d);
|
||||
}
|
||||
}
|
||||
|
||||
static void test_rel_ok(const char *root) {
|
||||
EXPECT(cst_attach_rel_ok("img-0a1b2c3d.png"), "normal name accepted");
|
||||
EXPECT(!cst_attach_rel_ok("../x.png"), "parent dir refused");
|
||||
EXPECT(!cst_attach_rel_ok("a/../../b.png"), "slash refused");
|
||||
EXPECT(!cst_attach_rel_ok("/etc/passwd"), "absolute refused");
|
||||
EXPECT(!cst_attach_rel_ok("a\\b.png"), "backslash refused");
|
||||
EXPECT(!cst_attach_rel_ok(".hidden"), "dot file refused");
|
||||
EXPECT(!cst_attach_rel_ok(""), "empty refused");
|
||||
|
||||
cJSON *arr = cJSON_Parse("[{\"id\":\"x\",\"rel\":\"../../secret.png\"},"
|
||||
"{\"id\":\"y\",\"rel\":\"img-1.png\"}]");
|
||||
int n = 0;
|
||||
cst_attachment_t *back = cst_attachments_from_json(arr, &n);
|
||||
EXPECT(n == 1 && back && strcmp(back[0].id, "y") == 0,
|
||||
"escaping rel dropped on load");
|
||||
cst_attachments_free(back, n);
|
||||
cJSON_Delete(arr);
|
||||
|
||||
/* a stored file swapped for non-image bytes is not sent */
|
||||
char session[1024], full[1024], err[256];
|
||||
snprintf(session, sizeof(session), "%.900s/sessions/r.jsonl", root);
|
||||
unsigned char b[64];
|
||||
size_t bn = png_hdr(b, 4, 4);
|
||||
cst_attachment_t *a =
|
||||
cst_attach_import_bytes(session, "x.png", (const char *)b, bn, err,
|
||||
sizeof(err));
|
||||
EXPECT(a != NULL, "stored");
|
||||
if (!a)
|
||||
return;
|
||||
EXPECT(cst_attach_full_path(session, a, full, sizeof(full)) == 0, "path");
|
||||
FILE *f = fopen(full, "wb");
|
||||
if (f) {
|
||||
fputs("plain text now", f);
|
||||
fclose(f);
|
||||
}
|
||||
size_t rn = 0;
|
||||
EXPECT(cst_attach_read(session, a, &rn, err, sizeof(err)) == NULL &&
|
||||
strstr(err, "no longer an image"),
|
||||
"read re-checks the bytes");
|
||||
EXPECT(cst_attach_data_url(session, a) == NULL, "no data url for it");
|
||||
snprintf(a->rel, sizeof(a->rel), "../r.jsonl");
|
||||
EXPECT(cst_attach_full_path(session, a, full, sizeof(full)) != 0,
|
||||
"full path refuses an escaping rel");
|
||||
cst_attach_free(a);
|
||||
}
|
||||
|
||||
static void test_store(const char *root) {
|
||||
char session[1024], dir[1024];
|
||||
snprintf(session, sizeof(session), "%.900s/sessions/a.jsonl", root);
|
||||
unsigned char b[64];
|
||||
size_t n = png_hdr(b, 32, 24);
|
||||
char err[256];
|
||||
|
||||
cst_attachment_t *a = cst_attach_import_bytes(session, "shot.png",
|
||||
(const char *)b, n, err,
|
||||
sizeof(err));
|
||||
EXPECT(a != NULL, "import stored");
|
||||
if (!a) {
|
||||
fprintf(stderr, " err: %s\n", err);
|
||||
return;
|
||||
}
|
||||
EXPECT(strcmp(a->mime, "image/png") == 0, "stored mime");
|
||||
EXPECT(a->width == 32 && a->height == 24, "stored dimensions");
|
||||
EXPECT(a->bytes == (long long)n, "stored size");
|
||||
EXPECT(strcmp(a->name, "shot.png") == 0, "stored name");
|
||||
EXPECT(strcmp(a->origin, "user") == 0, "origin=user");
|
||||
EXPECT(strcmp(a->rel, "img-") != 0 && strstr(a->rel, ".png") != NULL,
|
||||
"rel has a .png extension");
|
||||
|
||||
EXPECT(cst_attach_dir(session, dir, sizeof(dir)) == 0, "attach dir");
|
||||
char full[1024];
|
||||
EXPECT(cst_attach_full_path(session, a, full, sizeof(full)) == 0 &&
|
||||
strstr(full, "attachments/") != NULL,
|
||||
"full path inside attachments/");
|
||||
FILE *f = fopen(full, "rb");
|
||||
EXPECT(f != NULL, "stored file exists");
|
||||
if (f)
|
||||
fclose(f);
|
||||
|
||||
char desc[128];
|
||||
cst_attach_describe(a, desc, sizeof(desc));
|
||||
EXPECT(strstr(desc, "32x24") && strstr(desc, "png"), "describe has size");
|
||||
|
||||
size_t rn = 0;
|
||||
char *data = cst_attach_read(session, a, &rn, err, sizeof(err));
|
||||
EXPECT(data && rn == n && memcmp(data, b, n) == 0, "read back exact bytes");
|
||||
free(data);
|
||||
|
||||
char *url = cst_attach_data_url(session, a);
|
||||
EXPECT(url && strncmp(url, "data:image/png;base64,", 22) == 0,
|
||||
"data url prefix");
|
||||
if (url)
|
||||
EXPECT(strlen(url) == 22 + ((n + 2) / 3) * 4, "data url length");
|
||||
free(url);
|
||||
|
||||
cst_attach_free(a);
|
||||
|
||||
/* a text file named .png is still refused */
|
||||
EXPECT(cst_attach_import_bytes(session, "fake.png", "not an image", 12, err,
|
||||
sizeof(err)) == NULL,
|
||||
"content decides, not the name");
|
||||
EXPECT(strstr(err, "not a supported image") != NULL, "err explains why");
|
||||
EXPECT(cst_attach_import_bytes(session, "empty.png", "", 0, err,
|
||||
sizeof(err)) == NULL,
|
||||
"empty data refused");
|
||||
}
|
||||
|
||||
static void test_import_file(const char *root) {
|
||||
char session[1024], src[1024];
|
||||
snprintf(session, sizeof(session), "%.900s/sessions/b.jsonl", root);
|
||||
snprintf(src, sizeof(src), "%.900s/logo.gif", root);
|
||||
unsigned char b[64];
|
||||
size_t n = gif_hdr(b, 8, 6);
|
||||
FILE *f = fopen(src, "wb");
|
||||
EXPECT(f != NULL, "write source image");
|
||||
if (f) {
|
||||
fwrite(b, 1, n, f);
|
||||
fclose(f);
|
||||
}
|
||||
char err[256];
|
||||
cst_attachment_t *a =
|
||||
cst_attach_import_file(session, src, err, sizeof(err));
|
||||
EXPECT(a != NULL, "file imported");
|
||||
if (a) {
|
||||
EXPECT(strcmp(a->mime, "image/gif") == 0, "imported mime");
|
||||
EXPECT(strcmp(a->name, "logo.gif") == 0, "imported keeps the leaf name");
|
||||
EXPECT(a->width == 8 && a->height == 6, "imported dimensions");
|
||||
cst_attach_free(a);
|
||||
}
|
||||
EXPECT(cst_attach_import_file(session, "/nonexistent/x.png", err,
|
||||
sizeof(err)) == NULL,
|
||||
"missing file refused");
|
||||
}
|
||||
|
||||
static void test_json_roundtrip(void) {
|
||||
cst_attachment_t list[3];
|
||||
memset(list, 0, sizeof(list));
|
||||
snprintf(list[0].id, sizeof(list[0].id), "img-1");
|
||||
snprintf(list[0].name, sizeof(list[0].name), "a.png");
|
||||
snprintf(list[0].mime, sizeof(list[0].mime), "image/png");
|
||||
snprintf(list[0].rel, sizeof(list[0].rel), "img-1.png");
|
||||
list[0].bytes = 12345;
|
||||
list[0].width = 640;
|
||||
list[0].height = 480;
|
||||
snprintf(list[0].origin, sizeof(list[0].origin), "user");
|
||||
|
||||
snprintf(list[1].id, sizeof(list[1].id), "img-2");
|
||||
snprintf(list[1].name, sizeof(list[1].name), "b.jpg");
|
||||
snprintf(list[1].mime, sizeof(list[1].mime), "image/jpeg");
|
||||
snprintf(list[1].rel, sizeof(list[1].rel), "img-2.jpg");
|
||||
list[1].bytes = 4096;
|
||||
snprintf(list[1].detail, sizeof(list[1].detail), "high");
|
||||
snprintf(list[1].origin, sizeof(list[1].origin), "view_image");
|
||||
|
||||
snprintf(list[2].id, sizeof(list[2].id), "img-3");
|
||||
snprintf(list[2].name, sizeof(list[2].name), "c.webp");
|
||||
snprintf(list[2].mime, sizeof(list[2].mime), "image/webp");
|
||||
snprintf(list[2].rel, sizeof(list[2].rel), "img-3.webp");
|
||||
list[2].bytes = 7;
|
||||
|
||||
char *json = cst_attachments_to_json(list, 3);
|
||||
EXPECT(json != NULL, "to_json");
|
||||
int n = 0;
|
||||
cJSON *parsed = cJSON_Parse(json);
|
||||
cst_attachment_t *back = cst_attachments_from_json(parsed, &n);
|
||||
cJSON_Delete(parsed);
|
||||
EXPECT(back != NULL && n == 3, "from_json count");
|
||||
if (back && n == 3) {
|
||||
EXPECT(strcmp(back[0].id, "img-1") == 0 && back[0].bytes == 12345 &&
|
||||
back[0].width == 640 && back[0].height == 480,
|
||||
"first entry survives");
|
||||
EXPECT(strcmp(back[1].detail, "high") == 0 &&
|
||||
strcmp(back[1].origin, "view_image") == 0,
|
||||
"detail and origin survive");
|
||||
EXPECT(strcmp(back[2].mime, "image/webp") == 0 && back[2].width == 0,
|
||||
"entry without dimensions");
|
||||
EXPECT(strcmp(back[0].detail, "auto") == 0, "missing detail defaults");
|
||||
cst_attachments_free(back, n);
|
||||
}
|
||||
free(json);
|
||||
|
||||
/* entries without rel/id are dropped; a non-array is NULL */
|
||||
cJSON *arr = cJSON_Parse("[{\"id\":\"x\"},{\"id\":\"y\",\"rel\":\"y.png\"}]");
|
||||
back = cst_attachments_from_json(arr, &n);
|
||||
EXPECT(back != NULL && n == 1 && strcmp(back[0].id, "y") == 0,
|
||||
"incomplete entries skipped");
|
||||
cst_attachments_free(back, n);
|
||||
cJSON_Delete(arr);
|
||||
cJSON *obj = cJSON_Parse("{}");
|
||||
n = 7;
|
||||
EXPECT(cst_attachments_from_json(obj, &n) == NULL && n == 0,
|
||||
"object is not an attachment array");
|
||||
cJSON_Delete(obj);
|
||||
EXPECT(cst_attachments_to_json(NULL, 0) == NULL, "empty list is NULL");
|
||||
}
|
||||
|
||||
static void test_lists(void) {
|
||||
cst_attachment_t src;
|
||||
memset(&src, 0, sizeof(src));
|
||||
snprintf(src.id, sizeof(src.id), "img-x");
|
||||
snprintf(src.mime, sizeof(src.mime), "image/png");
|
||||
|
||||
cst_attachment_t *list = NULL;
|
||||
int n = 0, cap = 0;
|
||||
for (int i = 0; i < 10; i++)
|
||||
EXPECT(cst_attachments_push(&list, &n, &cap, &src) == 0, "push");
|
||||
EXPECT(n == 10 && cap >= 10, "list grew");
|
||||
EXPECT(strcmp(list[9].id, "img-x") == 0, "last entry copied");
|
||||
|
||||
cst_attachment_t *dup = cst_attachments_dup(list, n);
|
||||
EXPECT(dup && strcmp(dup[3].mime, "image/png") == 0, "dup copies fields");
|
||||
dup[3].id[0] = 'Z';
|
||||
EXPECT(list[3].id[0] == 'i', "dup is a deep copy");
|
||||
cst_attachments_free(dup, n);
|
||||
EXPECT(cst_attachments_dup(NULL, 0) == NULL, "dup of nothing is NULL");
|
||||
cst_attachments_free(list, n);
|
||||
}
|
||||
|
||||
static void test_agent_pending(const char *root) {
|
||||
cst_agent_config_t cfg;
|
||||
memset(&cfg, 0, sizeof(cfg));
|
||||
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", root);
|
||||
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/sessions/c.jsonl",
|
||||
root);
|
||||
cst_agent_t *a = cst_agent_create(&cfg);
|
||||
EXPECT(a != NULL, "agent created");
|
||||
if (!a)
|
||||
return;
|
||||
|
||||
char err[256], line[128];
|
||||
EXPECT(cst_agent_pending_count(a) == 0, "no pending at start");
|
||||
EXPECT(cst_agent_pending_describe(a, 0, line, sizeof(line)) == -1,
|
||||
"describe out of range");
|
||||
|
||||
unsigned char b[32];
|
||||
size_t n = png_hdr(b, 4, 2);
|
||||
char src[1024];
|
||||
snprintf(src, sizeof(src), "%.900s/pic.png", root);
|
||||
FILE *f = fopen(src, "wb");
|
||||
EXPECT(f != NULL, "write the source png");
|
||||
if (f) {
|
||||
fwrite(b, 1, n, f);
|
||||
fclose(f);
|
||||
}
|
||||
EXPECT(cst_agent_attach_file(a, src, err, sizeof(err)) == 0,
|
||||
"attach through the agent");
|
||||
EXPECT(cst_agent_pending_count(a) == 1, "one pending");
|
||||
EXPECT(cst_agent_pending_describe(a, 0, line, sizeof(line)) == 0 &&
|
||||
strstr(line, "4x2") != NULL,
|
||||
"pending describe shows the size");
|
||||
|
||||
EXPECT(cst_agent_attach_file(a, "/nonexistent/x.png", err, sizeof(err)) == -1,
|
||||
"missing file refused");
|
||||
EXPECT(cst_agent_pending_count(a) == 1, "a failure keeps the queue");
|
||||
|
||||
for (int i = 1; i < CST_ATTACH_MAX_COUNT; i++)
|
||||
EXPECT(cst_agent_attach_file(a, src, err, sizeof(err)) == 0, "attach more");
|
||||
EXPECT(cst_agent_pending_count(a) == CST_ATTACH_MAX_COUNT, "queue full");
|
||||
EXPECT(cst_agent_attach_file(a, src, err, sizeof(err)) == -1 &&
|
||||
strstr(err, "at most") != NULL,
|
||||
"one over the limit is refused");
|
||||
|
||||
cst_agent_pending_clear(a);
|
||||
EXPECT(cst_agent_pending_count(a) == 0, "cleared");
|
||||
cst_agent_destroy(a);
|
||||
}
|
||||
|
||||
static void test_paste_paths(void) {
|
||||
static char p[CST_PASTE_MAX_PATHS][CST_PASTE_PATH_MAX];
|
||||
const char *s = "/tmp/a.png";
|
||||
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == 1 &&
|
||||
strcmp(p[0], "/tmp/a.png") == 0,
|
||||
"single path");
|
||||
s = " '/home/u/my shot.png' \"/x/y z.jpg\"\n/plain.gif\r\n";
|
||||
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == 3, "quoted paths count");
|
||||
EXPECT(strcmp(p[0], "/home/u/my shot.png") == 0, "single quotes");
|
||||
EXPECT(strcmp(p[1], "/x/y z.jpg") == 0, "double quotes");
|
||||
EXPECT(strcmp(p[2], "/plain.gif") == 0, "newline separated");
|
||||
s = "file:///home/u/a%20b.png file://localhost/c.png";
|
||||
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == 2, "file uris");
|
||||
EXPECT(strcmp(p[0], "/home/u/a b.png") == 0, "percent decoded");
|
||||
EXPECT(strcmp(p[1], "/c.png") == 0, "localhost host");
|
||||
s = "file://evil.example/c.png";
|
||||
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == -1, "remote host refused");
|
||||
#ifndef _WIN32
|
||||
s = "/home/u/my\\ shot.png";
|
||||
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == 1 &&
|
||||
strcmp(p[0], "/home/u/my shot.png") == 0,
|
||||
"backslash escape");
|
||||
#endif
|
||||
s = "'/unterminated.png";
|
||||
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == -1, "unbalanced quote");
|
||||
s = " \n ";
|
||||
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == -1, "blank");
|
||||
s = "a b c";
|
||||
EXPECT(cst_paste_paths(s, strlen(s), p, 2) == -1, "too many");
|
||||
}
|
||||
|
||||
static void test_dib(void) {
|
||||
/* 3x2, 24-bit, bottom-up: stride 12 (9 bytes + pad). */
|
||||
unsigned char dib[40 + 24];
|
||||
memset(dib, 0, sizeof(dib));
|
||||
put_le32(dib, 40);
|
||||
put_le32(dib + 4, 3);
|
||||
put_le32(dib + 8, 2);
|
||||
put_le16(dib + 12, 1);
|
||||
put_le16(dib + 14, 24);
|
||||
dib[40] = 0xff; /* bottom-left pixel blue (BGR) */
|
||||
size_t n = 0;
|
||||
char *img = cst_dib_to_image(dib, sizeof(dib), &n);
|
||||
char mime[32];
|
||||
int w = 0, h = 0;
|
||||
EXPECT(img && cst_attach_sniff(img, n, mime, sizeof(mime)) == 0 &&
|
||||
strcmp(mime, "image/png") == 0,
|
||||
"24-bit DIB becomes a PNG");
|
||||
EXPECT(img && cst_attach_dimensions(img, n, &w, &h) == 0 && w == 3 && h == 2,
|
||||
"PNG keeps the size");
|
||||
free(img);
|
||||
|
||||
put_le16(dib + 14, 8); /* palette needs 256 entries: past the end */
|
||||
EXPECT(cst_dib_to_image(dib, sizeof(dib), &n) == NULL, "short DIB refused");
|
||||
put_le32(dib, 4);
|
||||
EXPECT(cst_dib_to_image(dib, sizeof(dib), &n) == NULL, "bad header size");
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
char root[1024];
|
||||
if (test_tmpdir(root, sizeof(root), "cst-attach") != 0) {
|
||||
fprintf(stderr, "cannot create a scratch dir\n");
|
||||
return 1;
|
||||
}
|
||||
test_sniff();
|
||||
test_dimensions();
|
||||
test_base64();
|
||||
test_rel_ok(root);
|
||||
test_paste_paths();
|
||||
test_dib();
|
||||
test_store(root);
|
||||
test_import_file(root);
|
||||
test_json_roundtrip();
|
||||
test_lists();
|
||||
test_agent_pending(root);
|
||||
test_rmrf(root);
|
||||
if (nfail) {
|
||||
fprintf(stderr, "test_attach: %d failed\n", nfail);
|
||||
return 1;
|
||||
}
|
||||
printf("test_attach: OK\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
+54
-2
@@ -5,6 +5,8 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "cst_agent.h"
|
||||
#include "cst_attach.h"
|
||||
#include "cst_mcp.h"
|
||||
#include "cst_tools.h"
|
||||
#include "os_compat.h"
|
||||
@@ -18,6 +20,7 @@
|
||||
|
||||
static int nfail;
|
||||
static char g_tmp[OS_PATH_MAX];
|
||||
static cst_agent_t *g_agent;
|
||||
|
||||
#define CHECK(cond, msg) \
|
||||
do { \
|
||||
@@ -99,6 +102,7 @@ static int server(void) {
|
||||
cJSON_AddItemToArray(tools, tool("mixed", "Mixed content."));
|
||||
cJSON_AddItemToArray(tools, tool("hang", "Never answers."));
|
||||
cJSON_AddItemToArray(tools, tool("crash", "Exits."));
|
||||
cJSON_AddItemToArray(tools, tool("pic", "Returns images."));
|
||||
}
|
||||
reply(id, r);
|
||||
} else if (strcmp(mt, "tools/call") == 0) {
|
||||
@@ -136,6 +140,21 @@ static int server(void) {
|
||||
cJSON_AddStringToObject(rr, "text", "resource text");
|
||||
cJSON_AddItemToArray(c, res);
|
||||
reply(id, r);
|
||||
} else if (strcmp(name, "pic") == 0) {
|
||||
cJSON *r = text_result("a chart", 0);
|
||||
cJSON *c = cJSON_GetObjectItemCaseSensitive(r, "content");
|
||||
const char *datas[] = {
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAC"
|
||||
"hwGA60e6kgAAAABJRU5ErkJggg==",
|
||||
"AAAA"};
|
||||
for (int i = 0; i < 2; i++) {
|
||||
cJSON *img = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(img, "type", "image");
|
||||
cJSON_AddStringToObject(img, "mimeType", "image/png");
|
||||
cJSON_AddStringToObject(img, "data", datas[i]);
|
||||
cJSON_AddItemToArray(c, img);
|
||||
}
|
||||
reply(id, r);
|
||||
} else if (strcmp(name, "crash") == 0) {
|
||||
fprintf(stderr, "fatal: fake crash\n");
|
||||
exit(3);
|
||||
@@ -163,6 +182,7 @@ static int call(const char *tool, const char *k, const char *v,
|
||||
cst_tool_ctx_t ctx;
|
||||
memset(&ctx, 0, sizeof(ctx));
|
||||
ctx.workspace_root = g_tmp;
|
||||
ctx.agent = g_agent;
|
||||
memset(r, 0, sizeof(*r));
|
||||
return cst_tool_run(&ctx, &c, r);
|
||||
}
|
||||
@@ -217,8 +237,8 @@ int main(int argc, char **argv) {
|
||||
cst_mcp_info_t mi[CST_MCP_MAX];
|
||||
int n = cst_mcp_info(mi, CST_MCP_MAX);
|
||||
const cst_mcp_info_t *f = info_of("fake", mi, n);
|
||||
CHECK(f && f->state == CST_MCP_READY && f->ntools == 6,
|
||||
"fake ready with 6 tools over two pages");
|
||||
CHECK(f && f->state == CST_MCP_READY && f->ntools == 7,
|
||||
"fake ready with 7 tools over two pages");
|
||||
const cst_mcp_info_t *rm = info_of("remote", mi, n);
|
||||
CHECK(rm && rm->state == CST_MCP_FAILED && strstr(rm->error, "stdio"),
|
||||
"url servers unsupported");
|
||||
@@ -255,8 +275,40 @@ int main(int argc, char **argv) {
|
||||
CHECK(r.exit_code == 0 && r.out && strstr(r.out, "first\n[image image/png") &&
|
||||
strstr(r.out, "resource text"),
|
||||
"content items mapped to text");
|
||||
CHECK(r.nattach == 0, "no session: images stay text");
|
||||
cst_tool_result_free(&r);
|
||||
|
||||
cst_agent_config_t acfg;
|
||||
memset(&acfg, 0, sizeof(acfg));
|
||||
snprintf(acfg.workspace_root, sizeof(acfg.workspace_root), "%s", g_tmp);
|
||||
char sdir[1024];
|
||||
snprintf(sdir, sizeof(sdir), "%.900s/cst-mcp-test-%d", g_tmp, (int)getpid());
|
||||
os_mkdir_p(sdir, 0755);
|
||||
snprintf(acfg.session_path, sizeof(acfg.session_path), "%s/s.jsonl", sdir);
|
||||
g_agent = cst_agent_create(&acfg);
|
||||
CHECK(g_agent != NULL, "agent for the session store");
|
||||
call("mcp__fake__pic", NULL, NULL, &r);
|
||||
CHECK(r.exit_code == 0 && r.nattach == 1 && r.attach &&
|
||||
strcmp(r.attach[0].mime, "image/png") == 0 &&
|
||||
strcmp(r.attach[0].origin, "mcp") == 0 &&
|
||||
r.attach[0].width == 1 && r.attach[0].height == 1,
|
||||
"image content stored and attached");
|
||||
CHECK(r.out && strstr(r.out, "a chart\n[image ") &&
|
||||
strstr(r.out, "shown to you as an image") &&
|
||||
strstr(r.out, "not shown:"),
|
||||
"one line per image, bad data falls back to text");
|
||||
if (r.nattach == 1) {
|
||||
char full[OS_PATH_MAX];
|
||||
CHECK(cst_attach_full_path(acfg.session_path, &r.attach[0], full,
|
||||
sizeof(full)) == 0 &&
|
||||
access(full, R_OK) == 0,
|
||||
"stored file exists");
|
||||
}
|
||||
cst_tool_result_free(&r);
|
||||
cst_agent_destroy(g_agent);
|
||||
g_agent = NULL;
|
||||
test_rmrf(sdir);
|
||||
|
||||
volatile int cancel = 1;
|
||||
os_set_cancel_flag(&cancel);
|
||||
double t0 = os_now_ms();
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/* 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;
|
||||
}
|
||||
+70
-2
@@ -3,6 +3,8 @@
|
||||
#endif
|
||||
|
||||
#include "cst_agent.h"
|
||||
#include "cst_models.h"
|
||||
#include "os_compat.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -72,11 +74,51 @@ static int start_server(void) {
|
||||
"Connection: close\r\n\r\n" body
|
||||
|
||||
int main(void) {
|
||||
char cfgdir[1024];
|
||||
CHECK(test_tmpdir(cfgdir, sizeof(cfgdir), "cst-models-cfg") == 0);
|
||||
setenv("XDG_CONFIG_HOME", cfgdir, 1); /* ignore a real models-cache */
|
||||
const cst_provider_t *tr = cst_provider_find("teamorouter");
|
||||
CHECK(tr && strcmp(tr->api_base, "https://api.teamorouter.com/v1") == 0);
|
||||
CHECK(tr->model[0] == 0); /* picked from GET /models */
|
||||
CHECK(cst_model_context_tokens("anthropic/claude-sonnet-4") == 200000);
|
||||
CHECK(cst_model_context_tokens("deepseek-flash") == 128000);
|
||||
CHECK(cst_model_context_tokens("deepseek-flash") == 1000000);
|
||||
CHECK(cst_model_context_tokens("deepseek/deepseek-v4-pro") == 1000000);
|
||||
CHECK(cst_model_context_tokens("google/gemini-2.5-pro") == 1048576);
|
||||
CHECK(cst_model_context_tokens("unknown-model") == 128000);
|
||||
|
||||
cst_model_caps_t caps;
|
||||
cst_model_caps("deepseek", "https://api.deepseek.com/v1", "deepseek-flash",
|
||||
&caps);
|
||||
CHECK(caps.reasoning_passback && caps.prefix_completion);
|
||||
CHECK(caps.cache_style == CST_CACHE_AUTO);
|
||||
cst_model_caps("teamorouter", "https://api.teamorouter.com/v1",
|
||||
"deepseek-flash", &caps);
|
||||
CHECK(!caps.reasoning_passback && !caps.prefix_completion);
|
||||
cst_model_caps("teamorouter", NULL, "anthropic/claude-sonnet-4", &caps);
|
||||
CHECK(caps.cache_style == CST_CACHE_ANTHROPIC);
|
||||
cst_model_caps("openai", NULL, "gpt-4.1", &caps);
|
||||
CHECK(caps.cache_style == CST_CACHE_OPENAI);
|
||||
cst_model_caps("openai", NULL, "o3-mini", &caps);
|
||||
CHECK(caps.cache_style == CST_CACHE_OPENAI);
|
||||
cst_model_caps("minimax", NULL, "MiniMax-M2", &caps);
|
||||
CHECK(caps.cache_style == CST_CACHE_AUTO);
|
||||
|
||||
/* Beijing time: Thu 10:00 peak, Thu 13:00 and Sat 10:00 off-peak */
|
||||
CHECK(cst_price_is_peak((time_t)1791424800));
|
||||
CHECK(!cst_price_is_peak((time_t)1791435600));
|
||||
CHECK(!cst_price_is_peak((time_t)1791597600));
|
||||
cst_price_t pr;
|
||||
CHECK(cst_model_price("deepseek-flash", &pr) == 0);
|
||||
CHECK(pr.offpeak_half && pr.hit < pr.miss);
|
||||
double peak = cst_price_cost(&pr, 1000000, 1000000, 0, 1000000,
|
||||
(time_t)1791424800);
|
||||
CHECK(peak > 9.99 && peak < 10.05); /* 2 + 0.04 + 8 */
|
||||
double off = cst_price_cost(&pr, 1000000, 1000000, 0, 1000000,
|
||||
(time_t)1791435600);
|
||||
CHECK(off > peak / 2 - 0.001 && off < peak / 2 + 0.001);
|
||||
CHECK(cst_model_price("anthropic/claude-sonnet-4", &pr) == 0);
|
||||
CHECK(pr.write > pr.miss && strcmp(pr.currency, "$") == 0);
|
||||
CHECK(cst_model_price("MiniMax-M2", &pr) != 0);
|
||||
|
||||
g_responses[0] = RESP("200 OK", "{\"object\":\"list\",\"data\":["
|
||||
"{\"id\":\"deepseek-flash\"},"
|
||||
@@ -87,7 +129,21 @@ int main(void) {
|
||||
g_responses[3] = RESP("200 OK", "{\"models\":[{\"name\":\"m1\"},"
|
||||
"{\"name\":\"m2\"}]}");
|
||||
g_responses[4] = RESP("500 Internal Server Error", "{}");
|
||||
g_nresp = 5;
|
||||
/* OpenRouter-style metadata; prices are USD per token, as strings */
|
||||
g_responses[5] = RESP(
|
||||
"200 OK",
|
||||
"{\"data\":[{\"id\":\"anthropic/claude-sonnet-4\","
|
||||
"\"context_length\":1000000,"
|
||||
"\"architecture\":{\"input_modalities\":[\"text\",\"image\"]},"
|
||||
"\"pricing\":{\"prompt\":\"0.000003\",\"completion\":\"0.000015\","
|
||||
"\"input_cache_read\":\"0.0000003\"}},"
|
||||
"{\"id\":\"old/text-model\","
|
||||
"\"architecture\":{\"modality\":\"text->text\"},"
|
||||
"\"pricing\":{\"prompt\":\"-1\",\"completion\":\"-1\"}},"
|
||||
"{\"id\":\"vllm-model\",\"max_model_len\":32768,"
|
||||
"\"modalities\":{\"input\":[\"text\"]}},"
|
||||
"{\"id\":\"plain\"}]}");
|
||||
g_nresp = 6;
|
||||
int port = start_server();
|
||||
pthread_t th;
|
||||
CHECK(pthread_create(&th, NULL, serve, NULL) == 0);
|
||||
@@ -118,6 +174,17 @@ int main(void) {
|
||||
n = cst_llm_list_models(base, "sk-test", ids, 8, err, sizeof(err));
|
||||
CHECK(n == -1 && strstr(err, "500") != NULL);
|
||||
|
||||
cst_model_meta_t meta[8];
|
||||
n = cst_llm_list_models_meta(base, "sk-test", meta, 8, err, sizeof(err));
|
||||
CHECK(n == 4);
|
||||
CHECK(meta[0].context == 1000000 && meta[0].vision == 1);
|
||||
CHECK(meta[0].has_price && meta[0].miss > 2.99 && meta[0].miss < 3.01 &&
|
||||
meta[0].out > 14.99 && meta[0].out < 15.01 && meta[0].hit < 0.31 &&
|
||||
meta[0].write == meta[0].miss);
|
||||
CHECK(meta[1].vision == 0 && !meta[1].has_price && meta[1].context == 0);
|
||||
CHECK(meta[2].context == 32768 && meta[2].vision == 0);
|
||||
CHECK(meta[3].vision == -1 && meta[3].context == 0 && !meta[3].has_price);
|
||||
|
||||
pthread_join(th, NULL);
|
||||
test_sock_close(g_listen);
|
||||
|
||||
@@ -125,6 +192,7 @@ int main(void) {
|
||||
n = cst_llm_list_models(base, "sk-test", ids, 8, err, sizeof(err));
|
||||
CHECK(n == -1);
|
||||
|
||||
test_rmrf(cfgdir);
|
||||
puts("test_models: ok");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "cst_agent.h"
|
||||
#include "cst_util.h"
|
||||
#include "os_compat.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include "cst_test_util.h"
|
||||
|
||||
/* Runs a few replayed turns (tool call, Plan mode on, approval change, a
|
||||
* reply cut off by max tokens) and checks the recorded request bodies: every
|
||||
* request must extend the previous one byte for byte, so the provider's
|
||||
* prefix cache keeps hitting. */
|
||||
|
||||
static int nfail;
|
||||
|
||||
#define EXPECT(cond, msg) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
|
||||
nfail++; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#define USAGE \
|
||||
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1000," \
|
||||
"\"completion_tokens\":20,\"prompt_cache_hit_tokens\":900," \
|
||||
"\"prompt_cache_miss_tokens\":100}}\n\n" \
|
||||
"data: [DONE]\n\n"
|
||||
|
||||
#define TOOL(id, name, args) \
|
||||
"data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0," \
|
||||
"\"id\":\"" id "\",\"type\":\"function\",\"function\":{\"name\":\"" name \
|
||||
"\",\"arguments\":\"" args "\"}}]},\"finish_reason\":null}]}\n\n" \
|
||||
"data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":" \
|
||||
"\"tool_calls\"}]}\n\n" USAGE
|
||||
|
||||
#undef TEXT /* <windows.h> has one */
|
||||
#define TEXT(s, finish) \
|
||||
"data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"" s "\"}," \
|
||||
"\"finish_reason\":null}]}\n\n" \
|
||||
"data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"" finish \
|
||||
"\"}]}\n\n" USAGE
|
||||
|
||||
#define THINK(s) \
|
||||
"data: {\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"" s \
|
||||
"\"},\"finish_reason\":null}]}\n\n"
|
||||
|
||||
static const char *FIXTURES[] = {
|
||||
/* turn 1 */
|
||||
THINK("look at the file") TOOL("c1", "read_file", "{\\\"path\\\":\\\"a.txt\\\"}"),
|
||||
TEXT("done one", "stop"),
|
||||
/* turn 2, Plan mode */
|
||||
TOOL("c2", "write_file",
|
||||
"{\\\"path\\\":\\\"b.txt\\\",\\\"content\\\":\\\"x\\\"}"),
|
||||
TEXT("plan reply", "stop"),
|
||||
/* turn 3, Build mode + yolo; cut off, then continued */
|
||||
TEXT("part one", "length"),
|
||||
TEXT(" part two", "stop"),
|
||||
/* turn 4 */
|
||||
TEXT("bye", "stop"),
|
||||
};
|
||||
#define NFIX ((int)(sizeof(FIXTURES) / sizeof(FIXTURES[0])))
|
||||
|
||||
static void write_file(const char *dir, const char *name, const char *body) {
|
||||
char p[2048];
|
||||
snprintf(p, sizeof(p), "%s/%s", dir, name);
|
||||
FILE *fp = fopen(p, "wb");
|
||||
EXPECT(fp != NULL, "write fixture");
|
||||
if (fp) {
|
||||
fputs(body, fp);
|
||||
fclose(fp);
|
||||
}
|
||||
}
|
||||
|
||||
static cJSON *load_req(const char *dir, int seq) {
|
||||
char p[2048];
|
||||
snprintf(p, sizeof(p), "%s/%04d.req.json", dir, seq);
|
||||
FILE *fp = fopen(p, "rb");
|
||||
if (!fp)
|
||||
return NULL;
|
||||
cst_buf_t b = {0};
|
||||
char buf[4096];
|
||||
size_t n;
|
||||
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0)
|
||||
cst_buf_append(&b, buf, n);
|
||||
fclose(fp);
|
||||
cJSON *j = b.buf ? cJSON_Parse(b.buf) : NULL;
|
||||
cst_buf_free(&b);
|
||||
return j;
|
||||
}
|
||||
|
||||
static const cJSON *msgs_of(const cJSON *req) {
|
||||
return cJSON_GetObjectItemCaseSensitive(req, "messages");
|
||||
}
|
||||
|
||||
static int is_prefix_msg(const cJSON *m) {
|
||||
return cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(m, "prefix"));
|
||||
}
|
||||
|
||||
static const char *str_of(const cJSON *o, const char *key) {
|
||||
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, key);
|
||||
return cJSON_IsString(v) ? v->valuestring : NULL;
|
||||
}
|
||||
|
||||
static char *print(const cJSON *j) { return j ? cJSON_PrintUnformatted(j) : NULL; }
|
||||
|
||||
/* messages of `a` (minus a request-only prefix message) start `b` */
|
||||
static int extends(const cJSON *a, const cJSON *b) {
|
||||
const cJSON *ma = msgs_of(a), *mb = msgs_of(b);
|
||||
int na = cJSON_GetArraySize(ma), nb = cJSON_GetArraySize(mb);
|
||||
if (na > 0 && is_prefix_msg(cJSON_GetArrayItem(ma, na - 1)))
|
||||
na--;
|
||||
if (na > nb)
|
||||
return 0;
|
||||
for (int i = 0; i < na; i++) {
|
||||
char *x = print(cJSON_GetArrayItem(ma, i));
|
||||
char *y = print(cJSON_GetArrayItem(mb, i));
|
||||
int same = x && y && strcmp(x, y) == 0;
|
||||
if (!same)
|
||||
fprintf(stderr, " message %d differs:\n %.300s\n %.300s\n", i,
|
||||
x ? x : "(null)", y ? y : "(null)");
|
||||
free(x);
|
||||
free(y);
|
||||
if (!same)
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static const cJSON *last_msg(const cJSON *req) {
|
||||
const cJSON *m = msgs_of(req);
|
||||
return cJSON_GetArrayItem(m, cJSON_GetArraySize(m) - 1);
|
||||
}
|
||||
|
||||
static int any_msg_has(const cJSON *req, const char *key, const char *needle) {
|
||||
const cJSON *m;
|
||||
cJSON_ArrayForEach(m, msgs_of(req)) {
|
||||
const char *s = str_of(m, key);
|
||||
if (s && strstr(s, needle))
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int run(cst_agent_t *a, const char *text) {
|
||||
char err[512];
|
||||
int rc = cst_agent_run_turn(a, text, err, sizeof(err));
|
||||
if (rc != 0)
|
||||
fprintf(stderr, " turn \"%s\": %s\n", text, err);
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* 8-byte PNG signature + IHDR header: enough for the attachment sniffer. */
|
||||
static size_t tiny_png(unsigned char *b) {
|
||||
static const unsigned char sig[8] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A,
|
||||
'\n'};
|
||||
memset(b, 0, 32);
|
||||
memcpy(b, sig, 8);
|
||||
b[11] = 13;
|
||||
memcpy(b + 12, "IHDR", 4);
|
||||
b[19] = 2; /* 2 x 2 */
|
||||
b[23] = 2;
|
||||
return 32;
|
||||
}
|
||||
|
||||
static int tools_have(const cJSON *req, const char *name) {
|
||||
char *t = print(cJSON_GetObjectItemCaseSensitive(req, "tools"));
|
||||
int has = t && strstr(t, name) != NULL;
|
||||
free(t);
|
||||
return has;
|
||||
}
|
||||
|
||||
static int has_image_url(const cJSON *req) {
|
||||
char *t = print(msgs_of(req));
|
||||
int has = t && strstr(t, "image_url") != NULL;
|
||||
free(t);
|
||||
return has;
|
||||
}
|
||||
|
||||
/* A text-only model never sees images or view_image; a model of unknown
|
||||
* vision that answers 400 to an image is retried with notes, and stays
|
||||
* that way so later requests extend the retried one. */
|
||||
static void test_vision(const char *root) {
|
||||
char dir[1100], sse[1200], rec[1200];
|
||||
snprintf(dir, sizeof(dir), "%s/vision", root);
|
||||
snprintf(sse, sizeof(sse), "%s/sse", dir);
|
||||
snprintf(rec, sizeof(rec), "%s/rec", dir);
|
||||
os_mkdir_p(sse, 0755);
|
||||
write_file(sse, "0000.sse", TEXT("blind reply", "stop"));
|
||||
write_file(sse, "0001.sse", "{\"error\":{\"message\":\"image input is not "
|
||||
"supported\"}}");
|
||||
write_file(sse, "0001.status", "400\n");
|
||||
write_file(sse, "0002.sse", TEXT("noted reply", "stop"));
|
||||
write_file(sse, "0003.sse", TEXT("later reply", "stop"));
|
||||
setenv("CST_REPLAY_SSE", sse, 1);
|
||||
setenv("CST_RECORD_SSE", rec, 1);
|
||||
cst_llm_trace_reset();
|
||||
|
||||
unsigned char png[32];
|
||||
size_t pn = tiny_png(png);
|
||||
char err[256];
|
||||
|
||||
cst_agent_config_t cfg;
|
||||
memset(&cfg, 0, sizeof(cfg));
|
||||
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", root);
|
||||
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/blind.jsonl", dir);
|
||||
snprintf(cfg.api_base, sizeof(cfg.api_base), "http://replay/v1");
|
||||
snprintf(cfg.model, sizeof(cfg.model), "deepseek-flash");
|
||||
snprintf(cfg.provider, sizeof(cfg.provider), "deepseek");
|
||||
cfg.protocol = CST_PROTO_NATIVE_EOS;
|
||||
cfg.max_steps = 4;
|
||||
cfg.headless = 1;
|
||||
cfg.approval = CST_APPROVAL_AUTO;
|
||||
cst_agent_t *a = cst_agent_create(&cfg);
|
||||
EXPECT(a != NULL, "blind agent");
|
||||
if (!a)
|
||||
return;
|
||||
EXPECT(cst_agent_sees_images(a) == 0, "deepseek-flash is text only");
|
||||
EXPECT(cst_agent_attach_bytes(a, "shot.png", (const char *)png, pn, err,
|
||||
sizeof(err)) == 0,
|
||||
"attach to a blind model");
|
||||
EXPECT(run(a, "what is in the picture?") == 0, "blind turn");
|
||||
cst_agent_destroy(a);
|
||||
|
||||
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/unknown.jsonl",
|
||||
dir);
|
||||
snprintf(cfg.model, sizeof(cfg.model), "mystery-model-9");
|
||||
snprintf(cfg.provider, sizeof(cfg.provider), "teamorouter");
|
||||
a = cst_agent_create(&cfg);
|
||||
EXPECT(a != NULL, "unknown-vision agent");
|
||||
if (!a)
|
||||
return;
|
||||
EXPECT(cst_agent_sees_images(a) != 0, "unknown vision: images are tried");
|
||||
EXPECT(cst_agent_attach_bytes(a, "shot.png", (const char *)png, pn, err,
|
||||
sizeof(err)) == 0,
|
||||
"attach to an unknown model");
|
||||
EXPECT(run(a, "describe it") == 0, "400 retried without images");
|
||||
EXPECT(run(a, "and now?") == 0, "next turn");
|
||||
cst_agent_destroy(a);
|
||||
|
||||
cJSON *r[4];
|
||||
for (int i = 0; i < 4; i++) {
|
||||
r[i] = load_req(rec, i);
|
||||
EXPECT(r[i] != NULL, "vision request recorded");
|
||||
}
|
||||
if (r[0] && r[1] && r[2] && r[3]) {
|
||||
EXPECT(!tools_have(r[0], "view_image"), "blind: no view_image tool");
|
||||
EXPECT(!has_image_url(r[0]), "blind: no image part");
|
||||
const char *c = str_of(last_msg(r[0]), "content");
|
||||
EXPECT(c && strstr(c, "what is in the picture?") &&
|
||||
strstr(c, "not sent: this model does not take images"),
|
||||
"blind: the image becomes a note");
|
||||
|
||||
EXPECT(tools_have(r[1], "view_image") && has_image_url(r[1]),
|
||||
"unknown: first try sends the image");
|
||||
EXPECT(!tools_have(r[2], "view_image") && !has_image_url(r[2]),
|
||||
"retry: no image, no view_image");
|
||||
c = str_of(last_msg(r[2]), "content");
|
||||
EXPECT(c && strstr(c, "not sent"), "retry: note instead");
|
||||
EXPECT(!has_image_url(r[3]) && extends(r[2], r[3]),
|
||||
"images stay off; the next request extends the retry");
|
||||
}
|
||||
for (int i = 0; i < 4; i++)
|
||||
cJSON_Delete(r[i]);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
char root[1024], sse[1100], rec[1100];
|
||||
if (test_tmpdir(root, sizeof(root), "cst-prefix") != 0) {
|
||||
fprintf(stderr, "cannot create a scratch dir\n");
|
||||
return 1;
|
||||
}
|
||||
snprintf(sse, sizeof(sse), "%s/sse", root);
|
||||
snprintf(rec, sizeof(rec), "%s/rec", root);
|
||||
os_mkdir_p(sse, 0755);
|
||||
for (int i = 0; i < NFIX; i++) {
|
||||
char name[32];
|
||||
snprintf(name, sizeof(name), "%04d.sse", i);
|
||||
write_file(sse, name, FIXTURES[i]);
|
||||
}
|
||||
write_file(root, "a.txt", "hello\n");
|
||||
setenv("CST_REPLAY_SSE", sse, 1);
|
||||
setenv("CST_RECORD_SSE", rec, 1);
|
||||
setenv("CST_MCP_WAIT_MS", "0", 1);
|
||||
setenv("XDG_CONFIG_HOME", root, 1); /* no real models.json or caches */
|
||||
unsetenv("CST_CTX_WINDOW");
|
||||
unsetenv("CST_NO_CACHE_HINTS");
|
||||
unsetenv("CST_PRICE");
|
||||
cst_llm_trace_reset();
|
||||
|
||||
cst_agent_config_t cfg;
|
||||
memset(&cfg, 0, sizeof(cfg));
|
||||
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", root);
|
||||
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/s.jsonl", root);
|
||||
snprintf(cfg.api_base, sizeof(cfg.api_base), "http://replay/v1");
|
||||
snprintf(cfg.model, sizeof(cfg.model), "deepseek-flash");
|
||||
snprintf(cfg.provider, sizeof(cfg.provider), "deepseek");
|
||||
cfg.protocol = CST_PROTO_NATIVE_EOS;
|
||||
cfg.max_steps = 8;
|
||||
cfg.headless = 1;
|
||||
cfg.approval = CST_APPROVAL_AUTO;
|
||||
cst_agent_t *a = cst_agent_create(&cfg);
|
||||
EXPECT(a != NULL, "agent created");
|
||||
if (!a)
|
||||
return 1;
|
||||
|
||||
EXPECT(run(a, "read a.txt") == 0, "turn 1");
|
||||
cst_agent_config_mut(a)->plan_mode = 1;
|
||||
EXPECT(run(a, "now write b.txt") == 0, "turn 2 (plan)");
|
||||
cst_agent_config_mut(a)->plan_mode = 0;
|
||||
cst_agent_config_mut(a)->approval = CST_APPROVAL_YOLO;
|
||||
EXPECT(run(a, "a long answer please") == 0, "turn 3 (continued)");
|
||||
EXPECT(run(a, "bye") == 0, "turn 4");
|
||||
|
||||
char bpath[1100];
|
||||
snprintf(bpath, sizeof(bpath), "%s/b.txt", root);
|
||||
EXPECT(access(bpath, F_OK) != 0, "Plan mode refused write_file");
|
||||
|
||||
cJSON *req[NFIX];
|
||||
for (int i = 0; i < NFIX; i++) {
|
||||
req[i] = load_req(rec, i);
|
||||
EXPECT(req[i] != NULL, "request recorded");
|
||||
}
|
||||
if (req[NFIX - 1]) {
|
||||
char *tools0 = print(cJSON_GetObjectItemCaseSensitive(req[0], "tools"));
|
||||
char *sys0 = print(cJSON_GetArrayItem(msgs_of(req[0]), 0));
|
||||
EXPECT(tools0 && strstr(tools0, "write_file"),
|
||||
"write tools stay in the schema");
|
||||
for (int i = 0; i < NFIX; i++) {
|
||||
char *tools = print(cJSON_GetObjectItemCaseSensitive(req[i], "tools"));
|
||||
char *sys = print(cJSON_GetArrayItem(msgs_of(req[i]), 0));
|
||||
EXPECT(tools && tools0 && strcmp(tools, tools0) == 0,
|
||||
"tools[] identical in every request");
|
||||
EXPECT(sys && sys0 && strcmp(sys, sys0) == 0,
|
||||
"system prompt identical in every request");
|
||||
const char *tc = str_of(req[i], "tool_choice");
|
||||
EXPECT(tc && strcmp(tc, "auto") == 0, "tool_choice stays auto");
|
||||
free(tools);
|
||||
free(sys);
|
||||
if (i + 1 < NFIX && !extends(req[i], req[i + 1])) {
|
||||
fprintf(stderr, " request %d does not extend request %d\n", i + 1, i);
|
||||
nfail++;
|
||||
}
|
||||
}
|
||||
free(tools0);
|
||||
free(sys0);
|
||||
|
||||
EXPECT(any_msg_has(req[1], "reasoning_content", "look at the file"),
|
||||
"DeepSeek reasoning passed back");
|
||||
EXPECT(strstr(str_of(last_msg(req[2]), "content"), "Plan mode is now ON"),
|
||||
"Plan mode announced by a note");
|
||||
EXPECT(any_msg_has(req[3], "content", "Plan mode is on"),
|
||||
"write_file denied with a Plan mode error");
|
||||
const char *t3 = str_of(last_msg(req[4]), "content");
|
||||
EXPECT(t3 && strstr(t3, "Plan mode is now OFF") &&
|
||||
strstr(t3, "Approval mode is now yolo"),
|
||||
"Build mode and approval change announced");
|
||||
const cJSON *pm = last_msg(req[5]);
|
||||
EXPECT(is_prefix_msg(pm) && strcmp(str_of(pm, "role"), "assistant") == 0 &&
|
||||
strcmp(str_of(pm, "content"), "part one") == 0,
|
||||
"cut-off reply continued through an assistant prefix");
|
||||
EXPECT(any_msg_has(req[6], "content", "part one part two"),
|
||||
"continued reply stored as one message");
|
||||
}
|
||||
for (int i = 0; i < NFIX; i++)
|
||||
cJSON_Delete(req[i]);
|
||||
|
||||
cst_usage_t s, turn;
|
||||
cst_agent_usage(a, &s, &turn);
|
||||
EXPECT(s.requests == NFIX, "every response counted");
|
||||
EXPECT(s.prompt == 1000L * NFIX && s.cached == 900L * NFIX &&
|
||||
s.miss == 100L * NFIX,
|
||||
"hit and miss tokens summed");
|
||||
EXPECT(s.priced && s.cost > 0 && strcmp(s.currency, "¥") == 0,
|
||||
"DeepSeek cost estimated");
|
||||
EXPECT(turn.requests == 1, "last turn usage");
|
||||
|
||||
cst_agent_destroy(a);
|
||||
test_vision(root);
|
||||
test_rmrf(root);
|
||||
if (nfail) {
|
||||
fprintf(stderr, "test_prefix_stable: %d failed\n", nfail);
|
||||
return 1;
|
||||
}
|
||||
printf("test_prefix_stable: OK\n");
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user