Files
CST-agent/tests/test_llm_body.c
T
WHC 82dee65942 v0.8.15: custom providers and model parameters under Advanced options
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
2026-10-11 13:26:02 +08:00

576 lines
21 KiB
C

#include "cJSON.h"
#include "cst_agent.h"
#include "cst_attach.h"
#include "cst_test_util.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 cst_message_t msg(cst_role_t role, const char *content) {
cst_message_t m;
memset(&m, 0, sizeof(m));
m.role = role;
m.content = (char *)content;
return m;
}
static cJSON *build(const cst_message_t *msgs, int n, int tools,
size_t tool_cap) {
cst_llm_request_t r;
memset(&r, 0, sizeof(r));
r.model = "test-model";
r.msgs = msgs;
r.nmsgs = n;
r.enable_tools = tools;
r.tool_out_cap = tool_cap;
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 char *content_at(const cJSON *root, int i) {
const cJSON *msgs = cJSON_GetObjectItemCaseSensitive(root, "messages");
const cJSON *m = cJSON_GetArrayItem(msgs, i);
const cJSON *c = cJSON_GetObjectItemCaseSensitive(m, "content");
return cJSON_IsString(c) ? c->valuestring : NULL;
}
static const char *role_at(const cJSON *root, int i) {
const cJSON *msgs = cJSON_GetObjectItemCaseSensitive(root, "messages");
const cJSON *m = cJSON_GetArrayItem(msgs, i);
const cJSON *r = cJSON_GetObjectItemCaseSensitive(m, "role");
return cJSON_IsString(r) ? r->valuestring : "";
}
static int nmsgs(const cJSON *root) {
return cJSON_GetArraySize(cJSON_GetObjectItemCaseSensitive(root, "messages"));
}
/* TAB / quotes / backslash / control bytes / CJK must survive verbatim
* (the old hand-rolled escaper turned a Makefile TAB into HTTP 400). */
static void test_escaping(void) {
const char *text = "all:\n\tgcc -o \"a b\" x.c \\\n\x01\x1f end 中文";
cst_message_t m[2] = {msg(CST_ROLE_SYSTEM, "sys"), msg(CST_ROLE_USER, text)};
cJSON *j = build(m, 2, 0, 0);
const char *c = content_at(j, 1);
EXPECT(c && strcmp(c, text) == 0, "user content roundtrips exactly");
EXPECT(!cJSON_GetObjectItemCaseSensitive(j, "tools"), "no tools when off");
const cJSON *so = cJSON_GetObjectItemCaseSensitive(j, "stream_options");
EXPECT(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(so, "include_usage")),
"stream_options.include_usage");
cJSON_Delete(j);
}
/* Settings -> Advanced: the generation parameters go on the wire only when
* they were set. An absent field is the provider's own default, which
* "temperature": 0 would not be. */
static void test_gen_params(void) {
cst_message_t m[1] = {msg(CST_ROLE_USER, "hi")};
cst_llm_request_t r;
memset(&r, 0, sizeof(r));
r.model = "test-model";
r.msgs = m;
r.nmsgs = 1;
r.temperature = 0.2;
r.top_p = 0.9;
r.max_tokens = 4096;
char *body = cst_llm_build_body(&r);
EXPECT(body != NULL, "body with parameters");
cJSON *j = body ? cJSON_Parse(body) : NULL;
free(body);
const cJSON *t = cJSON_GetObjectItemCaseSensitive(j, "temperature");
const cJSON *p = cJSON_GetObjectItemCaseSensitive(j, "top_p");
const cJSON *mt = cJSON_GetObjectItemCaseSensitive(j, "max_tokens");
EXPECT(cJSON_IsNumber(t) && t->valuedouble > 0.19 && t->valuedouble < 0.21,
"temperature is sent as a number");
EXPECT(cJSON_IsNumber(p) && cJSON_IsNumber(mt) && mt->valueint == 4096,
"top_p and max_tokens are sent");
cJSON_Delete(j);
/* unset: none of the three is in the body at all */
cJSON *plain = build(m, 1, 0, 0);
EXPECT(!cJSON_GetObjectItemCaseSensitive(plain, "temperature") &&
!cJSON_GetObjectItemCaseSensitive(plain, "top_p") &&
!cJSON_GetObjectItemCaseSensitive(plain, "max_tokens"),
"an unset parameter is not sent at all");
cJSON_Delete(plain);
}
static void test_invalid_utf8(void) {
const char text[] = "ok \xff\xfe bad \xe4\xb8 trunc";
cst_message_t m[1] = {msg(CST_ROLE_USER, text)};
cJSON *j = build(m, 1, 0, 0);
const char *c = content_at(j, 0);
EXPECT(c != NULL, "content present");
EXPECT(c && strlen(c) == strlen(text), "sanitize keeps byte length");
EXPECT(c && strstr(c, "ok ") == c, "valid prefix kept");
cJSON_Delete(j);
}
static void test_tool_calls_and_null_content(void) {
cst_message_t m[3];
m[0] = msg(CST_ROLE_USER, "go");
m[1] = msg(CST_ROLE_ASSISTANT, "");
m[1].tool_calls_json =
"[{\"id\":\"c1\",\"type\":\"function\",\"function\":{\"name\":"
"\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"a\\tb.c\\\"}\"}}]";
m[2] = msg(CST_ROLE_TOOL, "[tool read_file id=c1 exit=0]\nhello");
snprintf(m[2].tool_call_id, sizeof(m[2].tool_call_id), "c1");
cJSON *j = build(m, 3, 1, 0);
const cJSON *msgs = cJSON_GetObjectItemCaseSensitive(j, "messages");
const cJSON *a = cJSON_GetArrayItem(msgs, 1);
EXPECT(cJSON_IsNull(cJSON_GetObjectItemCaseSensitive(a, "content")),
"assistant content null with tool_calls");
const cJSON *tc = cJSON_GetObjectItemCaseSensitive(a, "tool_calls");
EXPECT(cJSON_IsArray(tc) && cJSON_GetArraySize(tc) == 1, "tool_calls kept");
const cJSON *t = cJSON_GetArrayItem(msgs, 2);
EXPECT(strcmp(role_at(j, 2), "tool") == 0, "tool role");
const cJSON *tid = cJSON_GetObjectItemCaseSensitive(t, "tool_call_id");
EXPECT(cJSON_IsString(tid) && strcmp(tid->valuestring, "c1") == 0,
"tool_call_id");
const cJSON *tools = cJSON_GetObjectItemCaseSensitive(j, "tools");
/* The exact set depends on the host: a tool whose platform probe fails is
* offered nowhere, so look for a core tool instead of counting. */
char *tj = tools ? cJSON_PrintUnformatted(tools) : NULL;
EXPECT(cJSON_IsArray(tools) && cJSON_GetArraySize(tools) >= 4 && tj &&
strstr(tj, "read_file") != NULL,
"tools[] schema present");
free(tj);
const cJSON *choice = cJSON_GetObjectItemCaseSensitive(j, "tool_choice");
EXPECT(cJSON_IsString(choice) && strcmp(choice->valuestring, "auto") == 0,
"tool_choice auto");
cJSON_Delete(j);
}
static void test_merge_and_orphan_tool(void) {
cst_message_t m[3];
m[0] = msg(CST_ROLE_USER, "first");
m[1] = msg(CST_ROLE_TOOL, "orphan output"); /* no id -> user text */
m[2] = msg(CST_ROLE_USER, "third");
cJSON *j = build(m, 3, 0, 0);
EXPECT(nmsgs(j) == 1, "consecutive user messages merged");
const char *c = content_at(j, 0);
EXPECT(c && strstr(c, "first") && strstr(c, "orphan output") &&
strstr(c, "third"),
"merged content keeps all parts in order");
cJSON_Delete(j);
}
static void test_tool_cap_head_tail(void) {
size_t n = 100000;
char *big = malloc(n + 1);
memset(big, 'x', n);
memcpy(big, "HEAD", 4);
memcpy(big + n - 4, "TAIL", 4);
big[n] = 0;
cst_message_t m[3];
m[0] = msg(CST_ROLE_USER, "go");
m[1] = msg(CST_ROLE_ASSISTANT, "");
m[1].tool_calls_json = "[{\"id\":\"c1\",\"type\":\"function\",\"function\":"
"{\"name\":\"run_shell\",\"arguments\":\"{}\"}}]";
m[2] = msg(CST_ROLE_TOOL, big);
snprintf(m[2].tool_call_id, sizeof(m[2].tool_call_id), "c1");
cJSON *j = build(m, 3, 1, 1000);
const char *c = content_at(j, 2);
EXPECT(c && strlen(c) < 1200, "tool output capped");
EXPECT(c && strncmp(c, "HEAD", 4) == 0, "head kept");
EXPECT(c && strcmp(c + strlen(c) - 4, "TAIL") == 0, "tail kept");
EXPECT(c && strstr(c, "omitted"), "omission note");
cJSON_Delete(j);
/* user text (e.g. text-protocol result frames) is never capped */
cst_message_t u[1] = {msg(CST_ROLE_USER, big)};
j = build(u, 1, 0, 1000);
c = content_at(j, 0);
EXPECT(c && strlen(c) == n, "user text not capped");
cJSON_Delete(j);
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);
}
/* Every element of every content array must be a typed part object. */
static int parts_typed(const cJSON *root) {
const cJSON *m;
cJSON_ArrayForEach(m, cJSON_GetObjectItemCaseSensitive(root, "messages")) {
const cJSON *c = cJSON_GetObjectItemCaseSensitive(m, "content");
if (!cJSON_IsArray(c))
continue;
const cJSON *p;
cJSON_ArrayForEach(p, c) {
const cJSON *t = cJSON_GetObjectItemCaseSensitive(p, "type");
if (!cJSON_IsObject(p) || !cJSON_IsString(t))
return 0;
}
}
return 1;
}
static void test_images_on(void) {
char dir[1024], sess[1100], err[256];
if (test_tmpdir(dir, sizeof(dir), "cst-body") != 0) {
EXPECT(0, "scratch dir");
return;
}
snprintf(sess, sizeof(sess), "%s/s.jsonl", dir);
static const unsigned char png[32] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A,
'\n', 0, 0, 0, 13, 'I', 'H', 'D', 'R',
0, 0, 0, 2, 0, 0, 0, 2};
cst_attachment_t *img = cst_attach_import_bytes(sess, "shot.png",
(const char *)png,
sizeof(png), err, sizeof(err));
EXPECT(img != NULL, "image stored");
if (!img)
return;
cst_attachment_t gone = *img;
snprintf(gone.rel, sizeof(gone.rel), "missing.png");
cst_message_t m[5];
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?");
m[4] = msg(CST_ROLE_USER, "");
m[4].attach = &gone;
m[4].nattach = 1;
cst_llm_request_t r;
memset(&r, 0, sizeof(r));
r.model = "deepseek-flash";
r.msgs = m;
r.nmsgs = 5;
r.session_path = sess;
cJSON *j = build_req(&r);
EXPECT(parts_typed(j), "content arrays hold only typed parts");
const cJSON *p0 = cJSON_GetObjectItemCaseSensitive(
cJSON_GetArrayItem(cJSON_GetObjectItemCaseSensitive(j, "messages"), 0),
"content");
const cJSON *t0 = cJSON_GetObjectItemCaseSensitive(
cJSON_GetArrayItem(p0, 0), "text");
const cJSON *u1 = cJSON_GetObjectItemCaseSensitive(
cJSON_GetObjectItemCaseSensitive(cJSON_GetArrayItem(p0, 1), "image_url"),
"url");
EXPECT(cJSON_IsString(t0) && strcmp(t0->valuestring, "what is this") == 0,
"user text is a text part");
EXPECT(cJSON_IsString(u1) &&
strncmp(u1->valuestring, "data:image/png;base64,", 22) == 0,
"image is a data URL part");
char *s = cJSON_PrintUnformatted(j);
EXPECT(s && strstr(s, "and now?") && strstr(s, "not sent"),
"follow-up text and the missing-image note are parts too");
free(s);
cJSON_Delete(j);
cst_attach_free(img);
}
/* Every text part of message i, joined (malloc'd, "" when there is none). */
static char *text_of(const cJSON *root, int i) {
const cJSON *m =
cJSON_GetArrayItem(cJSON_GetObjectItemCaseSensitive(root, "messages"), i);
const cJSON *c = cJSON_GetObjectItemCaseSensitive(m, "content");
if (cJSON_IsString(c)) {
size_t n = strlen(c->valuestring);
char *out = malloc(n + 1);
if (out)
memcpy(out, c->valuestring, n + 1);
return out;
}
size_t len = 1;
const cJSON *p, *t, *s;
cJSON_ArrayForEach(p, c) {
t = cJSON_GetObjectItemCaseSensitive(p, "type");
s = cJSON_GetObjectItemCaseSensitive(p, "text");
if (cJSON_IsString(t) && strcmp(t->valuestring, "text") == 0 &&
cJSON_IsString(s))
len += strlen(s->valuestring) + 1;
}
char *out = calloc(1, len);
if (!out)
return NULL;
cJSON_ArrayForEach(p, c) {
t = cJSON_GetObjectItemCaseSensitive(p, "type");
s = cJSON_GetObjectItemCaseSensitive(p, "text");
if (cJSON_IsString(t) && strcmp(t->valuestring, "text") == 0 &&
cJSON_IsString(s)) {
strcat(out, s->valuestring);
strcat(out, "\n");
}
}
return out;
}
/* A document is inlined as text, so a text-only model and a message that
* also carries an image both keep it; the image is what becomes a note. */
static void test_document_parts(void) {
char dir[1024], sess[1100], err[256], src[1200];
if (test_tmpdir(dir, sizeof(dir), "cst-body-doc") != 0) {
EXPECT(0, "scratch dir");
return;
}
snprintf(sess, sizeof(sess), "%s/s.jsonl", dir);
snprintf(src, sizeof(src), "%s/paper.pdf", dir);
static const char PDF[] =
"%PDF-1.4\n"
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"
"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"
"3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 4 0 R "
">> >> /Contents 5 0 R >>\nendobj\n"
"4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\n"
"endobj\n"
"5 0 obj\n<< >>\nstream\n"
"BT /F1 12 Tf 72 720 Td (Inlined paper text) Tj ET\n"
"endstream\nendobj\n"
"trailer\n<< /Root 1 0 R >>\n%%EOF\n";
FILE *f = fopen(src, "wb");
EXPECT(f != NULL, "write the PDF");
if (!f)
return;
fwrite(PDF, 1, strlen(PDF), f);
fclose(f);
cst_attachment_t *doc = cst_attach_import_file(sess, src, err, sizeof(err));
EXPECT(doc != NULL, "PDF stored");
if (!doc)
return;
static const unsigned char png[32] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A,
'\n', 0, 0, 0, 13, 'I', 'H', 'D', 'R',
0, 0, 0, 2, 0, 0, 0, 2};
cst_attachment_t *img = cst_attach_import_bytes(
sess, "shot.png", (const char *)png, sizeof(png), err, sizeof(err));
EXPECT(img != NULL, "image stored");
if (!img) {
cst_attach_free(doc);
return;
}
cst_attachment_t both[2] = {*doc, *img};
cst_message_t m[2];
m[0] = msg(CST_ROLE_USER, "what does this say?");
m[0].attach = both;
m[0].nattach = 2;
m[1] = msg(CST_ROLE_USER, "thanks");
cst_llm_request_t r;
memset(&r, 0, sizeof(r));
r.model = "deepseek-flash";
r.msgs = m;
r.nmsgs = 2;
r.images_off = 1; /* a text-only model */
r.session_path = sess;
cJSON *j = build_req(&r);
char *txt = text_of(j, 0);
EXPECT(txt && strstr(txt, "[document \"paper.pdf\"") &&
strstr(txt, "Inlined paper text") &&
strstr(txt, "[-- end of document --]"),
"the PDF text is inlined even when images are off");
EXPECT(txt && strstr(txt, "[image \"shot.png\"") && strstr(txt, "not sent"),
"the image beside it is still only a note");
free(txt);
char *s = cJSON_PrintUnformatted(j);
EXPECT(s && !strstr(s, "image_url"), "no image part with images off");
free(s);
cJSON_Delete(j);
cst_attach_free(doc);
cst_attach_free(img);
}
int main(void) {
test_images_off();
test_images_on();
test_document_parts();
test_reasoning_and_prefix();
test_cache_hints();
test_tools_snapshot();
test_gen_params();
test_escaping();
test_invalid_utf8();
test_tool_calls_and_null_content();
test_merge_and_orphan_tool();
test_tool_cap_head_tail();
if (nfail) {
fprintf(stderr, "test_llm_body: %d failed\n", nfail);
return 1;
}
printf("test_llm_body: OK\n");
return 0;
}