1694 lines
74 KiB
C
1694 lines
74 KiB
C
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
|
/* Copyright (C) 2026 David (Corey) Herrera */
|
|
/*
|
|
* Unit tests for openai_chat.c's request PARSE (tool_choice handling + the
|
|
* forced_name lifetime) and its response SHAPE (where session_conflict lands in
|
|
* the emitted JSON, on both the streaming and non-streaming paths).
|
|
*
|
|
* ── Request parse: tool_choice + forced_name LIFETIME (n2 tool-calling
|
|
* Phase 5 — BUG 2 regression).
|
|
*
|
|
* BUG 2 was an intermittent request failure introduced with the new tool_choice
|
|
* parsing. The forced-function name (from the object form
|
|
* {"type":"function","function":{"name":"X"}}) was kept as a BORROWED pointer
|
|
* into the request's jr_value DOM — memory the caller frees with jr_free(root).
|
|
* Any change to the free ordering, or a path that holds gen.forced_name past the
|
|
* DOM, turns that alias into a use-after-free. The fix OWNS the name on the
|
|
* request (strdup, freed in chat_request_free), so it never aliases freed DOM.
|
|
*
|
|
* These tests parse a request, FREE the DOM, then still read gen.forced_name the
|
|
* way the generator does — proving the kept string is request-owned and stable.
|
|
* They also cover the string forms (auto/none/required) and the tools gate.
|
|
*
|
|
* ── Response shape: session_conflict. See the block above those tests.
|
|
*
|
|
* parse_chat_request, send_completion_json and send_usage_chunk are all static,
|
|
* so the .c is #included directly (the engine-side handlers it also defines are
|
|
* satisfied by tiny link stubs below — neither path calls them; the two hs_send_*
|
|
* stubs capture the emitted bytes). Pure C, libc-only; no CUDA, no model.
|
|
*/
|
|
|
|
#include "lib/test.h"
|
|
|
|
#include <unistd.h> /* dup/dup2/read/close — the stderr capture below */
|
|
|
|
/* Pull in the unit under test (statics reachable). Rename its main out. */
|
|
#define main oai_chat_main_unused
|
|
#include "../src/serve/openai_chat.c"
|
|
#undef main
|
|
#include "../src/serve/serve_emitter.h"
|
|
|
|
/* ── Link stubs — the request-PARSE path never invokes these; they only satisfy
|
|
* the linker for the handler functions compiled into this TU. ── */
|
|
void bs_notify_pressure(pn_batch_sched *a, int b) { (void)a; (void)b; }
|
|
bs_acquire_result bs_slot_acquire_or_wait(pn_batch_sched *a, int b,
|
|
int *c, int *d, const char *e) {
|
|
(void)a; (void)b; (void)c; (void)d; (void)e; return BS_ACQUIRE_OK;
|
|
}
|
|
void hs_begin_sse(int a) { (void)a; }
|
|
int hs_client_alive(int a) { (void)a; return 1; }
|
|
void hs_end_sse(int a) { (void)a; }
|
|
void hs_send_error(int a, int b, const char *c, const char *d) {
|
|
(void)a; (void)b; (void)c; (void)d;
|
|
}
|
|
void hs_send_error_ex(int a, int b, const char *c, const char *d, const char *e) {
|
|
(void)a; (void)b; (void)c; (void)d; (void)e;
|
|
}
|
|
/* ── Wire capture — the two sinks openai_chat.c serializes a response into.
|
|
* The response-shape tests at the bottom read back the exact bytes a client
|
|
* would receive, so a field that stops arriving fails the build. ── */
|
|
static char wire_json[8192]; /* last hs_send_json body (non-streaming) */
|
|
static char wire_sse[8192]; /* last hs_send_sse event (streaming chunk) */
|
|
void hs_send_json(int a, int b, const char *c) {
|
|
(void)a; (void)b;
|
|
snprintf(wire_json, sizeof(wire_json), "%s", c ? c : "");
|
|
}
|
|
/* EVERY streamed byte, in order, as a client receives it (events and SSE
|
|
* comments) — the tool-call streaming tests fold this the way a client does. */
|
|
static json_writer wire_all;
|
|
void hs_send_sse(int a, const char *b) {
|
|
(void)a;
|
|
snprintf(wire_sse, sizeof(wire_sse), "%s", b ? b : "");
|
|
if (wire_all.data) {
|
|
jw_raw(&wire_all, "data: ");
|
|
jw_raw(&wire_all, b ? b : "");
|
|
jw_raw(&wire_all, "\n\n");
|
|
}
|
|
}
|
|
void hs_send_sse_comment(int a, const char *b) {
|
|
(void)a;
|
|
if (wire_all.data) {
|
|
jw_raw(&wire_all, ": ");
|
|
jw_raw(&wire_all, b);
|
|
jw_raw(&wire_all, "\n\n");
|
|
}
|
|
}
|
|
mr_entry *mr_acquire(mr_registry *a, const char *b) { (void)a; (void)b; return NULL; }
|
|
int mr_count(mr_registry *a) { (void)a; return 0; }
|
|
void mr_release(mr_registry *a, mr_entry *b) { (void)a; (void)b; }
|
|
int mr_snapshot(mr_registry *a, mr_info *b, int c) { (void)a; (void)b; (void)c; return 0; }
|
|
bool mr_model_is_minicpm(const pn_model *a) { (void)a; return false; }
|
|
void pn_memwatch_read(pn_mem_state *a) { (void)a; }
|
|
bool pn_memwatch_running(void) { return false; }
|
|
/* Set by a test to say whether the stubbed generation ENFORCED response_format,
|
|
* so the streaming path's status resolution can be driven both ways. */
|
|
static int stub_response_format_ok = 0;
|
|
/* Tokens the stubbed generation emits through a REAL emitter (wired exactly as
|
|
* serve_generation.c wires it, tool tap included), so run_streaming is driven
|
|
* end to end. NULL = emit nothing (the response-shape tests). */
|
|
static const char *const *stub_pieces = NULL;
|
|
static int stub_n_pieces = 0;
|
|
/* wire_all.len when the LAST piece had been fed — i.e. what the client had
|
|
* received while the model was still generating. */
|
|
static size_t stub_wire_len_before_end = 0;
|
|
int sg_run_completion(pn_model *a, pn_state *b, const sg_message *c, int d,
|
|
const sg_gen_params *e, sg_delta_fn f, void *g, sg_result *h,
|
|
const sg_prefix_ctx *i) {
|
|
(void)a; (void)b; (void)c; (void)d; (void)i;
|
|
if (h) h->response_format_ok = stub_response_format_ok;
|
|
if (!stub_pieces) return 0;
|
|
emitter em;
|
|
em_init(&em, f, g, e->stop, e->n_stop, e->has_tools, e->dialect);
|
|
em_set_tool_tap(&em, e->tool_tap, e->tool_tap_ud);
|
|
em.in_think = 0;
|
|
for (int k = 0; k < stub_n_pieces; k++) em_feed(&em, stub_pieces[k]);
|
|
stub_wire_len_before_end = wire_all.len;
|
|
em_flush(&em);
|
|
h->content = em.content.data ? em.content.data : strdup("");
|
|
h->tool_raw = em.tool.data;
|
|
h->finish_reason = "stop";
|
|
em.content.data = NULL;
|
|
em.tool.data = NULL;
|
|
em_free(&em);
|
|
return 0;
|
|
}
|
|
int sg_run_completion_batched(pn_model *a, pn_batch_sched *b, int c,
|
|
const sg_message *d, int e, const sg_gen_params *f, sg_delta_fn g,
|
|
void *h, sg_result *i, const char *j) {
|
|
(void)a; (void)b; (void)c; (void)d; (void)e; (void)f; (void)g; (void)h;
|
|
(void)i; (void)j; return 0;
|
|
}
|
|
void sg_result_free(sg_result *a) { (void)a; }
|
|
bool sg_tool_choice_required_with_thinking(tc_dialect d) { (void)d; return false; }
|
|
/* Phase-7 warm-tier externals the session handlers reference (never called on
|
|
* the request-parse path this test exercises). */
|
|
double sg_prefill_tok_per_s_measured(void) { return 0.0; }
|
|
uint64_t mr_weights_version(const mr_entry *a) { (void)a; return 0; }
|
|
unsigned char *pn_base64_decode(const char *a, size_t *b) { (void)a; if (b) *b = 0; return NULL; }
|
|
char *pn_base64_encode(const unsigned char *a, size_t b) { (void)a; (void)b; return NULL; }
|
|
void *pn_qwen35moe_snap_deserialize(const unsigned char *a, size_t b, uint64_t *c) {
|
|
(void)a; (void)b; if (c) *c = 0; return NULL;
|
|
}
|
|
void pn_qwen35moe_state_snapshot_free(void *a) { (void)a; }
|
|
pn_session_admit pn_prefix_cache_admit(pn_prefix_cache *a, const char *b,
|
|
uint64_t c, uint64_t d, int *e) {
|
|
(void)a; (void)b; (void)c; (void)d; if (e) *e = 0;
|
|
return PN_SESSION_ADMIT_OK;
|
|
}
|
|
void pn_prefix_cache_touch(pn_prefix_cache *a, const char *b, uint64_t c) {
|
|
(void)a; (void)b; (void)c;
|
|
}
|
|
int pn_prefix_cache_import_snapshot(pn_prefix_cache *a, const char *b,
|
|
const void *c, const uint32_t *d, int e) {
|
|
(void)a; (void)b; (void)c; (void)d; (void)e; return 0;
|
|
}
|
|
|
|
/* Bodies exercised. */
|
|
static const char *BODY_FUNCTION =
|
|
"{\"model\":\"nex\",\"messages\":[{\"role\":\"user\",\"content\":\"read it\"}],"
|
|
"\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"read\","
|
|
"\"parameters\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}}}],"
|
|
"\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"read\"}}}";
|
|
|
|
static const char *BODY_REQUIRED =
|
|
"{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],"
|
|
"\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"read\"}}],"
|
|
"\"tool_choice\":\"required\"}";
|
|
|
|
/* ============================================================
|
|
* BUG 2: the forced_name must survive the DOM being freed. We parse, free the
|
|
* jr_value tree, then read gen.forced_name the way the generator does. With the
|
|
* borrowed-pointer regression this is a use-after-free; with the owned copy it
|
|
* is stable ("read"). Looping stresses the allocator so a dangling read would
|
|
* reliably read reused/poisoned bytes.
|
|
* ============================================================ */
|
|
static void test_forced_name_survives_dom_free(void) {
|
|
for (int i = 0; i < 256; i++) {
|
|
jr_value *root = jr_parse(BODY_FUNCTION);
|
|
ASSERT_NOT_NULL(root);
|
|
chat_request req;
|
|
const char *err = NULL;
|
|
ASSERT_TRUE(parse_chat_request(root, &req, &err) == 0);
|
|
ASSERT_TRUE(req.gen.tool_choice == SG_TOOL_CHOICE_FUNCTION);
|
|
ASSERT_NOT_NULL(req.gen.forced_name);
|
|
ASSERT_TRUE(strcmp(req.gen.forced_name, "read") == 0);
|
|
|
|
/* Free the DOM FIRST — exactly what oai_handle_chat does after the
|
|
* completion. forced_name must NOT point into it. */
|
|
jr_free(root);
|
|
|
|
/* Now read the kept name (generator does this via sg_tg_setup). */
|
|
ASSERT_TRUE(strlen(req.gen.forced_name) == 4);
|
|
ASSERT_TRUE(strcmp(req.gen.forced_name, "read") == 0);
|
|
|
|
chat_request_free(&req);
|
|
}
|
|
}
|
|
|
|
/* tool_choice string forms resolve correctly and carry no forced_name. */
|
|
/* The prefix-cache session key: `session_id`, then OpenAI's `prompt_cache_key`, then
|
|
* opencode's `promptCacheKey`; empty strings don't count. Without one the serve caches
|
|
* nothing, so a client using either alias would re-prefill every turn. */
|
|
static void test_session_key_aliases(void) {
|
|
struct { const char *fields; const char *want; } cases[] = {
|
|
{ "\"session_id\":\"a\"", "a" },
|
|
{ "\"prompt_cache_key\":\"b\"", "b" },
|
|
{ "\"promptCacheKey\":\"c\"", "c" },
|
|
{ "\"promptCacheKey\":\"c\",\"session_id\":\"a\"", "a" },
|
|
{ "\"promptCacheKey\":\"c\",\"prompt_cache_key\":\"b\"", "b" },
|
|
{ "\"session_id\":\"\",\"promptCacheKey\":\"c\"", "c" },
|
|
{ "\"user\":\"u\"", NULL },
|
|
};
|
|
for (unsigned k = 0; k < sizeof(cases) / sizeof(cases[0]); k++) {
|
|
char body[512];
|
|
snprintf(body, sizeof(body),
|
|
"{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],%s}", cases[k].fields);
|
|
jr_value *root = jr_parse(body);
|
|
ASSERT_NOT_NULL(root);
|
|
chat_request req; const char *err = NULL;
|
|
ASSERT_TRUE(parse_chat_request(root, &req, &err) == 0);
|
|
if (cases[k].want) {
|
|
ASSERT_NOT_NULL(req.session_id);
|
|
ASSERT_TRUE(strcmp(req.session_id, cases[k].want) == 0);
|
|
} else {
|
|
ASSERT_TRUE(req.session_id == NULL);
|
|
}
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
}
|
|
|
|
static void test_tool_choice_string_forms(void) {
|
|
struct { const char *tc; sg_tool_choice_kind want; bool tools; } cases[] = {
|
|
{ "\"required\"", SG_TOOL_CHOICE_REQUIRED, true },
|
|
{ "\"auto\"", SG_TOOL_CHOICE_AUTO, true },
|
|
{ "\"none\"", SG_TOOL_CHOICE_AUTO, false }, /* none → tools dropped → AUTO */
|
|
{ "\"bogus\"", SG_TOOL_CHOICE_AUTO, true }, /* unknown → auto */
|
|
};
|
|
for (unsigned k = 0; k < sizeof(cases) / sizeof(cases[0]); k++) {
|
|
char body[512];
|
|
snprintf(body, sizeof(body),
|
|
"{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],"
|
|
"\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"read\"}}],"
|
|
"\"tool_choice\":%s}", cases[k].tc);
|
|
jr_value *root = jr_parse(body);
|
|
ASSERT_NOT_NULL(root);
|
|
chat_request req; const char *err = NULL;
|
|
ASSERT_TRUE(parse_chat_request(root, &req, &err) == 0);
|
|
ASSERT_TRUE(req.gen.tool_choice == cases[k].want);
|
|
ASSERT_TRUE((req.tools != NULL) == cases[k].tools);
|
|
ASSERT_NULL(req.gen.forced_name);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
}
|
|
|
|
/* "required" with tools present is honored; forced_name stays NULL. */
|
|
static void test_required_no_forced_name(void) {
|
|
jr_value *root = jr_parse(BODY_REQUIRED);
|
|
ASSERT_NOT_NULL(root);
|
|
chat_request req; const char *err = NULL;
|
|
ASSERT_TRUE(parse_chat_request(root, &req, &err) == 0);
|
|
ASSERT_TRUE(req.gen.tool_choice == SG_TOOL_CHOICE_REQUIRED);
|
|
ASSERT_NULL(req.gen.forced_name);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
/* No tools present → forcing is meaningless: tool_choice downgrades to AUTO even
|
|
* when the request asked for a specific function. */
|
|
static void test_forced_without_tools_downgrades(void) {
|
|
const char *body =
|
|
"{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],"
|
|
"\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"read\"}}}";
|
|
jr_value *root = jr_parse(body);
|
|
ASSERT_NOT_NULL(root);
|
|
chat_request req; const char *err = NULL;
|
|
ASSERT_TRUE(parse_chat_request(root, &req, &err) == 0);
|
|
ASSERT_NULL(req.tools);
|
|
ASSERT_TRUE(req.gen.tool_choice == SG_TOOL_CHOICE_AUTO);
|
|
ASSERT_NULL(req.gen.forced_name);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
/* Malformed object form (missing name) does not force and does not crash. */
|
|
static void test_malformed_object_form(void) {
|
|
const char *bodies[] = {
|
|
"{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],"
|
|
"\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"read\"}}],"
|
|
"\"tool_choice\":{}}",
|
|
"{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],"
|
|
"\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"read\"}}],"
|
|
"\"tool_choice\":{\"type\":\"function\"}}",
|
|
"{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}],"
|
|
"\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"read\"}}],"
|
|
"\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"\"}}}",
|
|
};
|
|
for (unsigned k = 0; k < sizeof(bodies) / sizeof(bodies[0]); k++) {
|
|
jr_value *root = jr_parse(bodies[k]);
|
|
ASSERT_NOT_NULL(root);
|
|
chat_request req; const char *err = NULL;
|
|
ASSERT_TRUE(parse_chat_request(root, &req, &err) == 0);
|
|
ASSERT_TRUE(req.gen.tool_choice == SG_TOOL_CHOICE_AUTO); /* not FUNCTION */
|
|
ASSERT_NULL(req.gen.forced_name);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
}
|
|
|
|
/* ============================================================
|
|
* RESPONSE SHAPE — session_conflict on the wire.
|
|
*
|
|
* The serve detects a second conversation wearing one session id and reports it
|
|
* with "session_conflict":true INSIDE the `usage` object — the object a client
|
|
* already reads for kv_used/kv_cap (an agent client's SSE parser reads the
|
|
* terminal usage chunk). It shipped emitted OUTSIDE `usage` on the
|
|
* non-streaming path (the usage object is closed by the jw_printf format, so an
|
|
* appended field lands as a sibling of `usage`, not a member), which is why no
|
|
* client ever saw it. These tests parse the emitted bytes and assert the field
|
|
* is a MEMBER of `usage` on BOTH paths — a substring check would have passed
|
|
* through the whole bug.
|
|
* ============================================================ */
|
|
|
|
/* A minimal successful result, as the generator fills it. */
|
|
static sg_result wire_test_result(int session_conflict) {
|
|
static char content[] = "hi";
|
|
sg_result res = {0};
|
|
res.content = content;
|
|
res.finish_reason = "stop";
|
|
res.prompt_tokens = 33;
|
|
res.completion_tokens = 4;
|
|
res.kv_used_tokens = 33;
|
|
res.kv_cap_tokens = 32000;
|
|
res.session_conflict = session_conflict;
|
|
return res;
|
|
}
|
|
|
|
/* Non-streaming: the flag rides `usage`, not the enclosing completion object. */
|
|
static void test_nonstream_usage_carries_session_conflict(void) {
|
|
sg_result res = wire_test_result(1);
|
|
wire_json[0] = '\0';
|
|
chat_choice ch = { .res = res };
|
|
send_completion_json(-1, "cmpl-1", 0, "test-model", &ch, 1, NULL, NULL);
|
|
|
|
jr_value *root = jr_parse(wire_json);
|
|
ASSERT_NOT_NULL(root);
|
|
const jr_value *usage = jr_object_get(root, "usage");
|
|
ASSERT_NOT_NULL(usage);
|
|
ASSERT_TRUE(jr_as_bool(jr_object_get(usage, "session_conflict"), false));
|
|
/* Not a top-level sibling: a client reading `usage` must find it. */
|
|
ASSERT_NULL(jr_object_get(root, "session_conflict"));
|
|
/* The fields it rides beside are untouched. */
|
|
ASSERT_INT_EQ((int)jr_as_number(jr_object_get(usage, "kv_used"), -1), 33);
|
|
ASSERT_INT_EQ((int)jr_as_number(jr_object_get(usage, "kv_cap"), -1), 32000);
|
|
jr_free(root);
|
|
}
|
|
|
|
/* No substitution detected → the field is absent everywhere (never a latched or
|
|
* always-present "false" a client could misread as proof of correctness). */
|
|
static void test_nonstream_usage_omits_session_conflict_when_clean(void) {
|
|
sg_result res = wire_test_result(0);
|
|
wire_json[0] = '\0';
|
|
chat_choice ch = { .res = res };
|
|
send_completion_json(-1, "cmpl-2", 0, "test-model", &ch, 1, NULL, NULL);
|
|
|
|
jr_value *root = jr_parse(wire_json);
|
|
ASSERT_NOT_NULL(root);
|
|
const jr_value *usage = jr_object_get(root, "usage");
|
|
ASSERT_NOT_NULL(usage);
|
|
ASSERT_NULL(jr_object_get(usage, "session_conflict"));
|
|
ASSERT_NULL(jr_object_get(root, "session_conflict"));
|
|
jr_free(root);
|
|
}
|
|
|
|
/* Streaming: the terminal usage chunk (stream_options.include_usage) carries it
|
|
* in the same place — this is the path the client actually uses. */
|
|
static void test_stream_usage_chunk_carries_session_conflict(void) {
|
|
stream_ctx sc = { .client_fd = -1, .id = "cmpl-3", .created = 0,
|
|
.model_id = "test-model", .role_sent = true };
|
|
wire_sse[0] = '\0';
|
|
send_usage_chunk(&sc, 33, 4, 0, 33, 32000, 1);
|
|
|
|
jr_value *root = jr_parse(wire_sse);
|
|
ASSERT_NOT_NULL(root);
|
|
const jr_value *usage = jr_object_get(root, "usage");
|
|
ASSERT_NOT_NULL(usage);
|
|
ASSERT_TRUE(jr_as_bool(jr_object_get(usage, "session_conflict"), false));
|
|
ASSERT_INT_EQ((int)jr_as_number(jr_object_get(usage, "kv_used"), -1), 33);
|
|
ASSERT_INT_EQ((int)jr_as_number(jr_object_get(usage, "total_tokens"), -1), 37);
|
|
jr_free(root);
|
|
}
|
|
|
|
static void test_stream_usage_chunk_omits_session_conflict_when_clean(void) {
|
|
stream_ctx sc = { .client_fd = -1, .id = "cmpl-4", .created = 0,
|
|
.model_id = "test-model", .role_sent = true };
|
|
wire_sse[0] = '\0';
|
|
send_usage_chunk(&sc, 33, 4, 0, 33, 32000, 0);
|
|
|
|
jr_value *root = jr_parse(wire_sse);
|
|
ASSERT_NOT_NULL(root);
|
|
const jr_value *usage = jr_object_get(root, "usage");
|
|
ASSERT_NOT_NULL(usage);
|
|
ASSERT_NULL(jr_object_get(usage, "session_conflict"));
|
|
jr_free(root);
|
|
}
|
|
|
|
/* ============================================================
|
|
* RESPONSE SHAPE — prompt_tokens_details.cached_tokens on the wire.
|
|
*
|
|
* The serve knew how many prompt tokens it reused (the prefix cache logs it to
|
|
* stderr) and told the client nothing, so a client's prefill rate was
|
|
* prompt_tokens / TTFT — a number that RISES the more the serve caches (measured
|
|
* 409 tok/s against a true 39.7 at 150 of 166 reused). These tests read the
|
|
* emitted bytes back and assert the OpenAI-standard nesting on BOTH paths: a
|
|
* MEMBER named cached_tokens inside a `prompt_tokens_details` object inside
|
|
* `usage`. They also pin the two properties a client depends on — the field is
|
|
* present on a COLD turn carrying 0 (absent and zero are different answers), and
|
|
* cached_tokens never exceeds prompt_tokens.
|
|
* ============================================================ */
|
|
|
|
/* Fetch usage.prompt_tokens_details.cached_tokens out of an emitted body, or
|
|
* -1 when either object is missing. */
|
|
static int wire_cached_tokens(const jr_value *root) {
|
|
const jr_value *usage = jr_object_get(root, "usage");
|
|
if (!usage) return -1;
|
|
const jr_value *details = jr_object_get(usage, "prompt_tokens_details");
|
|
if (!details) return -1;
|
|
const jr_value *cached = jr_object_get(details, "cached_tokens");
|
|
if (!cached) return -1;
|
|
return (int)jr_as_number(cached, -1);
|
|
}
|
|
|
|
/* Warm turn, non-streaming: the reused count reaches the client, nested where
|
|
* an OpenAI client already looks for it. */
|
|
static void test_nonstream_usage_reports_cached_tokens(void) {
|
|
sg_result res = wire_test_result(0);
|
|
res.prompt_tokens = 166;
|
|
res.cached_prompt_tokens = 150;
|
|
wire_json[0] = '\0';
|
|
chat_choice ch = { .res = res };
|
|
send_completion_json(-1, "cmpl-5", 0, "test-model", &ch, 1, NULL, NULL);
|
|
|
|
jr_value *root = jr_parse(wire_json);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_INT_EQ(wire_cached_tokens(root), 150);
|
|
/* The invariant a rate calculation divides by: computed = prompt - cached
|
|
* must stay positive, so cached can never exceed prompt. */
|
|
const jr_value *usage = jr_object_get(root, "usage");
|
|
int prompt = (int)jr_as_number(jr_object_get(usage, "prompt_tokens"), -1);
|
|
ASSERT_INT_EQ(prompt, 166);
|
|
ASSERT_TRUE(wire_cached_tokens(root) <= prompt);
|
|
/* Not a top-level sibling, and not flattened beside prompt_tokens. */
|
|
ASSERT_NULL(jr_object_get(root, "prompt_tokens_details"));
|
|
ASSERT_NULL(jr_object_get(usage, "cached_tokens"));
|
|
jr_free(root);
|
|
}
|
|
|
|
/* Cold turn, non-streaming: PRESENT and 0. If this ever goes back to being
|
|
* omitted, a client cannot distinguish "nothing was reused" from "this serve
|
|
* does not report reuse" and has to fall back to the inflated rate. */
|
|
static void test_nonstream_usage_reports_zero_cached_tokens_when_cold(void) {
|
|
sg_result res = wire_test_result(0);
|
|
res.cached_prompt_tokens = 0;
|
|
wire_json[0] = '\0';
|
|
chat_choice ch = { .res = res };
|
|
send_completion_json(-1, "cmpl-6", 0, "test-model", &ch, 1, NULL, NULL);
|
|
|
|
jr_value *root = jr_parse(wire_json);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_INT_EQ(wire_cached_tokens(root), 0);
|
|
jr_free(root);
|
|
}
|
|
|
|
/* Warm turn, streaming terminal usage chunk — the path benchmarks measure. */
|
|
static void test_stream_usage_chunk_reports_cached_tokens(void) {
|
|
stream_ctx sc = { .client_fd = -1, .id = "cmpl-7", .created = 0,
|
|
.model_id = "test-model", .role_sent = true };
|
|
wire_sse[0] = '\0';
|
|
send_usage_chunk(&sc, 166, 4, 150, 170, 32000, 0);
|
|
|
|
jr_value *root = jr_parse(wire_sse);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_INT_EQ(wire_cached_tokens(root), 150);
|
|
const jr_value *usage = jr_object_get(root, "usage");
|
|
ASSERT_TRUE(wire_cached_tokens(root) <=
|
|
(int)jr_as_number(jr_object_get(usage, "prompt_tokens"), -1));
|
|
/* The fields it rides beside are untouched. */
|
|
ASSERT_INT_EQ((int)jr_as_number(jr_object_get(usage, "total_tokens"), -1), 170);
|
|
ASSERT_INT_EQ((int)jr_as_number(jr_object_get(usage, "kv_used"), -1), 170);
|
|
jr_free(root);
|
|
}
|
|
|
|
/* Cold turn, streaming: same present-and-zero rule as the non-streaming body. */
|
|
static void test_stream_usage_chunk_reports_zero_cached_tokens_when_cold(void) {
|
|
stream_ctx sc = { .client_fd = -1, .id = "cmpl-8", .created = 0,
|
|
.model_id = "test-model", .role_sent = true };
|
|
wire_sse[0] = '\0';
|
|
send_usage_chunk(&sc, 166, 4, 0, 170, 32000, 0);
|
|
|
|
jr_value *root = jr_parse(wire_sse);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_INT_EQ(wire_cached_tokens(root), 0);
|
|
jr_free(root);
|
|
}
|
|
|
|
/* ============================================================
|
|
* OpenAI FIELDS THAT USED TO BE SILENTLY IGNORED
|
|
*
|
|
* response_format / seed / logit_bias / presence_penalty / frequency_penalty /
|
|
* logprobs / top_logprobs all parsed to nothing before this change: a client
|
|
* sending response_format:{"type":"json_object"} believed it was guaranteed JSON
|
|
* and quietly was not. These tests assert the parse RESULT (what the generator
|
|
* actually receives) and, for response_format, the reported status on BOTH the
|
|
* streaming and non-streaming wire — parsed back out of the emitted bytes, not
|
|
* substring-matched.
|
|
* ============================================================ */
|
|
|
|
/* Parse a body and hand back the request; caller frees both. */
|
|
static jr_value *parse_body(const char *body, chat_request *req) {
|
|
jr_value *root = jr_parse(body);
|
|
if (!root) return NULL;
|
|
const char *err = NULL;
|
|
if (parse_chat_request(root, req, &err) != 0) { jr_free(root); return NULL; }
|
|
return root;
|
|
}
|
|
|
|
#define BODY_HEAD "{\"messages\":[{\"role\":\"user\",\"content\":\"x\"}]"
|
|
|
|
static void test_response_format_json_object_is_applied(void) {
|
|
chat_request req;
|
|
jr_value *root = parse_body(
|
|
BODY_HEAD ",\"response_format\":{\"type\":\"json_object\"}}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_TRUE(req.gen.response_format == SG_RESPONSE_FORMAT_JSON_OBJECT);
|
|
ASSERT_NOT_NULL(req.response_format_status);
|
|
ASSERT_STR_EQ(req.response_format_status, "applied");
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
static void test_response_format_text_is_a_noop(void) {
|
|
chat_request req;
|
|
jr_value *root = parse_body(
|
|
BODY_HEAD ",\"response_format\":{\"type\":\"text\"}}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_TRUE(req.gen.response_format == SG_RESPONSE_FORMAT_TEXT);
|
|
/* The explicit default constrains nothing and needs no report. */
|
|
ASSERT_NULL(req.response_format_status);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
static void test_response_format_absent_is_a_noop(void) {
|
|
chat_request req;
|
|
jr_value *root = parse_body(BODY_HEAD "}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_TRUE(req.gen.response_format == SG_RESPONSE_FORMAT_TEXT);
|
|
ASSERT_NULL(req.response_format_status);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
static void test_response_format_json_schema_downgrades(void) {
|
|
/* The shape is not enforced, but the reply is still constrained to valid
|
|
* JSON and the client is TOLD the schema did not apply. */
|
|
chat_request req;
|
|
jr_value *root = parse_body(
|
|
BODY_HEAD ",\"response_format\":{\"type\":\"json_schema\",\"json_schema\":"
|
|
"{\"name\":\"r\",\"schema\":{\"type\":\"object\"}}}}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_TRUE(req.gen.response_format == SG_RESPONSE_FORMAT_JSON_OBJECT);
|
|
ASSERT_STR_EQ(req.response_format_status, "schema_downgraded");
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
static void test_response_format_unknown_type_is_reported(void) {
|
|
chat_request req;
|
|
jr_value *root = parse_body(
|
|
BODY_HEAD ",\"response_format\":{\"type\":\"xml\"}}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_TRUE(req.gen.response_format == SG_RESPONSE_FORMAT_TEXT);
|
|
ASSERT_STR_EQ(req.response_format_status, "ignored_unknown_type");
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
/* THE PRECEDENCE RULE. Tools and response_format are two masks over one decode
|
|
* and the sampler has one hook. Tools win — and the client is told, so the
|
|
* conflict is visible rather than silent. */
|
|
static void test_response_format_yields_to_tools(void) {
|
|
chat_request req;
|
|
jr_value *root = parse_body(
|
|
BODY_HEAD ",\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"read\"}}]"
|
|
",\"response_format\":{\"type\":\"json_object\"}}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_NOT_NULL(req.tools);
|
|
ASSERT_TRUE(req.gen.response_format == SG_RESPONSE_FORMAT_TEXT);
|
|
ASSERT_STR_EQ(req.response_format_status, "ignored_tools_present");
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
/* tool_choice:"none" NULLs the tools, so nothing is competing for the mask and
|
|
* the JSON constraint applies after all. */
|
|
static void test_response_format_applies_when_tool_choice_none(void) {
|
|
chat_request req;
|
|
jr_value *root = parse_body(
|
|
BODY_HEAD ",\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"read\"}}]"
|
|
",\"tool_choice\":\"none\""
|
|
",\"response_format\":{\"type\":\"json_object\"}}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_NULL(req.tools);
|
|
ASSERT_TRUE(req.gen.response_format == SG_RESPONSE_FORMAT_JSON_OBJECT);
|
|
ASSERT_STR_EQ(req.response_format_status, "applied");
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
static void test_seed_is_parsed(void) {
|
|
chat_request req;
|
|
jr_value *root = parse_body(BODY_HEAD ",\"seed\":12345}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_TRUE(req.gen.seed_set);
|
|
ASSERT_TRUE(req.gen.seed == 12345u);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
|
|
/* Absent → unset, so the global RNG is used exactly as before. */
|
|
root = parse_body(BODY_HEAD "}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_FALSE(req.gen.seed_set);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
|
|
/* seed 0 is a REAL seed, not "absent". */
|
|
root = parse_body(BODY_HEAD ",\"seed\":0}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_TRUE(req.gen.seed_set);
|
|
ASSERT_TRUE(req.gen.seed == 0u);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
/* `enable_thinking` is the one reasoning control the Qwen3.x template exposes,
|
|
* and it is BINARY. Absent must stay DEFAULT — that is what keeps every
|
|
* existing client byte-identical, and it is the half a "the field parses" test
|
|
* would miss. */
|
|
static void test_enable_thinking_is_parsed(void) {
|
|
chat_request req;
|
|
jr_value *root = parse_body(BODY_HEAD ",\"enable_thinking\":false}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_TRUE(req.gen.thinking == SG_THINKING_OFF);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
|
|
root = parse_body(BODY_HEAD ",\"enable_thinking\":true}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_TRUE(req.gen.thinking == SG_THINKING_ON);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
|
|
/* Absent → DEFAULT: the env still decides, exactly as before. */
|
|
root = parse_body(BODY_HEAD "}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_TRUE(req.gen.thinking == SG_THINKING_DEFAULT);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
|
|
/* A non-boolean is NOT a silent "false" — it leaves the default standing
|
|
* rather than quietly switching a client's reasoning off. */
|
|
root = parse_body(BODY_HEAD ",\"enable_thinking\":\"low\"}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_TRUE(req.gen.thinking == SG_THINKING_DEFAULT);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
static void test_penalties_are_parsed_and_clamped(void) {
|
|
chat_request req;
|
|
jr_value *root = parse_body(
|
|
BODY_HEAD ",\"presence_penalty\":1.5,\"frequency_penalty\":-0.25}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_FLT_NEAR(req.gen.presence_penalty, 1.5f, 1e-6f);
|
|
ASSERT_FLT_NEAR(req.gen.frequency_penalty, -0.25f, 1e-6f);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
|
|
/* Out of OpenAI's documented range → clamped, never passed through raw. */
|
|
root = parse_body(
|
|
BODY_HEAD ",\"presence_penalty\":99,\"frequency_penalty\":-99}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_FLT_NEAR(req.gen.presence_penalty, 2.0f, 1e-6f);
|
|
ASSERT_FLT_NEAR(req.gen.frequency_penalty, -2.0f, 1e-6f);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
static void test_logit_bias_is_parsed_and_owned(void) {
|
|
chat_request req;
|
|
jr_value *root = parse_body(
|
|
BODY_HEAD ",\"logit_bias\":{\"100\":-100,\"7\":2.5,\"bogus\":1,\"9\":\"x\"}}",
|
|
&req);
|
|
ASSERT_NOT_NULL(root);
|
|
/* Non-numeric key and non-numeric value are skipped, not fatal. */
|
|
ASSERT_INT_EQ(req.gen.n_logit_bias, 2);
|
|
ASSERT_NOT_NULL(req.gen.logit_bias);
|
|
|
|
/* The map must survive the DOM being freed — the same lifetime rule
|
|
* forced_name needed (BUG 2); the generator reads it after jr_free(root). */
|
|
jr_free(root);
|
|
ASSERT_TRUE(req.gen.logit_bias[0].token_id == 100u);
|
|
ASSERT_FLT_NEAR(req.gen.logit_bias[0].bias, -100.0f, 1e-6f);
|
|
ASSERT_TRUE(req.gen.logit_bias[1].token_id == 7u);
|
|
ASSERT_FLT_NEAR(req.gen.logit_bias[1].bias, 2.5f, 1e-6f);
|
|
chat_request_free(&req);
|
|
}
|
|
|
|
static void test_logprobs_are_parsed(void) {
|
|
chat_request req;
|
|
jr_value *root = parse_body(
|
|
BODY_HEAD ",\"logprobs\":true,\"top_logprobs\":5}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_TRUE(req.gen.logprobs);
|
|
ASSERT_INT_EQ(req.gen.logprobs_top, 5);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
|
|
/* Over the cap → clamped (the sampler writes into fixed-size arrays). */
|
|
root = parse_body(BODY_HEAD ",\"logprobs\":true,\"top_logprobs\":500}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_INT_EQ(req.gen.logprobs_top, PN_LOGPROBS_MAX_TOP);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
|
|
/* Absent → off, sampler untouched. */
|
|
root = parse_body(BODY_HEAD "}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_FALSE(req.gen.logprobs);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
/* ── response_format_status ON THE WIRE, both paths ──
|
|
* The last bug in this file was a field that landed correctly on one path and
|
|
* not the other, so both are asserted by PARSING the emitted bytes. */
|
|
|
|
static void test_nonstream_reports_response_format_status(void) {
|
|
sg_result res = wire_test_result(0);
|
|
wire_json[0] = '\0';
|
|
chat_choice ch = { .res = res };
|
|
send_completion_json(-1, "cmpl-rf", 0, "test-model", &ch, 1, "applied", NULL);
|
|
|
|
jr_value *root = jr_parse(wire_json);
|
|
ASSERT_NOT_NULL(root);
|
|
const jr_value *status = jr_object_get(root, "response_format_status");
|
|
ASSERT_NOT_NULL(status);
|
|
ASSERT_STR_EQ(jr_as_string(status), "applied");
|
|
/* It must not have disturbed the object it sits beside. */
|
|
ASSERT_NOT_NULL(jr_object_get(root, "usage"));
|
|
ASSERT_NOT_NULL(jr_object_get(root, "choices"));
|
|
jr_free(root);
|
|
}
|
|
|
|
static void test_nonstream_omits_status_when_not_requested(void) {
|
|
sg_result res = wire_test_result(0);
|
|
wire_json[0] = '\0';
|
|
chat_choice ch = { .res = res };
|
|
send_completion_json(-1, "cmpl-rf2", 0, "test-model", &ch, 1, NULL, NULL);
|
|
|
|
jr_value *root = jr_parse(wire_json);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_NULL(jr_object_get(root, "response_format_status"));
|
|
jr_free(root);
|
|
}
|
|
|
|
static void test_stream_chunk_reports_response_format_status(void) {
|
|
stream_ctx sc = { .client_fd = -1, .id = "cmpl-rf3", .created = 0,
|
|
.model_id = "test-model", .role_sent = true,
|
|
.response_format_status = "ignored_tools_present" };
|
|
wire_sse[0] = '\0';
|
|
send_chunk(&sc, "", "stop", NULL, 0);
|
|
|
|
jr_value *root = jr_parse(wire_sse);
|
|
ASSERT_NOT_NULL(root);
|
|
const jr_value *status = jr_object_get(root, "response_format_status");
|
|
ASSERT_NOT_NULL(status);
|
|
ASSERT_STR_EQ(jr_as_string(status), "ignored_tools_present");
|
|
/* Still a well-formed chunk: choices[0].finish_reason survives. */
|
|
const jr_value *choice = jr_array_get(jr_object_get(root, "choices"), 0);
|
|
ASSERT_NOT_NULL(choice);
|
|
ASSERT_STR_EQ(jr_as_string(jr_object_get(choice, "finish_reason")), "stop");
|
|
jr_free(root);
|
|
}
|
|
|
|
static void test_stream_chunk_omits_status_when_not_requested(void) {
|
|
stream_ctx sc = { .client_fd = -1, .id = "cmpl-rf4", .created = 0,
|
|
.model_id = "test-model", .role_sent = true };
|
|
wire_sse[0] = '\0';
|
|
send_chunk(&sc, "", "stop", NULL, 0);
|
|
|
|
jr_value *root = jr_parse(wire_sse);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_NULL(jr_object_get(root, "response_format_status"));
|
|
jr_free(root);
|
|
}
|
|
|
|
/* ── OpenAI `n` (multiple completions) ── */
|
|
|
|
static void test_n_defaults_to_one(void) {
|
|
chat_request req;
|
|
jr_value *root = parse_body(BODY_HEAD "}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_INT_EQ(req.n_choices, 1);
|
|
ASSERT_NULL(req.n_status);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
static void test_n_is_honoured(void) {
|
|
chat_request req;
|
|
jr_value *root = parse_body(BODY_HEAD ",\"n\":3}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_INT_EQ(req.n_choices, 3);
|
|
ASSERT_NULL(req.n_status);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
static void test_n_over_the_ceiling_is_reported_not_silent(void) {
|
|
chat_request req;
|
|
jr_value *root = parse_body(BODY_HEAD ",\"n\":500}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_INT_EQ(req.n_choices, OAI_MAX_CHOICES);
|
|
/* The clamp must be VISIBLE — a silent cap is the defect class this whole
|
|
* change exists to remove. */
|
|
ASSERT_NOT_NULL(req.n_status);
|
|
ASSERT_STR_EQ(req.n_status, "capped_to_8");
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
static void test_n_zero_and_negative_floor_at_one(void) {
|
|
chat_request req;
|
|
jr_value *root = parse_body(BODY_HEAD ",\"n\":0}", &req);
|
|
ASSERT_NOT_NULL(req.n_choices == 1 ? root : NULL);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
root = parse_body(BODY_HEAD ",\"n\":-4}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_INT_EQ(req.n_choices, 1);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
/* A seeded n>1 request must produce n DIFFERENT completions — reusing one RNG
|
|
* stream would make them identical, which is not what `seed` means. */
|
|
static void test_seeded_choices_get_distinct_streams(void) {
|
|
chat_request req;
|
|
jr_value *root = parse_body(BODY_HEAD ",\"seed\":7,\"n\":3}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
sg_gen_params a = gen_params_for_choice(&req, 0);
|
|
sg_gen_params b = gen_params_for_choice(&req, 1);
|
|
sg_gen_params c = gen_params_for_choice(&req, 2);
|
|
ASSERT_TRUE(a.seed_set && b.seed_set && c.seed_set);
|
|
ASSERT_TRUE(a.seed != b.seed);
|
|
ASSERT_TRUE(b.seed != c.seed);
|
|
/* …and re-deriving choice 0 gives the same stream, so the whole REQUEST is
|
|
* still reproducible. */
|
|
ASSERT_TRUE(gen_params_for_choice(&req, 0).seed == a.seed);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
/* The wire shape of an n>1 response: n choices with ascending indices, one
|
|
* prompt charged once, completion tokens summed. */
|
|
static void test_nonstream_emits_all_choices(void) {
|
|
sg_result r0 = wire_test_result(0);
|
|
sg_result r1 = wire_test_result(0);
|
|
static char alt[] = "yo";
|
|
r1.content = alt;
|
|
r1.completion_tokens = 6;
|
|
chat_choice chs[2] = { { .res = r0 }, { .res = r1 } };
|
|
wire_json[0] = '\0';
|
|
send_completion_json(-1, "cmpl-n", 0, "test-model", chs, 2, NULL, NULL);
|
|
|
|
jr_value *root = jr_parse(wire_json);
|
|
ASSERT_NOT_NULL(root);
|
|
const jr_value *choices = jr_object_get(root, "choices");
|
|
ASSERT_INT_EQ(jr_array_len(choices), 2);
|
|
const jr_value *c0 = jr_array_get(choices, 0);
|
|
const jr_value *c1 = jr_array_get(choices, 1);
|
|
ASSERT_INT_EQ((int)jr_as_number(jr_object_get(c0, "index"), -1), 0);
|
|
ASSERT_INT_EQ((int)jr_as_number(jr_object_get(c1, "index"), -1), 1);
|
|
ASSERT_STR_EQ(jr_as_string(jr_object_get(jr_object_get(c1, "message"),
|
|
"content")), "yo");
|
|
const jr_value *usage = jr_object_get(root, "usage");
|
|
/* The prompt was prefilled once; the completions add up. */
|
|
ASSERT_INT_EQ((int)jr_as_number(jr_object_get(usage, "prompt_tokens"), -1), 33);
|
|
ASSERT_INT_EQ((int)jr_as_number(jr_object_get(usage, "completion_tokens"), -1),
|
|
4 + 6);
|
|
ASSERT_INT_EQ((int)jr_as_number(jr_object_get(usage, "total_tokens"), -1),
|
|
33 + 10);
|
|
jr_free(root);
|
|
}
|
|
|
|
static void test_nonstream_reports_n_shortfall(void) {
|
|
sg_result r0 = wire_test_result(0);
|
|
chat_choice ch = { .res = r0 };
|
|
wire_json[0] = '\0';
|
|
send_completion_json(-1, "cmpl-n2", 0, "test-model", &ch, 1, NULL,
|
|
"partial_slot_unavailable");
|
|
jr_value *root = jr_parse(wire_json);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_STR_EQ(jr_as_string(jr_object_get(root, "n_status")),
|
|
"partial_slot_unavailable");
|
|
jr_free(root);
|
|
}
|
|
|
|
static void test_stream_chunk_carries_the_choice_index(void) {
|
|
stream_ctx sc = { .client_fd = -1, .id = "cmpl-n3", .created = 0,
|
|
.model_id = "test-model", .role_sent = true,
|
|
.choice_index = 2 };
|
|
wire_sse[0] = '\0';
|
|
send_chunk(&sc, "\"content\":\"x\"", NULL, NULL, 0);
|
|
jr_value *root = jr_parse(wire_sse);
|
|
ASSERT_NOT_NULL(root);
|
|
const jr_value *choice = jr_array_get(jr_object_get(root, "choices"), 0);
|
|
ASSERT_INT_EQ((int)jr_as_number(jr_object_get(choice, "index"), -1), 2);
|
|
jr_free(root);
|
|
}
|
|
|
|
/* ── the STREAMING path really sets response_format_status ──
|
|
* The wire-shape tests above set the field on the ctx by hand, which proves the
|
|
* writer works but NOT that the streaming run path populates it. That gap is
|
|
* exactly how the previous bug shipped (correct on one path, absent on the
|
|
* other), so drive run_streaming end-to-end against the stubbed generator and
|
|
* read the closing chunk back. */
|
|
/* Drive run_streaming end-to-end against the stubbed generator and read the
|
|
* closing chunk back. Asserts BOTH directions: the streaming path must report
|
|
* "applied" only when the generation actually enforced the constraint, and
|
|
* "not_enforced" when it did not. A status that claims a guarantee it did not
|
|
* deliver is the defect this whole change exists to remove. */
|
|
static void run_streaming_status_case(int enforced, const char *expect) {
|
|
chat_request req;
|
|
jr_value *root = parse_body(
|
|
BODY_HEAD ",\"response_format\":{\"type\":\"json_object\"}}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_STR_EQ(req.response_format_status, "applied"); /* the INTENT */
|
|
|
|
stub_response_format_ok = enforced;
|
|
stream_ctx sc = { .client_fd = -1, .id = "cmpl-s", .created = 0,
|
|
.model_id = "test-model" };
|
|
wire_sse[0] = '\0';
|
|
run_streaming(-1, NULL, NULL, &req, &req.gen, &sc, NULL);
|
|
|
|
/* wire_sse holds the LAST chunk written — the closing one. */
|
|
jr_value *chunk = jr_parse(wire_sse);
|
|
ASSERT_NOT_NULL(chunk);
|
|
ASSERT_STR_EQ(jr_as_string(jr_object_get(chunk, "response_format_status")),
|
|
expect);
|
|
jr_free(chunk);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
static void test_run_streaming_reports_enforced_status(void) {
|
|
run_streaming_status_case(1, "applied");
|
|
}
|
|
|
|
static void test_run_streaming_reports_unenforced_status(void) {
|
|
run_streaming_status_case(0, "not_enforced");
|
|
}
|
|
|
|
|
|
static void test_status_applied_requires_actual_enforcement(void) {
|
|
chat_request req;
|
|
jr_value *root = parse_body(
|
|
BODY_HEAD ",\"response_format\":{\"type\":\"json_object\"}}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_STR_EQ(req.response_format_status, "applied"); /* the INTENT */
|
|
|
|
sg_result enforced = {0};
|
|
enforced.response_format_ok = 1;
|
|
ASSERT_STR_EQ(wire_response_format_status(&req, &enforced), "applied");
|
|
|
|
/* The grammar did not close an object → the reply is not guaranteed JSON,
|
|
* so the wire must NOT claim it was. */
|
|
sg_result not_enforced = {0};
|
|
not_enforced.response_format_ok = 0;
|
|
ASSERT_STR_EQ(wire_response_format_status(&req, ¬_enforced),
|
|
"not_enforced");
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
static void test_status_schema_downgrade_also_requires_enforcement(void) {
|
|
chat_request req;
|
|
jr_value *root = parse_body(
|
|
BODY_HEAD ",\"response_format\":{\"type\":\"json_schema\",\"json_schema\":"
|
|
"{\"name\":\"r\",\"schema\":{\"type\":\"object\"}}}}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
sg_result ok = {0}; ok.response_format_ok = 1;
|
|
sg_result bad = {0}; bad.response_format_ok = 0;
|
|
ASSERT_STR_EQ(wire_response_format_status(&req, &ok), "schema_downgraded");
|
|
ASSERT_STR_EQ(wire_response_format_status(&req, &bad), "not_enforced");
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
/* The already-honest dispositions describe the REQUEST, not the generation, so
|
|
* they must survive untouched (there was nothing to enforce). */
|
|
static void test_status_ignored_reasons_pass_through(void) {
|
|
chat_request req;
|
|
jr_value *root = parse_body(
|
|
BODY_HEAD ",\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"read\"}}]"
|
|
",\"response_format\":{\"type\":\"json_object\"}}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
sg_result res = {0}; /* response_format_ok 0 — nothing was constrained */
|
|
ASSERT_STR_EQ(wire_response_format_status(&req, &res),
|
|
"ignored_tools_present");
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
|
|
root = parse_body(BODY_HEAD ",\"response_format\":{\"type\":\"xml\"}}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
ASSERT_STR_EQ(wire_response_format_status(&req, &res),
|
|
"ignored_unknown_type");
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
static void test_status_absent_stays_absent(void) {
|
|
chat_request req;
|
|
jr_value *root = parse_body(BODY_HEAD "}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
sg_result res = {0};
|
|
ASSERT_NULL(wire_response_format_status(&req, &res));
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
/* The wire writer must carry the RESOLVED status, so a non-enforcing turn is
|
|
* visibly non-enforcing in the body a client parses. */
|
|
static void test_nonstream_body_carries_not_enforced(void) {
|
|
sg_result res = wire_test_result(0);
|
|
res.response_format_ok = 0;
|
|
chat_choice ch = { .res = res };
|
|
chat_request req;
|
|
jr_value *root = parse_body(
|
|
BODY_HEAD ",\"response_format\":{\"type\":\"json_object\"}}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
wire_json[0] = '\0';
|
|
send_completion_json(-1, "cmpl-ne", 0, "test-model", &ch, 1,
|
|
wire_response_format_status(&req, &ch.res), NULL);
|
|
jr_value *body = jr_parse(wire_json);
|
|
ASSERT_NOT_NULL(body);
|
|
ASSERT_STR_EQ(jr_as_string(jr_object_get(body, "response_format_status")),
|
|
"not_enforced");
|
|
jr_free(body);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
/* ============================================================
|
|
* RESPONSE SHAPE — tool_call_error on the wire.
|
|
*
|
|
* Three conditions the client used to be able to confuse, asserted through the
|
|
* bytes it actually parses:
|
|
* 1. ABANDONED — the model opened <parameter=path> and stopped:
|
|
* no `tool_calls`, a `tool_call_error` naming `path`.
|
|
* 2. EMPTY ARG — the model closed an empty value: a real `tool_calls`
|
|
* entry with `{"path":""}` arguments, and NO
|
|
* `tool_call_error`.
|
|
* 3. NO CALL — neither field.
|
|
* Conditions 1 and 2 were byte-identical before this (both `read {}`), and the
|
|
* `{}` half of that collision is why 2 now carries the empty value instead of
|
|
* dropping it: a dropped member and a member that never arrived read the same.
|
|
* ============================================================ */
|
|
|
|
static const char *WIRE_READ_TOOLS =
|
|
"[{\"type\":\"function\",\"function\":{\"name\":\"read\","
|
|
"\"parameters\":{\"type\":\"object\","
|
|
"\"properties\":{\"path\":{\"type\":\"string\"}},"
|
|
"\"required\":[\"path\"]}}}]";
|
|
|
|
/* Run one turn whose tool channel held `tool_raw` all the way to the emitted
|
|
* non-streaming body, and return that body parsed. Caller jr_frees it. */
|
|
static jr_value *wire_emit_tool_turn_dialect(const char *tool_raw,
|
|
tc_dialect dialect) {
|
|
jr_value *tools = jr_parse(WIRE_READ_TOOLS);
|
|
chat_request req = {0};
|
|
req.tools = tools;
|
|
req.gen.dialect = dialect;
|
|
chat_choice ch = { .res = wire_test_result(0) };
|
|
ch.res.tool_raw = (char *)tool_raw; /* borrowed; not freed through res */
|
|
collect_tool_calls(&req, &ch.res, &ch.calls, &ch.n_calls, &ch.tool_error);
|
|
wire_json[0] = '\0';
|
|
send_completion_json(-1, "cmpl-tc", 0, "test-model", &ch, 1, NULL, NULL);
|
|
tc_tool_calls_free(ch.calls, ch.n_calls);
|
|
tc_parse_error_free(&ch.tool_error);
|
|
jr_free(tools);
|
|
return jr_parse(wire_json);
|
|
}
|
|
|
|
/* The XML dialect every other wire test uses. */
|
|
static jr_value *wire_emit_tool_turn(const char *tool_raw) {
|
|
return wire_emit_tool_turn_dialect(tool_raw, TC_DIALECT_QWEN_XML);
|
|
}
|
|
|
|
static const jr_value *wire_first_choice(const jr_value *root) {
|
|
return jr_array_get(jr_object_get(root, "choices"), 0);
|
|
}
|
|
|
|
/* 1. ABANDONED — reported, and NOT dressed up as a call. */
|
|
static void test_nonstream_body_reports_abandoned_call(void) {
|
|
jr_value *root = wire_emit_tool_turn(
|
|
"<tool_call>\n<function=read>\n<parameter=path>\n\n</function>");
|
|
ASSERT_NOT_NULL(root);
|
|
const jr_value *choice = wire_first_choice(root);
|
|
ASSERT_NOT_NULL(choice);
|
|
ASSERT_NULL(jr_object_get(jr_object_get(choice, "message"), "tool_calls"));
|
|
const jr_value *err = jr_object_get(choice, "tool_call_error");
|
|
ASSERT_NOT_NULL(err);
|
|
ASSERT_STR_EQ(jr_as_string(jr_object_get(err, "code")), "call_abandoned");
|
|
ASSERT_STR_EQ(jr_as_string(jr_object_get(err, "function")), "read");
|
|
ASSERT_STR_EQ(jr_as_string(jr_object_get(err, "parameter")), "path");
|
|
ASSERT_TRUE(jr_as_string(jr_object_get(err, "message")) != NULL);
|
|
jr_free(root);
|
|
}
|
|
|
|
/* 2. EMPTY ARGUMENT — still a call, still carrying the argument, and NOT flagged.
|
|
*
|
|
* `{"path":""}` on the wire, not `{}`. Empty and absent are different claims and
|
|
* The client acts differently on each: absent is what a truncated call looks like
|
|
* and is charged against a budget that ends the task, while empty is a value the
|
|
* client can judge against the tool (`edit`'s `new_string:""` is a deletion,
|
|
* `read`'s `path:""` is the empty-path wedge). */
|
|
static void test_nonstream_body_keeps_empty_argument_call(void) {
|
|
jr_value *root = wire_emit_tool_turn(
|
|
"<tool_call>\n<function=read>\n<parameter=path>\n\n</parameter>\n"
|
|
"</function>");
|
|
ASSERT_NOT_NULL(root);
|
|
const jr_value *choice = wire_first_choice(root);
|
|
ASSERT_NOT_NULL(choice);
|
|
const jr_value *tcs =
|
|
jr_object_get(jr_object_get(choice, "message"), "tool_calls");
|
|
ASSERT_NOT_NULL(tcs);
|
|
ASSERT_INT_EQ(jr_array_len(tcs), 1);
|
|
ASSERT_STR_EQ(jr_as_string(jr_object_get(
|
|
jr_object_get(jr_array_get(tcs, 0), "function"), "arguments")),
|
|
"{\"path\":\"\"}");
|
|
ASSERT_NULL(jr_object_get(choice, "tool_call_error"));
|
|
jr_free(root);
|
|
}
|
|
|
|
/* 3. NO CALL AT ALL — neither field; the turn is ordinary text.
|
|
*
|
|
* The fixture is an EMPTY tool channel, which is what "no call" actually looks
|
|
* like: the emitter only ever writes into `tool_raw` after it has seen a
|
|
* tool-call opener, so ordinary prose ends up in `content` and never here. (It
|
|
* used to pass prose as `tool_raw`, a state the emitter cannot produce; that
|
|
* shape now means something specific — see the no_call_parsed test below.) */
|
|
static void test_nonstream_body_silent_when_no_call(void) {
|
|
jr_value *root = wire_emit_tool_turn(NULL);
|
|
ASSERT_NOT_NULL(root);
|
|
const jr_value *choice = wire_first_choice(root);
|
|
ASSERT_NOT_NULL(choice);
|
|
ASSERT_NULL(jr_object_get(jr_object_get(choice, "message"), "tool_calls"));
|
|
ASSERT_NULL(jr_object_get(choice, "tool_call_error"));
|
|
jr_free(root);
|
|
}
|
|
|
|
/* A tool FRAME was emitted and nothing survived parsing: the emitter diverted a
|
|
* region (so `tool_raw` is non-empty) but no call came out of it and no more
|
|
* specific reason was found. Without this the whole diverted region vanishes —
|
|
* it never reaches `content` either — and the client sees a turn that did
|
|
* nothing, with no way to know why. qwen-code's analogue is the
|
|
* MALFORMED_TOOL_CALL raised on a tool_calls finish with zero completed calls
|
|
* (converter.ts:1699-1711). */
|
|
static void test_nonstream_body_reports_unparseable_frame(void) {
|
|
jr_value *root = wire_emit_tool_turn("<tool_call>\nnothing parseable here\n");
|
|
ASSERT_NOT_NULL(root);
|
|
const jr_value *choice = wire_first_choice(root);
|
|
ASSERT_NOT_NULL(choice);
|
|
ASSERT_NULL(jr_object_get(jr_object_get(choice, "message"), "tool_calls"));
|
|
const jr_value *err = jr_object_get(choice, "tool_call_error");
|
|
ASSERT_NOT_NULL(err);
|
|
ASSERT_STR_EQ(jr_as_string(jr_object_get(err, "code")), "no_call_parsed");
|
|
jr_free(root);
|
|
}
|
|
|
|
/* A truncated JSON argument object reaches the wire as a real tool call rather
|
|
* than as nothing: the serve closes the one '}' the model never got to write.
|
|
* This is the whole path — collect_tool_calls asks for the repair, logs it
|
|
* ([tool_json_repair] on stderr), and the call is serialized normally. */
|
|
static void test_nonstream_body_repairs_truncated_json_call(void) {
|
|
jr_value *root = wire_emit_tool_turn_dialect(
|
|
"```json\n{\"function\": \"read\", \"arguments\": {\"path\": \"/etc/hostname\"}",
|
|
TC_DIALECT_ORNITH_JSON);
|
|
ASSERT_NOT_NULL(root);
|
|
const jr_value *choice = wire_first_choice(root);
|
|
ASSERT_NOT_NULL(choice);
|
|
const jr_value *tcs =
|
|
jr_object_get(jr_object_get(choice, "message"), "tool_calls");
|
|
ASSERT_NOT_NULL(tcs);
|
|
ASSERT_INT_EQ(jr_array_len(tcs), 1);
|
|
ASSERT_STR_EQ(jr_as_string(jr_object_get(
|
|
jr_object_get(jr_array_get(tcs, 0), "function"), "arguments")),
|
|
"{\"path\":\"/etc/hostname\"}");
|
|
ASSERT_STR_EQ(jr_as_string(jr_object_get(choice, "finish_reason")), "tool_calls");
|
|
ASSERT_NULL(jr_object_get(choice, "tool_call_error"));
|
|
jr_free(root);
|
|
}
|
|
|
|
/* A call the model named but the request never declared: dropped as before (a
|
|
* hallucinated tool must never reach the client), but now NAMED, because "you
|
|
* called a tool that does not exist" is the one drop a client can put back in
|
|
* front of the model as a correction. */
|
|
static void test_nonstream_body_reports_unknown_tool_name(void) {
|
|
jr_value *root = wire_emit_tool_turn(
|
|
"<tool_call>\n<function=serach_files>\n<parameter=path>\n/etc\n"
|
|
"</parameter>\n</function>\n</tool_call>");
|
|
ASSERT_NOT_NULL(root);
|
|
const jr_value *choice = wire_first_choice(root);
|
|
ASSERT_NOT_NULL(choice);
|
|
ASSERT_NULL(jr_object_get(jr_object_get(choice, "message"), "tool_calls"));
|
|
const jr_value *err = jr_object_get(choice, "tool_call_error");
|
|
ASSERT_NOT_NULL(err);
|
|
ASSERT_STR_EQ(jr_as_string(jr_object_get(err, "code")), "call_name_unknown");
|
|
ASSERT_STR_EQ(jr_as_string(jr_object_get(err, "function")), "serach_files");
|
|
jr_free(root);
|
|
}
|
|
|
|
/* A healthy call is untouched: the field never appears on a good turn. */
|
|
static void test_nonstream_body_clean_call_unflagged(void) {
|
|
jr_value *root = wire_emit_tool_turn(
|
|
"<tool_call>\n<function=read>\n<parameter=path>\n/etc/hostname\n"
|
|
"</parameter>\n</function>");
|
|
ASSERT_NOT_NULL(root);
|
|
const jr_value *choice = wire_first_choice(root);
|
|
ASSERT_NOT_NULL(choice);
|
|
const jr_value *tcs =
|
|
jr_object_get(jr_object_get(choice, "message"), "tool_calls");
|
|
ASSERT_NOT_NULL(tcs);
|
|
ASSERT_INT_EQ(jr_array_len(tcs), 1);
|
|
ASSERT_STR_EQ(jr_as_string(jr_object_get(choice, "finish_reason")),
|
|
"tool_calls");
|
|
ASSERT_NULL(jr_object_get(choice, "tool_call_error"));
|
|
jr_free(root);
|
|
}
|
|
|
|
/* ============================================================
|
|
* TRUNCATION GATE — a call the DECODING GRAMMAR never closed.
|
|
*
|
|
* The one thing the text cannot tell you. A truncated tool call is a legal
|
|
* PREFIX of a good one, so every fixture below parses cleanly into a named call
|
|
* with a required argument silently absent — which is exactly what the live
|
|
* fleet handed to the client (`read {}`, `write` with no `content`) while the serve's
|
|
* own grammar state said the call had never finished. So the verdict rides in
|
|
* on sg_result.tool_grammar_verdict, and these tests assert what the client
|
|
* actually parses.
|
|
* ============================================================ */
|
|
|
|
/* Same turn as wire_emit_tool_turn, with the grammar's end-of-turn verdict set. */
|
|
static jr_value *wire_emit_tool_turn_verdict(const char *tool_raw, int verdict) {
|
|
jr_value *tools = jr_parse(WIRE_READ_TOOLS);
|
|
chat_request req = {0};
|
|
req.tools = tools;
|
|
req.gen.dialect = TC_DIALECT_QWEN_XML;
|
|
chat_choice ch = { .res = wire_test_result(0) };
|
|
ch.res.tool_raw = (char *)tool_raw; /* borrowed; not freed through res */
|
|
ch.res.tool_grammar_verdict = verdict;
|
|
collect_tool_calls(&req, &ch.res, &ch.calls, &ch.n_calls, &ch.tool_error);
|
|
wire_json[0] = '\0';
|
|
send_completion_json(-1, "cmpl-tc", 0, "test-model", &ch, 1, NULL, NULL);
|
|
tc_tool_calls_free(ch.calls, ch.n_calls);
|
|
tc_parse_error_free(&ch.tool_error);
|
|
jr_free(tools);
|
|
return jr_parse(wire_json);
|
|
}
|
|
|
|
/* THE DEFECT, verbatim off the live serve log (tool_arg_truncation.md log:274):
|
|
* the model emitted `path` as a single newline and stopped. It parses into
|
|
* `read {}` — a named call with its one required argument gone — and used to be
|
|
* sent as one. It must now be absent from `tool_calls` and NAMED on the error
|
|
* channel instead. */
|
|
static void test_nonstream_body_refuses_incomplete_call(void) {
|
|
jr_value *root = wire_emit_tool_turn_verdict(
|
|
"<tool_call>\n<function=read>\n<parameter=path>\n\n"
|
|
"</parameter></function>\n", SG_TOOL_GRAMMAR_INCOMPLETE);
|
|
ASSERT_NOT_NULL(root);
|
|
const jr_value *choice = wire_first_choice(root);
|
|
ASSERT_NOT_NULL(choice);
|
|
ASSERT_NULL(jr_object_get(jr_object_get(choice, "message"), "tool_calls"));
|
|
const jr_value *err = jr_object_get(choice, "tool_call_error");
|
|
ASSERT_NOT_NULL(err);
|
|
ASSERT_STR_EQ(jr_as_string(jr_object_get(err, "code")), "call_incomplete");
|
|
ASSERT_STR_EQ(jr_as_string(jr_object_get(err, "function")), "read");
|
|
ASSERT_TRUE(jr_as_string(jr_object_get(err, "message")) != NULL);
|
|
jr_free(root);
|
|
}
|
|
|
|
/* The 2-required-argument shape: `path` parsed, `content` emptied and dropped.
|
|
* A call carrying a real-looking `path` is the more dangerous of the two — a
|
|
* client that only checks "did I get a call" would run it. */
|
|
static void test_nonstream_body_refuses_partial_arguments(void) {
|
|
jr_value *root = wire_emit_tool_turn_verdict(
|
|
"<function=read>\n<parameter=path>\n/etc/hostname\n</parameter>\n",
|
|
SG_TOOL_GRAMMAR_INCOMPLETE);
|
|
ASSERT_NOT_NULL(root);
|
|
const jr_value *choice = wire_first_choice(root);
|
|
ASSERT_NOT_NULL(choice);
|
|
ASSERT_NULL(jr_object_get(jr_object_get(choice, "message"), "tool_calls"));
|
|
ASSERT_STR_EQ(jr_as_string(jr_object_get(
|
|
jr_object_get(choice, "tool_call_error"), "code")),
|
|
"call_incomplete");
|
|
jr_free(root);
|
|
}
|
|
|
|
/* CONTROL — the grammar CLOSED the call: untouched, and unflagged. Without this
|
|
* the gate could be "refuse everything" and still look green. */
|
|
static void test_nonstream_body_keeps_complete_call(void) {
|
|
jr_value *root = wire_emit_tool_turn_verdict(
|
|
"<tool_call>\n<function=read>\n<parameter=path>\n/etc/hostname\n"
|
|
"</parameter>\n</function>", SG_TOOL_GRAMMAR_COMPLETE);
|
|
ASSERT_NOT_NULL(root);
|
|
const jr_value *choice = wire_first_choice(root);
|
|
ASSERT_NOT_NULL(choice);
|
|
const jr_value *tcs =
|
|
jr_object_get(jr_object_get(choice, "message"), "tool_calls");
|
|
ASSERT_NOT_NULL(tcs);
|
|
ASSERT_INT_EQ(jr_array_len(tcs), 1);
|
|
ASSERT_NULL(jr_object_get(choice, "tool_call_error"));
|
|
jr_free(root);
|
|
}
|
|
|
|
/* CONTROL — NO tool grammar ran (PANTHEON_TOOL_GRAMMAR off, or a dialect this
|
|
* grammar does not model, or a walk that never entered a call). ABSENT is not a
|
|
* soft "incomplete": with no verdict to gate on, the turn must behave exactly as
|
|
* it did before this gate existed. */
|
|
static void test_nonstream_body_ungated_without_a_grammar(void) {
|
|
jr_value *root = wire_emit_tool_turn_verdict(
|
|
"<tool_call>\n<function=read>\n<parameter=path>\n\n"
|
|
"</parameter></function>\n", SG_TOOL_GRAMMAR_ABSENT);
|
|
ASSERT_NOT_NULL(root);
|
|
const jr_value *choice = wire_first_choice(root);
|
|
ASSERT_NOT_NULL(choice);
|
|
ASSERT_NOT_NULL(jr_object_get(jr_object_get(choice, "message"), "tool_calls"));
|
|
ASSERT_NULL(jr_object_get(choice, "tool_call_error"));
|
|
jr_free(root);
|
|
}
|
|
|
|
/* A reason the PARSER already named wins — it is the same event seen closer up,
|
|
* and it carries the parameter name. The gate must not overwrite it. */
|
|
static void test_incomplete_does_not_mask_a_named_reason(void) {
|
|
jr_value *root = wire_emit_tool_turn_verdict(
|
|
"<tool_call>\n<function=read>\n<parameter=path>\n\n</function>",
|
|
SG_TOOL_GRAMMAR_INCOMPLETE);
|
|
ASSERT_NOT_NULL(root);
|
|
const jr_value *choice = wire_first_choice(root);
|
|
ASSERT_NOT_NULL(choice);
|
|
ASSERT_NULL(jr_object_get(jr_object_get(choice, "message"), "tool_calls"));
|
|
const jr_value *err = jr_object_get(choice, "tool_call_error");
|
|
ASSERT_STR_EQ(jr_as_string(jr_object_get(err, "code")), "call_abandoned");
|
|
ASSERT_STR_EQ(jr_as_string(jr_object_get(err, "parameter")), "path");
|
|
jr_free(root);
|
|
}
|
|
|
|
|
|
/* ============================================================
|
|
* THE TOOL-SYNTAX LEAK. Everything in collect_tool_calls hangs off
|
|
* `res->tool_raw`, which the emitter only fills once it recognises a real
|
|
* opener. A model that invents a NEAR-MISS frame matches no opener, is never
|
|
* diverted, and lands in `content` — so the turn ends with no call, no defect
|
|
* code, and NOTHING in the log. That silence is what made this class of bug
|
|
* cost a session to find: it reads exactly like a turn that chose to answer in
|
|
* prose.
|
|
*
|
|
* The captured text is verbatim from qwen36-bf16 (BF16 full weights, 7 tools,
|
|
* 558-token prompt, grammar off). stderr is redirected to a temp file so the
|
|
* assertion is on what the fleet log actually receives, not on the call being
|
|
* spelled right.
|
|
* ============================================================ */
|
|
static char *capture_leak_stderr(const char *content, const char *tool_raw,
|
|
int with_tools) {
|
|
static char buf[4096];
|
|
char path[] = "/tmp/pn_tool_leak_XXXXXX";
|
|
int fd = mkstemp(path);
|
|
if (fd < 0) return NULL;
|
|
fflush(stderr);
|
|
int saved = dup(fileno(stderr));
|
|
dup2(fd, fileno(stderr));
|
|
|
|
jr_value *tools = with_tools
|
|
? jr_parse("[{\"type\":\"function\",\"function\":{\"name\":\"bash\","
|
|
"\"parameters\":{\"type\":\"object\",\"properties\":"
|
|
"{\"command\":{\"type\":\"string\"}}}}}]")
|
|
: NULL;
|
|
chat_request req = {0};
|
|
req.tools = tools;
|
|
sg_result res = {0};
|
|
res.content = (char *)content;
|
|
res.tool_raw = (char *)tool_raw;
|
|
|
|
tc_tool_call *calls = NULL; int n = 0; tc_parse_error err = {0};
|
|
collect_tool_calls(&req, &res, &calls, &n, &err);
|
|
tc_tool_calls_free(calls, n);
|
|
tc_parse_error_free(&err);
|
|
jr_free(tools);
|
|
|
|
fflush(stderr);
|
|
dup2(saved, fileno(stderr));
|
|
close(saved);
|
|
lseek(fd, 0, SEEK_SET);
|
|
ssize_t got = read(fd, buf, sizeof(buf) - 1);
|
|
buf[got > 0 ? got : 0] = '\0';
|
|
close(fd);
|
|
remove(path);
|
|
return buf;
|
|
}
|
|
|
|
static const char INVENTED_FRAME[] =
|
|
"<tool_calls>\n<tool>\n<tool_name>bash</tool_name>\n<tool>\n"
|
|
"<parameter_name>command</parameter_name>\n"
|
|
"<parameter_value>sed -n '175,200p' src/context/context_store.c"
|
|
"</parameter_value>\n</tool>\n</tool_call>";
|
|
|
|
static void test_invented_tool_frame_is_reported(void) {
|
|
const char *log = capture_leak_stderr(INVENTED_FRAME, NULL, 1);
|
|
ASSERT_TRUE(log != NULL);
|
|
ASSERT_TRUE(strstr(log, "[tool_syntax_leak]") != NULL);
|
|
/* The raw text has to be IN the line — the whole point is that the client
|
|
* only ever records the parsed result, so this is the one place the bytes
|
|
* that produced the empty turn still exist. */
|
|
ASSERT_TRUE(strstr(log, "<tool_name>bash</tool_name>") != NULL);
|
|
}
|
|
|
|
/* A plain prose answer is the common case and must stay silent, or the signal is
|
|
* worthless. */
|
|
static void test_plain_prose_answer_is_silent(void) {
|
|
const char *log = capture_leak_stderr(
|
|
"The file reads a config and returns 0 on success.", NULL, 1);
|
|
ASSERT_TRUE(log != NULL);
|
|
ASSERT_TRUE(strstr(log, "[tool_syntax_leak]") == NULL);
|
|
}
|
|
|
|
/* A turn that DID divert a frame is `no_call_parsed`'s business, not this one:
|
|
* the two silences are different and must not be conflated. */
|
|
static void test_diverted_frame_is_not_a_leak(void) {
|
|
const char *log = capture_leak_stderr("", "<tool_call>\n<bash>\n", 1);
|
|
ASSERT_TRUE(log != NULL);
|
|
ASSERT_TRUE(strstr(log, "[tool_syntax_leak]") == NULL);
|
|
ASSERT_TRUE(strstr(log, "no_call_parsed") != NULL);
|
|
}
|
|
|
|
/* No tools on the request → the model was never offered a call, and tool-ish
|
|
* prose (a code answer ABOUT tool calling) is not a defect. */
|
|
static void test_no_tools_no_report(void) {
|
|
const char *log = capture_leak_stderr(INVENTED_FRAME, NULL, 0);
|
|
ASSERT_TRUE(log != NULL);
|
|
ASSERT_TRUE(strstr(log, "[tool_syntax_leak]") == NULL);
|
|
}
|
|
|
|
/* ── tool calls stream WHILE they are generated (run_streaming end to end) ──
|
|
* The stubbed generation feeds a real emitter whose tool tap is armed by
|
|
* run_streaming, exactly as serve_generation.c does. The old behaviour sent
|
|
* nothing of a call until the turn ended — the client's stall watchdog then
|
|
* killed long `write` calls — so these assert on what was on the wire BEFORE
|
|
* the end, and that the streamed fragments add up to the parsed arguments. */
|
|
|
|
typedef struct {
|
|
char name[32];
|
|
json_writer args;
|
|
int fragments;
|
|
bool seen;
|
|
} folded_call;
|
|
|
|
/* Fold `len` bytes of SSE the way a client does (tool_calls by index). */
|
|
static int fold_wire(const char *wire, size_t len, folded_call *calls, int max,
|
|
char *finish, size_t finish_len, int *keepalives)
|
|
{
|
|
int n = 0;
|
|
*keepalives = 0;
|
|
if (finish_len) finish[0] = '\0';
|
|
char *copy = strndup(wire, len);
|
|
for (char *line = strtok(copy, "\n"); line; line = strtok(NULL, "\n")) {
|
|
if (line[0] == ':') { (*keepalives)++; continue; }
|
|
if (strncmp(line, "data: ", 6) != 0) continue;
|
|
jr_value *ev = jr_parse(line + 6);
|
|
const jr_value *ch = jr_array_get(jr_object_get(ev, "choices"), 0);
|
|
const jr_value *tcs = jr_object_get(jr_object_get(ch, "delta"), "tool_calls");
|
|
for (int i = 0; i < jr_array_len(tcs); i++) {
|
|
const jr_value *tc = jr_array_get(tcs, i);
|
|
int idx = (int)jr_as_number(jr_object_get(tc, "index"), -1);
|
|
if (idx < 0 || idx >= max) continue;
|
|
folded_call *fc = &calls[idx];
|
|
if (!fc->seen) { fc->seen = true; jw_init(&fc->args); if (idx >= n) n = idx + 1; }
|
|
const jr_value *fn = jr_object_get(tc, "function");
|
|
const char *name = jr_as_string(jr_object_get(fn, "name"));
|
|
if (name) snprintf(fc->name, sizeof(fc->name), "%s", name);
|
|
const char *args = jr_as_string(jr_object_get(fn, "arguments"));
|
|
if (args && *args) { jw_raw(&fc->args, args); fc->fragments++; }
|
|
}
|
|
const char *fr = jr_as_string(jr_object_get(ch, "finish_reason"));
|
|
if (fr && finish_len) snprintf(finish, finish_len, "%s", fr);
|
|
jr_free(ev);
|
|
}
|
|
free(copy);
|
|
return n;
|
|
}
|
|
|
|
static void free_folded(folded_call *calls, int n)
|
|
{
|
|
for (int i = 0; i < n; i++) if (calls[i].seen) jw_free(&calls[i].args);
|
|
}
|
|
|
|
#define TOOLS_WRITE ",\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"write\"," \
|
|
"\"parameters\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}," \
|
|
"\"content\":{\"type\":\"string\"}},\"required\":[\"path\",\"content\"]}}}]"
|
|
|
|
static const char *const ORNITH_WRITE_PIECES[] = {
|
|
"I'll write it.\n\n", "```", "json\n", "{\n \"", "function", "\": \"",
|
|
"write", "\",\n \"", "arguments", "\": {\n \"path\": \"", "a.py",
|
|
"\",\n \"content\": \"", "import os\\n", "\\n", "def main():\\n",
|
|
" print(\\\"hi\\\")\\n", " return 0\\n", "\\n", "if __name__ == ",
|
|
"\\\"__main__\\\":\\n", " main()\\n", "\"\n }\n}", "\n```",
|
|
};
|
|
|
|
/* Run one streaming completion over `pieces`; leaves the wire in wire_all. */
|
|
static void run_stub_stream(chat_request *req, const char *const *pieces, int n,
|
|
double keepalive_s)
|
|
{
|
|
jw_free(&wire_all);
|
|
jw_init(&wire_all);
|
|
stub_pieces = pieces;
|
|
stub_n_pieces = n;
|
|
stream_ctx sc = { .client_fd = -1, .id = "cmpl-tc", .created = 0,
|
|
.model_id = "test-model", .keepalive_s = keepalive_s };
|
|
run_streaming(-1, NULL, NULL, req, &req->gen, &sc, NULL);
|
|
stub_pieces = NULL;
|
|
stub_n_pieces = 0;
|
|
}
|
|
|
|
static void test_stream_tool_call_reaches_wire_before_turn_ends(void) {
|
|
chat_request req;
|
|
jr_value *root = parse_body(BODY_HEAD TOOLS_WRITE ",\"stream\":true}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
req.gen.dialect = TC_DIALECT_ORNITH_JSON; /* what chat_apply_tool_dialect sets for ornith */
|
|
int n = (int)(sizeof(ORNITH_WRITE_PIECES) / sizeof(*ORNITH_WRITE_PIECES));
|
|
run_stub_stream(&req, ORNITH_WRITE_PIECES, n, 0);
|
|
|
|
/* What the client had BEFORE generation ended: the opening delta and
|
|
* argument fragments — not silence. */
|
|
folded_call early[4] = {0};
|
|
char fin[32];
|
|
int ka = 0;
|
|
int n_early = fold_wire(wire_all.data, stub_wire_len_before_end, early, 4,
|
|
fin, sizeof(fin), &ka);
|
|
ASSERT_INT_EQ(n_early, 1);
|
|
ASSERT_STR_EQ(early[0].name, "write");
|
|
ASSERT_TRUE(early[0].fragments >= 5);
|
|
ASSERT_TRUE(strstr(early[0].args.data, "def main()") != NULL);
|
|
|
|
/* The whole stream: one call whose fragments concatenate to the parsed
|
|
* arguments (what the non-streaming body carries), finish tool_calls. */
|
|
folded_call all[4] = {0};
|
|
int n_all = fold_wire(wire_all.data, wire_all.len, all, 4, fin, sizeof(fin), &ka);
|
|
ASSERT_INT_EQ(n_all, 1);
|
|
json_writer raw;
|
|
jw_init(&raw);
|
|
for (int k = 0; k < n; k++) jw_raw(&raw, ORNITH_WRITE_PIECES[k]);
|
|
tc_tool_call *calls = NULL;
|
|
int n_calls = 0;
|
|
tc_parse_tool_calls(strstr(raw.data, "```"), req.tools, TC_DIALECT_ORNITH_JSON,
|
|
&calls, &n_calls);
|
|
ASSERT_INT_EQ(n_calls, 1);
|
|
ASSERT_STR_EQ(all[0].args.data, calls[0].arguments);
|
|
ASSERT_STR_EQ(fin, "tool_calls");
|
|
tc_tool_calls_free(calls, n_calls);
|
|
jw_free(&raw);
|
|
free_folded(early, n_early);
|
|
free_folded(all, n_all);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
/* A call that cannot be streamed early (the XML dialect) must still not leave
|
|
* the wire silent: keepalive comments while it is written, the call itself
|
|
* at the end exactly as before. */
|
|
static void test_stream_keepalive_while_call_is_held(void) {
|
|
static const char *const pieces[] = {
|
|
"<tool_call>\n", "<function=write>\n", "<parameter=path>\na.txt\n",
|
|
"</parameter>\n", "<parameter=content>\nhello\n", "</parameter>\n",
|
|
"</function>\n", "</tool_call>",
|
|
};
|
|
chat_request req;
|
|
jr_value *root = parse_body(BODY_HEAD TOOLS_WRITE ",\"stream\":true}", &req);
|
|
ASSERT_NOT_NULL(root);
|
|
run_stub_stream(&req, pieces, (int)(sizeof(pieces) / sizeof(*pieces)), 1e-9);
|
|
|
|
folded_call early[4] = {0};
|
|
char fin[32];
|
|
int ka = 0;
|
|
int n_early = fold_wire(wire_all.data, stub_wire_len_before_end, early, 4,
|
|
fin, sizeof(fin), &ka);
|
|
ASSERT_INT_EQ(n_early, 0); /* held: nothing of the call yet … */
|
|
ASSERT_TRUE(ka >= 1); /* … but the stream is not silent */
|
|
|
|
folded_call all[4] = {0};
|
|
int n_all = fold_wire(wire_all.data, wire_all.len, all, 4, fin, sizeof(fin), &ka);
|
|
ASSERT_INT_EQ(n_all, 1);
|
|
ASSERT_STR_EQ(all[0].name, "write");
|
|
ASSERT_STR_EQ(all[0].args.data, "{\"path\":\"a.txt\",\"content\":\"hello\"}");
|
|
ASSERT_STR_EQ(fin, "tool_calls");
|
|
free_folded(all, n_all);
|
|
chat_request_free(&req);
|
|
jr_free(root);
|
|
}
|
|
|
|
int main(void) {
|
|
printf("\n=== chat_request (tool_choice parse + forced_name lifetime) ===\n\n");
|
|
RUN_TEST(forced_name_survives_dom_free);
|
|
RUN_TEST(tool_choice_string_forms);
|
|
RUN_TEST(session_key_aliases);
|
|
RUN_TEST(required_no_forced_name);
|
|
RUN_TEST(forced_without_tools_downgrades);
|
|
RUN_TEST(malformed_object_form);
|
|
printf("\n=== tool-syntax leak (a frame that matched no opener) ===\n\n");
|
|
RUN_TEST(invented_tool_frame_is_reported);
|
|
RUN_TEST(plain_prose_answer_is_silent);
|
|
RUN_TEST(diverted_frame_is_not_a_leak);
|
|
RUN_TEST(no_tools_no_report);
|
|
printf("\n=== openai fields (response_format / seed / penalties / bias / logprobs) ===\n\n");
|
|
RUN_TEST(response_format_json_object_is_applied);
|
|
RUN_TEST(response_format_text_is_a_noop);
|
|
RUN_TEST(response_format_absent_is_a_noop);
|
|
RUN_TEST(response_format_json_schema_downgrades);
|
|
RUN_TEST(response_format_unknown_type_is_reported);
|
|
RUN_TEST(response_format_yields_to_tools);
|
|
RUN_TEST(response_format_applies_when_tool_choice_none);
|
|
RUN_TEST(seed_is_parsed);
|
|
RUN_TEST(enable_thinking_is_parsed);
|
|
RUN_TEST(penalties_are_parsed_and_clamped);
|
|
RUN_TEST(logit_bias_is_parsed_and_owned);
|
|
RUN_TEST(logprobs_are_parsed);
|
|
RUN_TEST(nonstream_reports_response_format_status);
|
|
RUN_TEST(nonstream_omits_status_when_not_requested);
|
|
RUN_TEST(stream_chunk_reports_response_format_status);
|
|
RUN_TEST(stream_chunk_omits_status_when_not_requested);
|
|
RUN_TEST(run_streaming_reports_enforced_status);
|
|
RUN_TEST(run_streaming_reports_unenforced_status);
|
|
RUN_TEST(n_defaults_to_one);
|
|
RUN_TEST(n_is_honoured);
|
|
RUN_TEST(n_over_the_ceiling_is_reported_not_silent);
|
|
RUN_TEST(n_zero_and_negative_floor_at_one);
|
|
RUN_TEST(seeded_choices_get_distinct_streams);
|
|
RUN_TEST(nonstream_emits_all_choices);
|
|
RUN_TEST(nonstream_reports_n_shortfall);
|
|
RUN_TEST(stream_chunk_carries_the_choice_index);
|
|
RUN_TEST(status_applied_requires_actual_enforcement);
|
|
RUN_TEST(status_schema_downgrade_also_requires_enforcement);
|
|
RUN_TEST(status_ignored_reasons_pass_through);
|
|
RUN_TEST(status_absent_stays_absent);
|
|
RUN_TEST(nonstream_body_carries_not_enforced);
|
|
|
|
RUN_TEST(nonstream_body_reports_abandoned_call);
|
|
RUN_TEST(nonstream_body_keeps_empty_argument_call);
|
|
RUN_TEST(nonstream_body_silent_when_no_call);
|
|
RUN_TEST(nonstream_body_reports_unparseable_frame);
|
|
RUN_TEST(nonstream_body_repairs_truncated_json_call);
|
|
RUN_TEST(nonstream_body_reports_unknown_tool_name);
|
|
RUN_TEST(nonstream_body_clean_call_unflagged);
|
|
RUN_TEST(nonstream_body_refuses_incomplete_call);
|
|
RUN_TEST(nonstream_body_refuses_partial_arguments);
|
|
RUN_TEST(nonstream_body_keeps_complete_call);
|
|
RUN_TEST(nonstream_body_ungated_without_a_grammar);
|
|
RUN_TEST(incomplete_does_not_mask_a_named_reason);
|
|
printf("\n=== chat response shape (session_conflict on the wire) ===\n\n");
|
|
RUN_TEST(nonstream_usage_carries_session_conflict);
|
|
RUN_TEST(nonstream_usage_omits_session_conflict_when_clean);
|
|
RUN_TEST(stream_usage_chunk_carries_session_conflict);
|
|
RUN_TEST(stream_usage_chunk_omits_session_conflict_when_clean);
|
|
printf("\n=== chat response shape (cached_tokens on the wire) ===\n\n");
|
|
RUN_TEST(nonstream_usage_reports_cached_tokens);
|
|
RUN_TEST(nonstream_usage_reports_zero_cached_tokens_when_cold);
|
|
RUN_TEST(stream_usage_chunk_reports_cached_tokens);
|
|
RUN_TEST(stream_usage_chunk_reports_zero_cached_tokens_when_cold);
|
|
RUN_TEST(stream_tool_call_reaches_wire_before_turn_ends);
|
|
RUN_TEST(stream_keepalive_while_call_is_held);
|
|
TEST_REPORT();
|
|
return __ft_failed == 0 ? 0 : 1;
|
|
}
|