Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions src/core/openai.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions src/core/openai.h
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
43 changes: 43 additions & 0 deletions src/core/types.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions src/core/types.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 29 additions & 1 deletion src/http/handler.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
52 changes: 52 additions & 0 deletions tests/test_http_gen.c
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}
79 changes: 79 additions & 0 deletions tests/test_http_generate.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading