diff --git a/src/core/openai.c b/src/core/openai.c index ae4575c..d3dd082 100644 --- a/src/core/openai.c +++ b/src/core/openai.c @@ -426,6 +426,93 @@ void chat_completion_request_free(chat_completion_request_t *req) { free_str_array(req->params.stop, req->params.stop_count); } +static yyjson_mut_val *tool_call_template_obj(const tool_call_t *tc, yyjson_mut_doc *doc) { + yyjson_mut_val *o = yyjson_mut_obj(doc); + if (!o) + return NULL; + if (tc->id) + yyjson_mut_obj_add_strcpy(doc, o, "id", tc->id); + yyjson_mut_obj_add_strcpy(doc, o, "type", "function"); + yyjson_mut_val *fn = yyjson_mut_obj(doc); + if (!fn) + return NULL; + if (tc->function_name) + yyjson_mut_obj_add_strcpy(doc, fn, "name", tc->function_name); + yyjson_mut_obj_add_strcpy(doc, fn, "arguments", tc->arguments ? tc->arguments : ""); + yyjson_mut_obj_add_val(doc, o, "function", fn); + return o; +} + +yyjson_mut_val *messages_template_serialize(const message_t *msgs, int count, + yyjson_mut_doc *doc) { + if (!doc || count < 0 || (count > 0 && !msgs)) + return NULL; + + yyjson_mut_val *arr = yyjson_mut_arr(doc); + if (!arr) + return NULL; + + for (int i = 0; i < count; i++) { + const message_t *msg = &msgs[i]; + yyjson_mut_val *o = yyjson_mut_obj(doc); + if (!o) + return NULL; + + yyjson_mut_obj_add_strcpy(doc, o, "role", role_str(msg->role)); + + /* Content rules for template input: + * CONTENT_NONE -> JSON null (intentional absent/null) + * CONTENT_STRING + ptr -> JSON string (incl. ""); direct copy + * CONTENT_STRING + NULL -> fail (parse-OOM shape, not intentional null) + * CONTENT_PARTS -> flatten; NULL flatten is OOM -> fail + */ + switch (msg->content.kind) { + case CONTENT_NONE: + yyjson_mut_obj_add_null(doc, o, "content"); + break; + case CONTENT_STRING: + /* NULL string is parse-OOM shape, not intentional null. */ + if (!msg->content.string) + return NULL; + /* Direct copy - avoids strdup + add_strcpy double copy. */ + yyjson_mut_obj_add_strcpy(doc, o, "content", msg->content.string); + break; + case CONTENT_PARTS: { + char *text = message_content_text(&msg->content); + if (!text) + return NULL; /* OOM: never emit null for parts */ + yyjson_mut_obj_add_strcpy(doc, o, "content", text); + free(text); + break; + } + default: + return NULL; + } + + if (msg->name) + yyjson_mut_obj_add_strcpy(doc, o, "name", msg->name); + if (msg->tool_call_id) + yyjson_mut_obj_add_strcpy(doc, o, "tool_call_id", msg->tool_call_id); + + if (msg->tool_call_count > 0) { + yyjson_mut_val *tcs = yyjson_mut_arr(doc); + if (!tcs) + return NULL; + for (int j = 0; j < msg->tool_call_count; j++) { + yyjson_mut_val *tc = tool_call_template_obj(&msg->tool_calls[j], doc); + if (!tc) + return NULL; + yyjson_mut_arr_add_val(tcs, tc); + } + yyjson_mut_obj_add_val(doc, o, "tool_calls", tcs); + } + + yyjson_mut_arr_add_val(arr, o); + } + + return arr; +} + yyjson_mut_val *chat_completion_response_serialize(const chat_completion_response_t *resp, yyjson_mut_doc *doc) { yyjson_mut_val *root = yyjson_mut_obj(doc); diff --git a/src/core/openai.h b/src/core/openai.h index bb2ff5d..805c928 100644 --- a/src/core/openai.h +++ b/src/core/openai.h @@ -29,6 +29,13 @@ typedef struct { int chat_completion_request_parse(chat_completion_request_t *req, yyjson_val *root, const char **err); void chat_completion_request_free(chat_completion_request_t *req); +/* Serialize messages for chat-template input: content is always string or null + * (content-part arrays are flattened via message_content_text). + * Returns NULL on OOM or on CONTENT_STRING with NULL string (parse-OOM shape). + * Intentional absent/null content is CONTENT_NONE and serializes as JSON null. */ +yyjson_mut_val *messages_template_serialize(const message_t *msgs, int count, + yyjson_mut_doc *doc); + /* --- Chat completion response --------------------------------------------- */ /* One candidate in a sampled token's top_logprobs list. */ diff --git a/src/core/types.c b/src/core/types.c index 0d35ef1..d3921ce 100644 --- a/src/core/types.c +++ b/src/core/types.c @@ -83,6 +83,49 @@ void message_content_free(message_content_t *content) { free(content->parts); } +/* OpenAI content-part union: only type == "text" carries prompt text. */ +static bool content_part_is_text(const content_part_t *p) { + return p && p->type && p->text && strcmp(p->type, "text") == 0; +} + +char *message_content_text(const message_content_t *c) { + if (!c) + return NULL; + + switch (c->kind) { + case CONTENT_NONE: + return NULL; + + case CONTENT_STRING: + if (!c->string) + return NULL; + return strdup(c->string); + + case CONTENT_PARTS: { + size_t total = 0; + for (int i = 0; i < c->part_count; i++) { + if (!content_part_is_text(&c->parts[i])) + continue; + total += strlen(c->parts[i].text); + } + char *out = malloc(total + 1); + if (!out) + return NULL; + size_t off = 0; + for (int i = 0; i < c->part_count; i++) { + if (!content_part_is_text(&c->parts[i])) + continue; + size_t n = strlen(c->parts[i].text); + memcpy(out + off, c->parts[i].text, n); + off += n; + } + out[off] = '\0'; + return out; + } + } + return NULL; +} + void message_free(message_t *msg) { if (!msg) return; diff --git a/src/core/types.h b/src/core/types.h index 8749e78..377cefd 100644 --- a/src/core/types.h +++ b/src/core/types.h @@ -109,6 +109,16 @@ typedef struct { void content_part_free(content_part_t *part); void message_content_free(message_content_t *content); +/* Flatten message content to a single owned C string for chat-template input. + * CONTENT_NONE / NULL input / NULL string -> NULL. + * CONTENT_STRING -> strdup of the string (incl. ""). + * CONTENT_PARTS -> concatenation of parts with type "text" and non-NULL text + * (no separator); empty string if no text fragments contribute. + * Note: CONTENT_STRING + NULL is the parse-OOM shape; template serialize treats + * that as error (returns NULL), while this helper just returns NULL. + * Caller frees the result. */ +char *message_content_text(const message_content_t *c); + /* --- Message -------------------------------------------------------------- */ typedef struct { diff --git a/src/http/handler.c b/src/http/handler.c index 6a4647c..9786512 100644 --- a/src/http/handler.c +++ b/src/http/handler.c @@ -167,7 +167,35 @@ static void handle_chat_completions(const http_request_t *req, } } - char *messages_json = yyjson_val_write(yyjson_obj_get(root, "messages"), 0, NULL); + /* Serialize from parsed creq so content-part arrays are flattened to + * strings before Jinja render (issue #126). Raw yyjson_val_write would + * pass arrays through and break string-content HF templates. */ + yyjson_mut_doc *msg_doc = yyjson_mut_doc_new(NULL); + if (!msg_doc) { + respond_json_error(resp, 500, "server_error", NULL, "out of memory"); + chat_completion_request_free(&creq); + yyjson_doc_free(doc); + return; + } + yyjson_mut_val *msg_arr = + messages_template_serialize(creq.messages, creq.message_count, msg_doc); + if (!msg_arr) { + respond_json_error(resp, 500, "server_error", NULL, "out of memory"); + yyjson_mut_doc_free(msg_doc); + chat_completion_request_free(&creq); + yyjson_doc_free(doc); + return; + } + yyjson_mut_doc_set_root(msg_doc, msg_arr); + char *messages_json = yyjson_mut_write(msg_doc, 0, NULL); + yyjson_mut_doc_free(msg_doc); + if (!messages_json) { + respond_json_error(resp, 500, "server_error", NULL, "out of memory"); + chat_completion_request_free(&creq); + yyjson_doc_free(doc); + return; + } + yyjson_val *tools_val = yyjson_obj_get(root, "tools"); char *tools_json = tools_val ? yyjson_val_write(tools_val, 0, NULL) : NULL; diff --git a/tests/test_http_gen.c b/tests/test_http_gen.c index f5034b2..8752c61 100644 --- a/tests/test_http_gen.c +++ b/tests/test_http_gen.c @@ -628,6 +628,57 @@ static void test_bos_token_override(void) { tokenizer_free(tok); } + +/* --- Issue #126: parts-normalized messages_json matches plain string ------ */ + +static void test_chat_prompt_parts_normalized_matches_string(void) { + tokenizer_t *tok = tokenizer_load(MLXD_FIXTURES_DIR "/gpt2/tokenizer.json"); + assert(tok != NULL); + + const char *body = + "{\"model\":\"gpt2\",\"messages\":[" + "{\"role\":\"user\",\"content\":[" + "{\"type\":\"text\",\"text\":\"hel\"}," + "{\"type\":\"text\",\"text\":\"lo world\"}" + "]}" + "]}"; + yyjson_doc *doc = yyjson_read(body, strlen(body), 0); + assert(doc != NULL); + chat_completion_request_t creq = {0}; + const char *perr = NULL; + assert(chat_completion_request_parse(&creq, yyjson_doc_get_root(doc), &perr) == 0); + + yyjson_mut_doc *mdoc = yyjson_mut_doc_new(NULL); + assert(mdoc != NULL); + yyjson_mut_val *arr = + messages_template_serialize(creq.messages, creq.message_count, mdoc); + assert(arr != NULL); + yyjson_mut_doc_set_root(mdoc, arr); + char *messages_json = yyjson_mut_write(mdoc, 0, NULL); + assert(messages_json != NULL); + yyjson_mut_doc_free(mdoc); + + int32_t *ids = NULL; + const char *err = NULL; + int n = gen_build_chat_prompt(tok, TRIVIAL_TMPL, messages_json, NULL, NULL, + &ids, &err); + assert(n > 0); + assert(err == NULL); + + int32_t *direct_ids = NULL; + int direct_n = + tokenizer_encode_alloc(tok, "hello world", 11, false, &direct_ids); + assert(direct_n == n); + assert(memcmp(ids, direct_ids, (size_t)n * sizeof(int32_t)) == 0); + + free(ids); + free(direct_ids); + free(messages_json); + chat_completion_request_free(&creq); + yyjson_doc_free(doc); + tokenizer_free(tok); +} + int main(void) { test_chat_prompt_matches_direct(); test_completion_prompt(); @@ -660,6 +711,7 @@ int main(void) { test_extra_json_empty_string_ok(); test_bos_token_seed(); test_bos_token_override(); + test_chat_prompt_parts_normalized_matches_string(); printf("test_http_gen: all passed\n"); return 0; } diff --git a/tests/test_http_generate.c b/tests/test_http_generate.c index ece0049..4c5c5b7 100644 --- a/tests/test_http_generate.c +++ b/tests/test_http_generate.c @@ -979,6 +979,84 @@ static void test_enable_thinking_true(void) { gen_fixture_down(&f); } + +/* --- Issue #126: content-part array format must not fail template render -- */ + +/* Real HF templates call string methods on message.content; array content + * makes those fail. TRIVIAL_TMPL alone silently renders arrays as empty. */ +#define PARTS_TMPL "{{ messages[0].content.strip() }}" + +static void test_chat_content_parts_array_ok(void) { + gen_fixture_t f = gen_fixture_up(true, true, PARTS_TMPL, 0); + + /* Single text part array - the SDK default that previously 400'd */ + { + http_client_response_t resp = post_json(f.port, "/v1/chat/completions", + "{\"model\":\"gpt2\",\"messages\":[" + "{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"hello\"}]}" + "],\"max_tokens\":8}"); + assert(resp.status == 200); + + yyjson_doc *doc = yyjson_read(resp.body, resp.body_len, 0); + assert(doc != NULL); + yyjson_val *root = yyjson_doc_get_root(doc); + assert(strcmp(yyjson_get_str(yyjson_obj_get(root, "object")), + "chat.completion") == 0); + yyjson_val *choices = yyjson_obj_get(root, "choices"); + assert(yyjson_arr_size(choices) == 1); + yyjson_val *msg = yyjson_obj_get(yyjson_arr_get(choices, 0), "message"); + assert(msg != NULL); + const char *content = yyjson_get_str(yyjson_obj_get(msg, "content")); + assert(content != NULL); + assert(strcmp(content, "hello") == 0); + yyjson_doc_free(doc); + http_client_response_free(&resp); + } + + /* Multi-part text still 200 with flattened content */ + { + http_client_response_t resp = post_json(f.port, "/v1/chat/completions", + "{\"model\":\"gpt2\",\"messages\":[" + "{\"role\":\"user\",\"content\":[" + "{\"type\":\"text\",\"text\":\"hel\"}," + "{\"type\":\"text\",\"text\":\"lo\"}" + "]}" + "],\"max_tokens\":8}"); + assert(resp.status == 200); + + yyjson_doc *doc = yyjson_read(resp.body, resp.body_len, 0); + assert(doc != NULL); + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *msg = yyjson_obj_get( + yyjson_arr_get(yyjson_obj_get(root, "choices"), 0), "message"); + const char *content = yyjson_get_str(yyjson_obj_get(msg, "content")); + assert(content != NULL); + assert(strcmp(content, "hello") == 0); + yyjson_doc_free(doc); + http_client_response_free(&resp); + } + + /* Plain string still works with the same template */ + { + http_client_response_t resp = post_json(f.port, "/v1/chat/completions", + "{\"model\":\"gpt2\",\"messages\":[" + "{\"role\":\"user\",\"content\":\"hello\"}" + "],\"max_tokens\":8}"); + assert(resp.status == 200); + yyjson_doc *doc = yyjson_read(resp.body, resp.body_len, 0); + assert(doc != NULL); + yyjson_val *msg = yyjson_obj_get( + yyjson_arr_get(yyjson_obj_get(yyjson_doc_get_root(doc), "choices"), 0), + "message"); + const char *content = yyjson_get_str(yyjson_obj_get(msg, "content")); + assert(content && strcmp(content, "hello") == 0); + yyjson_doc_free(doc); + http_client_response_free(&resp); + } + + gen_fixture_down(&f); +} + /* --- enable_thinking: non-boolean -> 400 --------------------------------- */ static void test_enable_thinking_non_boolean_400(void) { @@ -1039,6 +1117,7 @@ int main(void) { test_enable_thinking_false(); test_enable_thinking_true(); test_enable_thinking_non_boolean_400(); + test_chat_content_parts_array_ok(); printf("test_http_generate: all passed\n"); unsetenv("MLXD_CACHE_DIR"); diff --git a/tests/test_openai.c b/tests/test_openai.c index 36e9e51..69850ca 100644 --- a/tests/test_openai.c +++ b/tests/test_openai.c @@ -1056,6 +1056,439 @@ static void test_parse_top_k_integral_double(void) { yyjson_doc_free(doc); } +/* --- Issue #126: message_content_text flatten helper --------------------- */ + +static void test_message_content_text(void) { + /* 1. CONTENT_STRING "hello" -> "hello" */ + { + message_content_t c = {.kind = CONTENT_STRING, .string = (char *)"hello"}; + char *out = message_content_text(&c); + assert(out != NULL); + assert(strcmp(out, "hello") == 0); + free(out); + } + + /* 2. CONTENT_NONE -> NULL */ + { + message_content_t c = {.kind = CONTENT_NONE}; + char *out = message_content_text(&c); + assert(out == NULL); + } + + /* 3. single text part -> "hello" */ + { + content_part_t parts[1] = {{ + .type = (char *)"text", + .text = (char *)"hello", + }}; + message_content_t c = { + .kind = CONTENT_PARTS, + .parts = parts, + .part_count = 1, + }; + char *out = message_content_text(&c); + assert(out != NULL); + assert(strcmp(out, "hello") == 0); + free(out); + } + + /* 4. two text parts -> "foobar" (no separator) */ + { + content_part_t parts[2] = { + {.type = (char *)"text", .text = (char *)"foo"}, + {.type = (char *)"text", .text = (char *)"bar"}, + }; + message_content_t c = { + .kind = CONTENT_PARTS, + .parts = parts, + .part_count = 2, + }; + char *out = message_content_text(&c); + assert(out != NULL); + assert(strcmp(out, "foobar") == 0); + free(out); + } + + /* 5. text + image_url part -> text only */ + { + content_part_t parts[2] = { + {.type = (char *)"text", .text = (char *)"see this"}, + { + .type = (char *)"image_url", + .text = NULL, + .has_image_url = true, + .image_url = {.url = (char *)"https://example.com/x.png"}, + }, + }; + message_content_t c = { + .kind = CONTENT_PARTS, + .parts = parts, + .part_count = 2, + }; + char *out = message_content_text(&c); + assert(out != NULL); + assert(strcmp(out, "see this") == 0); + free(out); + } + + /* 6. image_url only -> "" */ + { + content_part_t parts[1] = {{ + .type = (char *)"image_url", + .text = NULL, + .has_image_url = true, + .image_url = {.url = (char *)"https://example.com/x.png"}, + }}; + message_content_t c = { + .kind = CONTENT_PARTS, + .parts = parts, + .part_count = 1, + }; + char *out = message_content_text(&c); + assert(out != NULL); + assert(strcmp(out, "") == 0); + free(out); + } + + /* 7. empty part_count == 0 with CONTENT_PARTS -> "" */ + { + message_content_t c = { + .kind = CONTENT_PARTS, + .parts = NULL, + .part_count = 0, + }; + char *out = message_content_text(&c); + assert(out != NULL); + assert(strcmp(out, "") == 0); + free(out); + } + + /* 8. part with text == NULL skipped between real text parts */ + { + content_part_t parts[3] = { + {.type = (char *)"text", .text = (char *)"a"}, + {.type = (char *)"image_url", .text = NULL}, + {.type = (char *)"text", .text = (char *)"b"}, + }; + message_content_t c = { + .kind = CONTENT_PARTS, + .parts = parts, + .part_count = 3, + }; + char *out = message_content_text(&c); + assert(out != NULL); + assert(strcmp(out, "ab") == 0); + free(out); + } + + /* NULL input -> NULL */ + assert(message_content_text(NULL) == NULL); + + /* CONTENT_STRING with NULL string -> NULL */ + { + message_content_t c = {.kind = CONTENT_STRING, .string = NULL}; + char *out = message_content_text(&c); + assert(out == NULL); + } + + /* CONTENT_STRING "" -> non-NULL "" (strip()-safe distinction from null) */ + { + message_content_t c = {.kind = CONTENT_STRING, .string = (char *)""}; + char *out = message_content_text(&c); + assert(out != NULL); + assert(out[0] == '\0'); + free(out); + } + + /* Unknown / non-text type with non-NULL text must NOT be joined. */ + { + content_part_t parts[2] = { + {.type = (char *)"text", .text = (char *)"keep"}, + {.type = (char *)"input_audio", .text = (char *)"drop-me"}, + }; + message_content_t c = { + .kind = CONTENT_PARTS, + .parts = parts, + .part_count = 2, + }; + char *out = message_content_text(&c); + assert(out != NULL); + assert(strcmp(out, "keep") == 0); + free(out); + } + + /* image_url with spurious text must NOT be joined. */ + { + content_part_t parts[2] = { + {.type = (char *)"text", .text = (char *)"see"}, + {.type = (char *)"image_url", + .text = (char *)"ignore", + .has_image_url = true, + .image_url = {.url = (char *)"https://example.com/x.png"}}, + }; + message_content_t c = { + .kind = CONTENT_PARTS, + .parts = parts, + .part_count = 2, + }; + char *out = message_content_text(&c); + assert(out != NULL); + assert(strcmp(out, "see") == 0); + free(out); + } + + /* NULL type with text: do not join (strict OpenAI union). */ + { + content_part_t parts[1] = { + {.type = NULL, .text = (char *)"nope"}, + }; + message_content_t c = { + .kind = CONTENT_PARTS, + .parts = parts, + .part_count = 1, + }; + char *out = message_content_text(&c); + assert(out != NULL); + assert(strcmp(out, "") == 0); + free(out); + } + + printf(" test_message_content_text: passed\n"); +} + +/* --- Issue #126: messages_template_serialize ------------------------------ */ + +static void test_messages_template_serialize(void) { + /* Multi-turn mix: string, parts, null+tool_calls, tool role, name */ + { + content_part_t user_parts[1] = {{ + .type = (char *)"text", + .text = (char *)"hello", + }}; + tool_call_t calls[1] = {{ + .id = (char *)"call_1", + .function_name = (char *)"get_weather", + .arguments = (char *)"{\"city\":\"NYC\"}", + }}; + message_t msgs[4] = { + { + .role = ROLE_USER, + .content = {.kind = CONTENT_STRING, .string = (char *)"plain"}, + .name = (char *)"alice", + }, + { + .role = ROLE_USER, + .content = + { + .kind = CONTENT_PARTS, + .parts = user_parts, + .part_count = 1, + }, + }, + { + .role = ROLE_ASSISTANT, + .content = {.kind = CONTENT_NONE}, + .tool_calls = calls, + .tool_call_count = 1, + }, + { + .role = ROLE_TOOL, + .content = {.kind = CONTENT_STRING, .string = (char *)"72F"}, + .tool_call_id = (char *)"call_1", + }, + }; + + yyjson_mut_doc *mdoc = yyjson_mut_doc_new(NULL); + assert(mdoc != NULL); + yyjson_mut_val *arr = messages_template_serialize(msgs, 4, mdoc); + assert(arr != NULL); + yyjson_mut_doc_set_root(mdoc, arr); + char *json = yyjson_mut_write(mdoc, 0, NULL); + assert(json != NULL); + yyjson_mut_doc_free(mdoc); + + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + free(json); + assert(doc != NULL); + yyjson_val *root = yyjson_doc_get_root(doc); + assert(yyjson_is_arr(root)); + assert(yyjson_arr_size(root) == 4); + + /* [0] user string + name */ + yyjson_val *m0 = yyjson_arr_get(root, 0); + assert(strcmp(yyjson_get_str(yyjson_obj_get(m0, "role")), "user") == 0); + assert(yyjson_is_str(yyjson_obj_get(m0, "content"))); + assert(strcmp(yyjson_get_str(yyjson_obj_get(m0, "content")), "plain") == 0); + assert(strcmp(yyjson_get_str(yyjson_obj_get(m0, "name")), "alice") == 0); + + /* [1] user parts flattened to string (not array) */ + yyjson_val *m1 = yyjson_arr_get(root, 1); + assert(strcmp(yyjson_get_str(yyjson_obj_get(m1, "role")), "user") == 0); + yyjson_val *c1 = yyjson_obj_get(m1, "content"); + assert(yyjson_is_str(c1)); + assert(!yyjson_is_arr(c1)); + assert(strcmp(yyjson_get_str(c1), "hello") == 0); + + /* [2] assistant null content + tool_calls */ + yyjson_val *m2 = yyjson_arr_get(root, 2); + assert(strcmp(yyjson_get_str(yyjson_obj_get(m2, "role")), "assistant") == 0); + assert(yyjson_is_null(yyjson_obj_get(m2, "content"))); + yyjson_val *tcs = yyjson_obj_get(m2, "tool_calls"); + assert(yyjson_is_arr(tcs)); + assert(yyjson_arr_size(tcs) == 1); + yyjson_val *tc0 = yyjson_arr_get(tcs, 0); + assert(strcmp(yyjson_get_str(yyjson_obj_get(tc0, "id")), "call_1") == 0); + assert(strcmp(yyjson_get_str(yyjson_obj_get(tc0, "type")), "function") == 0); + yyjson_val *fn = yyjson_obj_get(tc0, "function"); + assert(strcmp(yyjson_get_str(yyjson_obj_get(fn, "name")), "get_weather") == 0); + assert(strcmp(yyjson_get_str(yyjson_obj_get(fn, "arguments")), + "{\"city\":\"NYC\"}") == 0); + + /* [3] tool role with tool_call_id */ + yyjson_val *m3 = yyjson_arr_get(root, 3); + assert(strcmp(yyjson_get_str(yyjson_obj_get(m3, "role")), "tool") == 0); + assert(strcmp(yyjson_get_str(yyjson_obj_get(m3, "content")), "72F") == 0); + assert(strcmp(yyjson_get_str(yyjson_obj_get(m3, "tool_call_id")), "call_1") == 0); + + yyjson_doc_free(doc); + } + + /* Multi text parts flatten inside serialize */ + { + content_part_t parts[2] = { + {.type = (char *)"text", .text = (char *)"hel"}, + {.type = (char *)"text", .text = (char *)"lo"}, + }; + message_t msgs[1] = {{ + .role = ROLE_USER, + .content = + { + .kind = CONTENT_PARTS, + .parts = parts, + .part_count = 2, + }, + }}; + + yyjson_mut_doc *mdoc = yyjson_mut_doc_new(NULL); + assert(mdoc != NULL); + yyjson_mut_val *arr = messages_template_serialize(msgs, 1, mdoc); + assert(arr != NULL); + yyjson_mut_doc_set_root(mdoc, arr); + char *json = yyjson_mut_write(mdoc, 0, NULL); + assert(json != NULL); + yyjson_mut_doc_free(mdoc); + + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + free(json); + assert(doc != NULL); + yyjson_val *m0 = yyjson_arr_get(yyjson_doc_get_root(doc), 0); + assert(strcmp(yyjson_get_str(yyjson_obj_get(m0, "content")), "hello") == 0); + yyjson_doc_free(doc); + } + + /* count == 0 -> [] */ + { + yyjson_mut_doc *mdoc = yyjson_mut_doc_new(NULL); + assert(mdoc != NULL); + yyjson_mut_val *arr = messages_template_serialize(NULL, 0, mdoc); + assert(arr != NULL); + yyjson_mut_doc_set_root(mdoc, arr); + char *json = yyjson_mut_write(mdoc, 0, NULL); + assert(json != NULL); + yyjson_mut_doc_free(mdoc); + + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + free(json); + assert(doc != NULL); + assert(yyjson_is_arr(yyjson_doc_get_root(doc))); + assert(yyjson_arr_size(yyjson_doc_get_root(doc)) == 0); + yyjson_doc_free(doc); + } + + /* CONTENT_STRING "" must be JSON "", not null (strip()-safe). */ + { + message_t msgs[1] = {{ + .role = ROLE_USER, + .content = {.kind = CONTENT_STRING, .string = (char *)""}, + }}; + yyjson_mut_doc *mdoc = yyjson_mut_doc_new(NULL); + assert(mdoc != NULL); + yyjson_mut_val *arr = messages_template_serialize(msgs, 1, mdoc); + assert(arr != NULL); + yyjson_mut_doc_set_root(mdoc, arr); + char *json = yyjson_mut_write(mdoc, 0, NULL); + assert(json != NULL); + yyjson_mut_doc_free(mdoc); + + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + free(json); + assert(doc != NULL); + yyjson_val *m0 = yyjson_arr_get(yyjson_doc_get_root(doc), 0); + yyjson_val *c0 = yyjson_obj_get(m0, "content"); + assert(yyjson_is_str(c0)); + assert(!yyjson_is_null(c0)); + assert(strcmp(yyjson_get_str(c0), "") == 0); + yyjson_doc_free(doc); + } + + /* CONTENT_STRING + NULL string is parse-OOM shape: serialize must fail. */ + { + message_t msgs[1] = {{ + .role = ROLE_USER, + .content = {.kind = CONTENT_STRING, .string = NULL}, + }}; + yyjson_mut_doc *mdoc = yyjson_mut_doc_new(NULL); + assert(mdoc != NULL); + yyjson_mut_val *arr = messages_template_serialize(msgs, 1, mdoc); + assert(arr == NULL); + yyjson_mut_doc_free(mdoc); + } + + /* CONTENT_NONE still succeeds with JSON null (control). */ + { + message_t msgs[1] = {{ + .role = ROLE_ASSISTANT, + .content = {.kind = CONTENT_NONE}, + }}; + yyjson_mut_doc *mdoc = yyjson_mut_doc_new(NULL); + assert(mdoc != NULL); + yyjson_mut_val *arr = messages_template_serialize(msgs, 1, mdoc); + assert(arr != NULL); + yyjson_mut_doc_set_root(mdoc, arr); + char *json = yyjson_mut_write(mdoc, 0, NULL); + assert(json != NULL); + yyjson_mut_doc_free(mdoc); + + yyjson_doc *doc = yyjson_read(json, strlen(json), 0); + free(json); + assert(doc != NULL); + yyjson_val *m0 = yyjson_arr_get(yyjson_doc_get_root(doc), 0); + assert(yyjson_is_null(yyjson_obj_get(m0, "content"))); + yyjson_doc_free(doc); + } + + /* Good message then STRING+NULL fails the whole serialize. */ + { + message_t msgs[2] = { + { + .role = ROLE_USER, + .content = {.kind = CONTENT_STRING, .string = (char *)"ok"}, + }, + { + .role = ROLE_USER, + .content = {.kind = CONTENT_STRING, .string = NULL}, + }, + }; + yyjson_mut_doc *mdoc = yyjson_mut_doc_new(NULL); + assert(mdoc != NULL); + yyjson_mut_val *arr = messages_template_serialize(msgs, 2, mdoc); + assert(arr == NULL); + yyjson_mut_doc_free(mdoc); + } + + printf(" test_messages_template_serialize: passed\n"); +} + int main(void) { test_helper_reads_and_parses_error_envelope(); test_error_envelope_serialize(); @@ -1087,6 +1520,8 @@ int main(void) { test_chat_top_logprobs_rejected(); test_completion_logprobs_rejected(); test_parse_top_k_integral_double(); + test_message_content_text(); + test_messages_template_serialize(); printf(" test_completion_logprobs_rejected: passed\n"); printf("test_openai: all passed\n"); return 0;