From 23991c03a28112839619af886b9c44dc8bee9702 Mon Sep 17 00:00:00 2001 From: Donato Capitella Date: Wed, 9 Sep 2026 20:19:03 +0100 Subject: [PATCH] server: keep OpenAI Completions prompts raw Remove the injected system/user conversation from string prompts, preserve exact token-ID prompts, and keep compatibility thinking controls inert. Retain model stops without applying chat-only thinking-tag stops. Validate raw live and disk cache histories before reuse, preserving model start tokens and sampled tokenization boundaries. Add model-free prompt/cache/stop regressions and a model-backed tokenization check. --- .gitignore | 1 + Makefile | 8 + docs/SERVER.md | 2 + ds4_server.c | 389 +++++++++++++++++++++++++++++------ tests/ds4_test.c | 65 ++++++ tests/test_raw_completions.c | 344 +++++++++++++++++++++++++++++++ 6 files changed, 741 insertions(+), 68 deletions(-) create mode 100644 tests/test_raw_completions.c diff --git a/.gitignore b/.gitignore index 18014c39b1..38603d8b50 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ /tests/test_q4k_dot /tests/test_sampling /tests/test_quality_api +/tests/test_raw_completions /tests/test_cuda_q8_scratch /tests/test_cuda_dspark_moe /tests/test_session_state diff --git a/Makefile b/Makefile index fe26c00da2..d593e29c98 100644 --- a/Makefile +++ b/Makefile @@ -754,12 +754,20 @@ test-quality-api: tests/test_quality_api.c gguf-tools/quality-testing/score_offi $(CC) $(QUALITY_CFLAGS) -I. -ffunction-sections -fdata-sections -o tests/test_quality_api tests/test_quality_api.c -Wl,$(if $(filter Darwin,$(UNAME_S)),-dead_strip,--gc-sections) -lm ./tests/test_quality_api +.PHONY: test-raw-completions +tests/test_raw_completions: tests/test_raw_completions.c ds4_server.c ds4.h ds4_kvstore.h ds4_tool_text.h $(CPU_CORE_OBJS) ds4_help.o ds4_kvstore.o rax.o + $(CC) $(CFLAGS) -Wno-unused-function -DDS4_NO_GPU -I. -ffunction-sections -fdata-sections -o $@ $< $(CPU_CORE_OBJS) ds4_help.o ds4_kvstore.o rax.o -Wl,$(if $(filter Darwin,$(UNAME_S)),-dead_strip,--gc-sections) $(LDLIBS) + +test-raw-completions: tests/test_raw_completions + ./tests/test_raw_completions + ds4.o ds4_cpu.o ds4_agent.o ds4_agent_cpu.o ds4_server.o ds4_server_cpu.o \ ds4_test.o ds4_agent_test.o \ ds4_cpu_test_hooks.o ds4_cuda_test_hooks.o tests/test_session_state.o \ tests/test_session_state_gpu.o: ds4_tool_text.h clean: + rm -f tests/test_raw_completions rm -f tests/test_metal_ssd_experts rm -f tests/test_cuda_q8_scratch rm -f tests/test_cuda_dspark_moe diff --git a/docs/SERVER.md b/docs/SERVER.md index a95df755af..120d35c8b6 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -49,6 +49,8 @@ Max only with sufficient context; otherwise it falls back to normal thinking. `xhigh` maps to normal thinking, not Think Max. Use `think:false`, a disabled thinking object, or a non-thinking model alias for direct answers. +`/v1/completions` continues a raw prompt without adding a system message or chat template. `prompt` accepts one string or one non-empty flat array of integer token IDs. Strings receive the model start sequence and recognize explicitly spelled special tokens; token arrays are used exactly as supplied. Batches, nested or mixed arrays, and IDs outside the model vocabulary are rejected. The compatibility fields `thinking`, `think`, and `reasoning_effort` are validated but do not change the prompt or suppress thinking tags in the output. Model stop tokens and explicit client stop sequences still apply. + ## Multiple sessions ```sh diff --git a/ds4_server.c b/ds4_server.c index ee008a1637..c7bbe6c55e 100644 --- a/ds4_server.c +++ b/ds4_server.c @@ -4989,45 +4989,147 @@ static bool parse_responses_request(ds4_engine *e, server *s, const char *body, return false; } -static bool parse_prompt(const char **p, char **out) { +typedef enum { + COMPLETION_PROMPT_NONE = 0, + COMPLETION_PROMPT_TEXT, + COMPLETION_PROMPT_TOKENS, +} completion_prompt_kind; + +typedef struct { + completion_prompt_kind kind; + char *text; + ds4_tokens tokens; +} completion_prompt; + +static void completion_prompt_free(completion_prompt *prompt) { + if (!prompt) return; + free(prompt->text); + ds4_tokens_free(&prompt->tokens); + memset(prompt, 0, sizeof(*prompt)); +} + +static bool parse_completion_token_id(const char **p, int *out) { json_ws(p); - if (**p == '"') return json_string(p, out); - if (**p != '[') { - if (!json_skip_value(p)) return false; - *out = xstrdup(""); + const char *start = *p; + if (*start < '0' || *start > '9') return false; + uint64_t value = 0; + if (*start == '0' && start[1] >= '0' && start[1] <= '9') return false; + while (**p >= '0' && **p <= '9') { + const uint32_t digit = (uint32_t)(**p - '0'); + if (value > ((uint64_t)INT_MAX - digit) / 10u) return false; + value = value * 10u + digit; + (*p)++; + } + *out = (int)value; + return true; +} + +static bool parse_completion_prompt(const char **p, completion_prompt *out, + char *err, size_t errlen) { + completion_prompt parsed = {0}; + json_ws(p); + if (**p == '"') { + if (!json_string(p, &parsed.text)) { + snprintf(err, errlen, "prompt must be a valid JSON string or a flat array of integer token IDs"); + return false; + } + parsed.kind = COMPLETION_PROMPT_TEXT; + *out = parsed; return true; } + if (**p != '[') { + snprintf(err, errlen, "prompt must be a string or a flat array of integer token IDs"); + return false; + } (*p)++; json_ws(p); - if (**p == '"') { - if (!json_string(p, out)) return false; - } else { - *out = xstrdup(""); - if (**p && **p != ']' && !json_skip_value(p)) return false; + if (**p == ']') { + (*p)++; + parsed.kind = COMPLETION_PROMPT_TOKENS; + *out = parsed; + return true; } - while (**p && **p != ']') { + while (**p) { + int token = 0; + if (!parse_completion_token_id(p, &token)) { + snprintf(err, errlen, "prompt token array must contain only non-negative integer token IDs"); + completion_prompt_free(&parsed); + return false; + } + ds4_tokens_push(&parsed.tokens, token); json_ws(p); - if (**p == ',') { + if (**p == ']') { (*p)++; - if (!json_skip_value(p)) return false; - } else { - break; + parsed.kind = COMPLETION_PROMPT_TOKENS; + *out = parsed; + return true; + } + if (**p != ',') { + snprintf(err, errlen, "prompt token array must be a flat array of integer token IDs"); + completion_prompt_free(&parsed); + return false; + } + (*p)++; + json_ws(p); + if (**p == ']') { + snprintf(err, errlen, "prompt token array must not contain a trailing comma"); + completion_prompt_free(&parsed); + return false; } } - if (**p != ']') return false; - (*p)++; + snprintf(err, errlen, "unterminated prompt token array"); + completion_prompt_free(&parsed); + return false; +} + +static bool completion_prompt_build(ds4_engine *e, int vocab_size, + completion_prompt *prompt, request *r, + char *err, size_t errlen) { + if (prompt->kind == COMPLETION_PROMPT_TEXT) { + if (!e) { + snprintf(err, errlen, "completion text tokenization requires a loaded model"); + return false; + } + r->prompt_text = prompt->text; + prompt->text = NULL; + ds4_chat_begin(e, &r->prompt); + ds4_tokenize_rendered_chat(e, r->prompt_text, &r->prompt); + return true; + } + if (prompt->kind != COMPLETION_PROMPT_TOKENS) { + snprintf(err, errlen, "missing prompt"); + return false; + } + if (prompt->tokens.len == 0) { + snprintf(err, errlen, "prompt token array must not be empty"); + return false; + } + if (vocab_size <= 0) { + snprintf(err, errlen, "completion token validation requires a loaded model"); + return false; + } + for (int i = 0; i < prompt->tokens.len; i++) { + if (prompt->tokens.v[i] < 0 || prompt->tokens.v[i] >= vocab_size) { + snprintf(err, errlen, "prompt token ID %d is outside the model vocabulary [0, %d)", + prompt->tokens.v[i], vocab_size); + return false; + } + } + r->prompt = prompt->tokens; + memset(&prompt->tokens, 0, sizeof(prompt->tokens)); return true; } -static bool parse_completion_request(ds4_engine *e, const char *body, int def_tokens, - int ctx_size, request *r, char *err, size_t errlen) { +static bool parse_completion_request_with_vocab(ds4_engine *e, int vocab_size, + const char *body, int def_tokens, + int ctx_size, request *r, + char *err, size_t errlen) { request_init(r, REQ_COMPLETION, def_tokens); r->model_syntax = server_model_syntax_for_engine(e); const char *p = body; - char *prompt = NULL; - bool got_thinking = false; - bool thinking_enabled = true; - ds4_think_mode reasoning_effort = DS4_THINK_HIGH; + completion_prompt prompt = {0}; + bool got_prompt = false; + (void)ctx_size; json_ws(&p); if (*p != '{') goto bad; @@ -5043,14 +5145,16 @@ static bool parse_completion_request(ds4_engine *e, const char *body, int def_to } p++; if (!strcmp(key, "prompt")) { - char *tmp = NULL; - if (!parse_prompt(&p, &tmp)) { - free(tmp); + completion_prompt tmp = {0}; + if (!parse_completion_prompt(&p, &tmp, err, errlen)) { free(key); - goto bad; + completion_prompt_free(&prompt); + request_free(r); + return false; } - free(prompt); + completion_prompt_free(&prompt); prompt = tmp; + got_prompt = true; } else if (!strcmp(key, "model")) { if (!json_string_replace(&p, &r->model)) { free(key); @@ -5110,22 +5214,23 @@ static bool parse_completion_request(ds4_engine *e, const char *body, int def_to goto bad; } } else if (!strcmp(key, "thinking")) { - if (!parse_thinking_control_value(&p, &thinking_enabled)) { + bool ignored = false; + if (!parse_thinking_control_value(&p, &ignored)) { free(key); goto bad; } - got_thinking = true; } else if (!strcmp(key, "reasoning_effort")) { - if (!parse_reasoning_effort_value(&p, &reasoning_effort)) { + ds4_think_mode ignored = DS4_THINK_NONE; + if (!parse_reasoning_effort_value(&p, &ignored)) { free(key); goto bad; } } else if (!strcmp(key, "think")) { - if (!json_bool(&p, &thinking_enabled)) { + bool ignored = false; + if (!json_bool(&p, &ignored)) { free(key); goto bad; } - got_thinking = true; } else if (!strcmp(key, "stop")) { if (!parse_stop(&p, &r->stops)) { free(key); @@ -5141,38 +5246,41 @@ static bool parse_completion_request(ds4_engine *e, const char *body, int def_to json_ws(&p); } if (*p != '}') goto bad; - if (!prompt) { + if (!got_prompt) { snprintf(err, errlen, "missing prompt"); + completion_prompt_free(&prompt); request_free(r); return false; } - if (!got_thinking && model_alias_disables_thinking(r->model)) thinking_enabled = false; - if (!got_thinking && model_alias_enables_thinking(r->model)) thinking_enabled = true; - r->think_mode = ds4_think_mode_for_context( - think_mode_from_enabled(thinking_enabled, reasoning_effort), ctx_size); - chat_msgs msgs = {0}; - chat_msg sys = {0}; - sys.role = xstrdup("system"); - sys.content = xstrdup("You are a helpful assistant"); - chat_msgs_push(&msgs, sys); - chat_msg user_msg = {0}; - user_msg.role = xstrdup("user"); - user_msg.content = prompt; - prompt = NULL; - chat_msgs_push(&msgs, user_msg); - r->prompt_text = render_chat_prompt_text_for_syntax( - r->model_syntax, &msgs, NULL, NULL, r->think_mode); - ds4_tokenize_rendered_chat(e, r->prompt_text, &r->prompt); - chat_msgs_free(&msgs); - free(prompt); + r->think_mode = DS4_THINK_NONE; + if (!completion_prompt_build(e, vocab_size, &prompt, r, + err, errlen)) { + completion_prompt_free(&prompt); + request_free(r); + return false; + } + completion_prompt_free(&prompt); return true; bad: - free(prompt); + completion_prompt_free(&prompt); snprintf(err, errlen, "invalid JSON request"); request_free(r); return false; } +static bool parse_completion_request(ds4_engine *e, const char *body, int def_tokens, + int ctx_size, request *r, char *err, size_t errlen) { + return parse_completion_request_with_vocab( + e, ds4_engine_vocab_size(e), body, def_tokens, ctx_size, r, err, errlen); +} + +/* Raw completions have no injected no-thinking chat suffix. Thinking tags + * are ordinary output here; model generation stops still apply. */ +static bool request_token_is_stop(ds4_engine *e, const request *r, int token) { + return r->kind == REQ_COMPLETION ? ds4_token_is_stop(e, token) : + ds4_token_is_stop_for_think_mode(e, token, r->think_mode); +} + static long long wall_ms(void) { struct timeval tv; gettimeofday(&tv, NULL); @@ -10587,11 +10695,61 @@ static int kv_cache_try_load(server *s, server_slot *slot, const request *req, ds4_tokens *effective_prompt, char **loaded_path_out, uint8_t *loaded_ext_flags_out) { - return kv_cache_try_load_text(s, slot, req ? req->prompt_text : NULL, - effective_prompt, - loaded_path_out, - loaded_ext_flags_out, - req && req->api == API_RESPONSES); + if (!req || req->kind != REQ_COMPLETION) { + return kv_cache_try_load_text(s, slot, req ? req->prompt_text : NULL, + effective_prompt, + loaded_path_out, + loaded_ext_flags_out, + req && req->api == API_RESPONSES); + } + if (loaded_path_out) *loaded_path_out = NULL; + if (loaded_ext_flags_out) *loaded_ext_flags_out = 0; + if (effective_prompt) effective_prompt->len = 0; + /* Exact token arrays cannot use a text-keyed cache. String keys include + * the automatic model start sequence, just like stored token histories. */ + if (!s || !slot || !s->kv.enabled || !req->prompt_text) return 0; + size_t prompt_text_len = 0; + char *prompt_text = render_tokens_text(s->engine, &req->prompt, + &prompt_text_len); + int loaded = kv_cache_try_load_text(s, slot, prompt_text, effective_prompt, + loaded_path_out, + loaded_ext_flags_out, false); + if (loaded > 0) { + pthread_mutex_lock(&s->inference_mu); + const ds4_tokens *live = ds4_session_tokens(slot->session); + ds4_tokens start = {0}; + ds4_chat_begin(s->engine, &start); + bool matches = live && ds4_tokens_starts_with(live, &start); + ds4_tokens_free(&start); + size_t live_text_len = 0; + char *live_text = matches ? + render_tokens_text(s->engine, live, &live_text_len) : NULL; + matches = matches && byte_prefix_match(prompt_text, prompt_text_len, + live_text, live_text_len); + free(live_text); + /* Chat-visible cache keys can omit hidden reasoning. A raw request + * must match the actual payload, and its suffix starts after those + * actual bytes rather than after the possibly shorter visible key. */ + if (matches && effective_prompt) { + ds4_tokens_copy(effective_prompt, live); + ds4_tokenize_rendered_chat(s->engine, + prompt_text + live_text_len, + effective_prompt); + } else if (!matches) { + ds4_session_invalidate(slot->session); + if (effective_prompt) ds4_tokens_free(effective_prompt); + if (loaded_path_out) { + free(*loaded_path_out); + *loaded_path_out = NULL; + } + if (loaded_ext_flags_out) *loaded_ext_flags_out = 0; + slot->continued_last_store_tokens = 0; + loaded = 0; + } + pthread_mutex_unlock(&s->inference_mu); + } + free(prompt_text); + return loaded; } /* A text-only suffix tokenizer would turn image markers into literal text. @@ -10654,9 +10812,25 @@ static int live_text_prefix_prompt(server *s, server_slot *slot, size_t live_text_len = 0; char *live_text = render_tokens_text(s->engine, live_tokens, &live_text_len); + size_t live_text_start = 0; + if (req->kind == REQ_COMPLETION) { + ds4_tokens start = {0}; + ds4_chat_begin(s->engine, &start); + /* Raw string prompts include this start sequence. A previous exact + * token-array request may lack it, even when its visible text matches. */ + if (!ds4_tokens_starts_with(live_tokens, &start)) { + ds4_tokens_free(&start); + free(live_text); + return 0; + } + char *start_text = render_tokens_text(s->engine, &start, &live_text_start); + free(start_text); + ds4_tokens_free(&start); + } const size_t prompt_text_len = strlen(req->prompt_text); if (!byte_prefix_match(req->prompt_text, prompt_text_len, - live_text, live_text_len)) + live_text + live_text_start, + live_text_len - live_text_start)) { free(live_text); return 0; @@ -10666,8 +10840,9 @@ static int live_text_prefix_prompt(server *s, server_slot *slot, * keep its sampled tokenization and tokenize only the request bytes that * come after it. Reusing req->prompt's token suffix would be wrong: full * prompt BPE may have merged across this byte boundary. */ - bool ok = build_live_prompt_suffix(s, slot, req, req->prompt_text + live_text_len, - effective_prompt); + bool ok = build_live_prompt_suffix( + s, slot, req, req->prompt_text + live_text_len - live_text_start, + effective_prompt); free(live_text); return ok ? live_tokens->len : 0; } @@ -12808,9 +12983,7 @@ static void generate_job_inner(server *s, server_slot *slot, job *j) { snprintf(err, sizeof(err), "failed to select a non-EOS token"); break; } - if (ds4_token_is_stop_for_think_mode(s->engine, - token, - j->req.think_mode)) { + if (request_token_is_stop(s->engine, &j->req, token)) { finish = "stop"; stop_detail = "stop token"; stop_token = token; @@ -12865,9 +13038,7 @@ static void generate_job_inner(server *s, server_slot *slot, job *j) { break; } token = toks[ti]; - if (ds4_token_is_stop_for_think_mode(s->engine, - token, - j->req.think_mode)) { + if (request_token_is_stop(s->engine, &j->req, token)) { finish = "stop"; stop_detail = "stop token"; stop_token = token; @@ -18292,6 +18463,86 @@ static void test_json_skip_has_nesting_limit(void) { free(bad); } +static void test_completion_prompt_shapes(void) { + char err[160] = {0}; + completion_prompt prompt = {0}; + const char *p = "\"raw text\""; + TEST_ASSERT(parse_completion_prompt(&p, &prompt, err, sizeof(err))); + TEST_ASSERT(prompt.kind == COMPLETION_PROMPT_TEXT); + TEST_ASSERT(prompt.text && !strcmp(prompt.text, "raw text")); + TEST_ASSERT(*p == '\0'); + completion_prompt_free(&prompt); + + p = "[11, 22, 33]"; + TEST_ASSERT(parse_completion_prompt(&p, &prompt, err, sizeof(err))); + TEST_ASSERT(prompt.kind == COMPLETION_PROMPT_TOKENS); + TEST_ASSERT(prompt.tokens.len == 3); + TEST_ASSERT(prompt.tokens.len == 3 && prompt.tokens.v[0] == 11); + TEST_ASSERT(prompt.tokens.len == 3 && prompt.tokens.v[1] == 22); + TEST_ASSERT(prompt.tokens.len == 3 && prompt.tokens.v[2] == 33); + TEST_ASSERT(*p == '\0'); + completion_prompt_free(&prompt); + + const char *invalid[] = { + "null", "7", "true", "[\"a\"]", "[11,\"a\"]", "[[11,22]]", + "[11.0]", "[1e2]", "[-1]", "[01]", "[2147483648]", "[11,]", + "[11", "{\"tokens\":[11]}" + }; + for (size_t i = 0; i < sizeof(invalid) / sizeof(invalid[0]); i++) { + memset(&prompt, 0, sizeof(prompt)); + memset(err, 0, sizeof(err)); + p = invalid[i]; + TEST_ASSERT(!parse_completion_prompt(&p, &prompt, err, sizeof(err))); + TEST_ASSERT(err[0] != '\0'); + completion_prompt_free(&prompt); + } +} + +static void test_completion_token_requests_are_exact(void) { + char err[160] = {0}; + request r; + bool ok = parse_completion_request_with_vocab( + NULL, 1000, + "{\"prompt\":[11,22,33],\"max_tokens\":0,\"thinking\":true,\"think\":false,\"reasoning_effort\":\"max\",\"stream\":true}", + 128, 4096, &r, err, sizeof(err)); + TEST_ASSERT(ok); + if (ok) { + TEST_ASSERT(r.prompt.len == 3); + TEST_ASSERT(r.prompt.len == 3 && r.prompt.v[0] == 11); + TEST_ASSERT(r.prompt.len == 3 && r.prompt.v[1] == 22); + TEST_ASSERT(r.prompt.len == 3 && r.prompt.v[2] == 33); + TEST_ASSERT(r.prompt_text == NULL); + TEST_ASSERT(r.max_tokens == 0); + TEST_ASSERT(r.stream); + TEST_ASSERT(r.think_mode == DS4_THINK_NONE); + request_free(&r); + } + + ok = parse_completion_request_with_vocab( + NULL, 100, "{\"prompt\":[99],\"max_tokens\":0}", + 128, 4096, &r, err, sizeof(err)); + TEST_ASSERT(ok); + if (ok) request_free(&r); + + const char *invalid[] = { + "{}", "{\"prompt\":[]}", "{\"prompt\":[100]}", + "{\"prompt\":[11,\"a\"]}", "{\"prompt\":[[11]]}", + "{\"prompt\":null}", + "{\"prompt\":[11],\"prompt\":[12,\"bad\"]}", + "{\"prompt\":\"owned text\",\"prompt\":[12,]}", + "{\"prompt\":[11],\"think\":\"yes\"}", + "{\"prompt\":[11],\"reasoning_effort\":\"invalid\"}" + }; + for (size_t i = 0; i < sizeof(invalid) / sizeof(invalid[0]); i++) { + memset(err, 0, sizeof(err)); + ok = parse_completion_request_with_vocab( + NULL, 100, invalid[i], 128, 4096, &r, err, sizeof(err)); + TEST_ASSERT(!ok); + TEST_ASSERT(err[0] != '\0'); + if (ok) request_free(&r); + } +} + static void test_request_parsers_reject_malformed_duplicate_owned_fields(void) { const char *p = "{\"name\":\"ok\",\"name\":\"bad\\q\",\"arguments\":\"{}\"}"; @@ -20270,6 +20521,8 @@ static void ds4_server_unit_tests_run(void) { test_stop_list_parses_all_sequences(); test_stop_list_streaming_holds_and_trims_stop_text(); test_json_skip_has_nesting_limit(); + test_completion_prompt_shapes(); + test_completion_token_requests_are_exact(); test_request_parsers_reject_malformed_duplicate_owned_fields(); test_json_parser_handles_tool_heavy_requests(); test_json_string_handles_surrogates(); diff --git a/tests/ds4_test.c b/tests/ds4_test.c index adea6aeba6..82922698c9 100644 --- a/tests/ds4_test.c +++ b/tests/ds4_test.c @@ -146,6 +146,70 @@ static void test_close_engine(bool quality) { *slot = NULL; } +static bool test_tokens_match(const ds4_tokens *a, const ds4_tokens *b) { + if (!a || !b || a->len != b->len) return false; + for (int i = 0; i < a->len; i++) { + if (a->v[i] != b->v[i]) return false; + } + return true; +} + +static void test_raw_completion_prompt_tokenization(void) { + ds4_engine *engine = test_get_engine(false); + if (!engine) return; + + const char *raw = "Raw completion text remains direct."; + request r; + char err[160] = {0}; + bool ok = parse_completion_request( + engine, + "{\"prompt\":\"Raw completion text remains direct.\",\"max_tokens\":0,\"thinking\":true,\"think\":true,\"reasoning_effort\":\"max\"}", + 128, 4096, &r, err, sizeof(err)); + TEST_ASSERT(ok); + if (ok) { + ds4_tokens expected = {0}; + ds4_chat_begin(engine, &expected); + ds4_tokenize_rendered_chat(engine, raw, &expected); + TEST_ASSERT(r.prompt_text && !strcmp(r.prompt_text, raw)); + TEST_ASSERT(test_tokens_match(&r.prompt, &expected)); + TEST_ASSERT(r.max_tokens == 0); + TEST_ASSERT(r.think_mode == DS4_THINK_NONE); + ds4_tokens_free(&expected); + request_free(&r); + } + + ok = parse_completion_request( + engine, "{\"prompt\":\"\",\"max_tokens\":0}", + 128, 4096, &r, err, sizeof(err)); + TEST_ASSERT(ok); + if (ok) { + ds4_tokens start = {0}; + ds4_chat_begin(engine, &start); + TEST_ASSERT(r.prompt.len == start.len + 1); + ds4_tokens_free(&start); + request_free(&r); + } + + const int vocab_size = ds4_engine_vocab_size(engine); + char body[128]; + snprintf(body, sizeof(body), "{\"prompt\":[%d],\"max_tokens\":0}", + vocab_size - 1); + ok = parse_completion_request( + engine, body, 128, 4096, &r, err, sizeof(err)); + TEST_ASSERT(ok); + if (ok) { + TEST_ASSERT(r.prompt.len == 1 && r.prompt.v[0] == vocab_size - 1); + request_free(&r); + } + + snprintf(body, sizeof(body), "{\"prompt\":[%d],\"max_tokens\":0}", + vocab_size); + ok = parse_completion_request( + engine, body, 128, 4096, &r, err, sizeof(err)); + TEST_ASSERT(!ok); + if (ok) request_free(&r); +} + static void test_session_snapshot_roundtrip(void) { ds4_engine *engine = test_get_engine(false); if (!engine) return; @@ -6829,6 +6893,7 @@ typedef struct { static const ds4_test_entry test_entries[] = { #ifndef DS4_NO_GPU + {"--raw-completions", "raw-completions", "raw Completion string, special-token, and token-ID prompt construction", test_raw_completion_prompt_tokenization}, {"--session-snapshot", "session-snapshot", "session snapshot and recurrent-state round trip", test_session_snapshot_roundtrip}, {"--long-context", "long-context", "long-context story fact-recall regression", test_long_story_fact_recall}, {"--tool-call-quality", "tool-call-quality", "model tool call and post-result stop regression", test_tool_call_quality}, diff --git a/tests/test_raw_completions.c b/tests/test_raw_completions.c new file mode 100644 index 0000000000..1ed6adde77 --- /dev/null +++ b/tests/test_raw_completions.c @@ -0,0 +1,344 @@ +/* Exercise the real server parser and live-prefix path without model weights. + * Model-dependent operations and disk restoration are replaced; token + * containers, JSON parsing, prompt construction and reuse are production code. */ +#define ds4_engine_is_glm_dsa fixture_is_glm +#define ds4_engine_vocab_size fixture_vocab_size +#define ds4_chat_begin fixture_chat_begin +#define ds4_tokenize_rendered_chat fixture_tokenize +#define ds4_session_tokens fixture_session_tokens +#define ds4_session_rebase_vision_state fixture_rebase_vision +#define ds4_session_vision_prefix_matches fixture_vision_matches +#define ds4_kvstore_render_tokens_text fixture_render_tokens +#define ds4_session_has_vision_state fixture_has_vision +#define ds4_session_invalidate fixture_invalidate +#define ds4_kvstore_try_load_text fixture_load_text +#define ds4_token_is_stop fixture_is_stop +#define ds4_token_is_stop_for_think_mode fixture_is_stop_for_think_mode +#define DS4_SERVER_TEST +#define DS4_SERVER_TEST_NO_MAIN +#include "../ds4_server.c" + +struct ds4_engine { bool glm; }; +struct ds4_session { ds4_tokens tokens; bool vision_matches; int invalidations; }; + +enum { BOS = 256, GMASK, SOP, THINK, MERGED_AB, MERGED_ABC, END_THINK, EOS, VOCAB_SIZE }; + +bool fixture_is_glm(ds4_engine *e) { return e && e->glm; } +int fixture_vocab_size(ds4_engine *e) { return e ? VOCAB_SIZE : 0; } + +void fixture_chat_begin(ds4_engine *e, ds4_tokens *out) { + ds4_tokens_push(out, e->glm ? GMASK : BOS); + if (e->glm) ds4_tokens_push(out, SOP); +} + +static const char *fixture_token_spelling(int token) { + switch (token) { + case BOS: return "<|begin_of_sentence|>"; + case GMASK: return "[gMASK]"; + case SOP: return ""; + case THINK: return ""; + case MERGED_AB: return "ab"; + case MERGED_ABC: return "abc"; + case END_THINK: return ""; + case EOS: return ""; + default: return NULL; + } +} + +void fixture_tokenize(ds4_engine *e, const char *text, ds4_tokens *out) { + (void)e; + while (*text) { + int match = -1; + size_t match_len = 0; + for (int token = BOS; token < VOCAB_SIZE; token++) { + const char *spelling = fixture_token_spelling(token); + size_t len = strlen(spelling); + if (len > match_len && !strncmp(text, spelling, len)) { + match = token; + match_len = len; + } + } + if (match >= 0) { + ds4_tokens_push(out, match); + text += match_len; + } else { + ds4_tokens_push(out, (unsigned char)*text++); + } + } +} + +char *fixture_render_tokens(ds4_engine *e, const ds4_tokens *tokens, + size_t *out_len) { + (void)e; + buf text = {0}; + for (int i = 0; i < tokens->len; i++) { + const char *spelling = fixture_token_spelling(tokens->v[i]); + if (spelling) buf_puts(&text, spelling); + else buf_putc(&text, (char)tokens->v[i]); + } + if (out_len) *out_len = text.len; + return text.ptr ? text.ptr : xstrdup(""); +} + +const ds4_tokens *fixture_session_tokens(ds4_session *s) { return &s->tokens; } + +bool fixture_rebase_vision(const ds4_session *s, + ds4_vision_span *images, size_t count) { + (void)images; + return s->vision_matches && count == 0; +} + +bool fixture_vision_matches(const ds4_session *s, + const ds4_vision_span *images, size_t count) { + (void)images; + return s->vision_matches && count == 0; +} + +bool fixture_has_vision(const ds4_session *s) { (void)s; return false; } +void fixture_invalidate(ds4_session *s) { + s->invalidations++; + ds4_tokens_free(&s->tokens); +} + +static char *disk_lookup_text; +static int disk_lookups; + +int fixture_load_text(ds4_kvstore *kc, ds4_engine *e, ds4_session *session, + const char *text, ds4_tokens *effective, + ds4_kvstore_load_result *result, + const ds4_kvstore_trailer_hooks *hooks, + bool responses_protocol) { + (void)kc; (void)e; (void)hooks; (void)responses_protocol; + disk_lookups++; + free(disk_lookup_text); + disk_lookup_text = text ? xstrdup(text) : NULL; + /* The payload is supplied through session.tokens; the wrapper must + * validate its actual tokens, not trust the disk entry's visible key. */ + ds4_tokens_push(effective, 'z'); + result->path = xstrdup("fixture.kv"); + result->ext_flags = KV_EXT_TOOL_MAP; + return session->tokens.len; +} + +bool fixture_is_stop(ds4_engine *e, int token) { (void)e; return token == EOS; } +bool fixture_is_stop_for_think_mode(ds4_engine *e, int token, + ds4_think_mode mode) { + return fixture_is_stop(e, token) || + (mode == DS4_THINK_NONE && (token == THINK || token == END_THINK)); +} + +static void expect_tokens(const ds4_tokens *actual, const int *expected, int n) { + TEST_ASSERT(actual->len == n); + for (int i = 0; i < actual->len && i < n; i++) + TEST_ASSERT(actual->v[i] == expected[i]); +} + +static void test_raw_strings(ds4_engine *e) { + request r; + char err[160] = {0}; + const char *body = "{\"prompt\":\"ab\",\"max_tokens\":0," + "\"thinking\":true,\"think\":true,\"reasoning_effort\":\"max\"}"; + bool ok = parse_completion_request(e, body, 128, 4096, &r, err, sizeof(err)); + TEST_ASSERT(ok); + if (!ok) return; + const int deepseek[] = {BOS, MERGED_AB, THINK}; + const int glm[] = {GMASK, SOP, MERGED_AB, THINK}; + expect_tokens(&r.prompt, e->glm ? glm : deepseek, e->glm ? 4 : 3); + TEST_ASSERT(r.prompt_text && !strcmp(r.prompt_text, "ab")); + TEST_ASSERT(r.think_mode == DS4_THINK_NONE); + TEST_ASSERT(r.max_tokens == 0); + request_free(&r); + + ok = parse_completion_request(e, "{\"prompt\":\"\"}", 128, 4096, + &r, err, sizeof(err)); + TEST_ASSERT(ok); + if (ok) { + expect_tokens(&r.prompt, e->glm ? glm : deepseek, e->glm ? 2 : 1); + request_free(&r); + } +} + +static void test_exact_token_ids(ds4_engine *e) { + request r; + char err[160] = {0}; + const int expected[] = {97, MERGED_AB, THINK}; + char body[96]; + snprintf(body, sizeof(body), "{\"prompt\":[97,%d,%d],\"stream\":true}", + MERGED_AB, THINK); + bool ok = parse_completion_request(e, body, 128, 4096, &r, err, sizeof(err)); + TEST_ASSERT(ok); + if (ok) { + expect_tokens(&r.prompt, expected, 3); + TEST_ASSERT(r.prompt_text == NULL); + TEST_ASSERT(r.stream); + request_free(&r); + } + snprintf(body, sizeof(body), "{\"prompt\":[%d]}", VOCAB_SIZE); + ok = parse_completion_request(e, body, 128, 4096, &r, err, sizeof(err)); + TEST_ASSERT(!ok); + if (ok) request_free(&r); +} + +static void test_raw_live_prefix(ds4_engine *e) { + ds4_session session = {.vision_matches = true}; + server s = {.engine = e}; + server_slot slot = {.session = &session}; + request r; + char err[160] = {0}; + bool ok = parse_completion_request(e, "{\"prompt\":\"abc\"}", 128, 4096, + &r, err, sizeof(err)); + TEST_ASSERT(ok); + if (!ok) return; + /* Full prompt tokenization merges abc. Reuse must keep the cached ab + * token and tokenize only c, rather than use the full-prompt token suffix. */ + const int full_deepseek[] = {BOS, MERGED_ABC}; + const int full_glm[] = {GMASK, SOP, MERGED_ABC}; + expect_tokens(&r.prompt, e->glm ? full_glm : full_deepseek, e->glm ? 3 : 2); + fixture_chat_begin(e, &session.tokens); + ds4_tokens_push(&session.tokens, MERGED_AB); + ds4_tokens effective = {0}; + int cached = live_text_prefix_prompt(&s, &slot, &r, &effective); + TEST_ASSERT(cached == session.tokens.len); + const int reused_deepseek[] = {BOS, MERGED_AB, 'c'}; + const int reused_glm[] = {GMASK, SOP, MERGED_AB, 'c'}; + expect_tokens(&effective, e->glm ? reused_glm : reused_deepseek, + e->glm ? 4 : 3); + ds4_tokens_free(&effective); + + session.tokens.v[session.tokens.len - 1] = 'z'; + TEST_ASSERT(live_text_prefix_prompt(&s, &slot, &r, &effective) == 0); + TEST_ASSERT(effective.len == 0); + session.tokens.v[session.tokens.len - 1] = MERGED_AB; + session.vision_matches = false; + TEST_ASSERT(live_text_prefix_prompt(&s, &slot, &r, &effective) == 0); + TEST_ASSERT(effective.len == 0); + session.vision_matches = true; + + /* A prior token-array request may omit start tokens; identical text is + * insufficient to reuse that state for a string request which adds them. */ + ds4_tokens_free(&session.tokens); + ds4_tokens_push(&session.tokens, MERGED_AB); + TEST_ASSERT(live_text_prefix_prompt(&s, &slot, &r, &effective) == 0); + TEST_ASSERT(effective.len == 0); + ds4_tokens_free(&session.tokens); + ds4_tokens_free(&effective); + request_free(&r); +} + +static void test_raw_disk_prefix(ds4_engine *e) { + ds4_session session = {.vision_matches = true}; + server s = {.engine = e, .kv = {.enabled = true}}; + server_slot slot = {.session = &session}; + TEST_ASSERT(pthread_mutex_init(&s.inference_mu, NULL) == 0); + TEST_ASSERT(pthread_mutex_init(&s.kv_mu, NULL) == 0); + for (int explicit_start = 0; explicit_start < 2; explicit_start++) { + const char *start = e->glm ? "[gMASK]" : "<|begin_of_sentence|>"; + char body[160], expected_text[160]; + snprintf(body, sizeof(body), "{\"prompt\":\"%sabc\"}", + explicit_start ? start : ""); + snprintf(expected_text, sizeof(expected_text), "%s%sabc", start, + explicit_start ? start : ""); + request r; + char err[160] = {0}; + bool ok = parse_completion_request(e, body, 128, 4096, &r, err, sizeof(err)); + TEST_ASSERT(ok); + if (!ok) continue; + fixture_chat_begin(e, &session.tokens); + if (explicit_start) fixture_chat_begin(e, &session.tokens); + ds4_tokens_push(&session.tokens, MERGED_AB); + ds4_tokens expected = {0}, effective = {0}; + ds4_tokens_copy(&expected, &session.tokens); + ds4_tokens_push(&expected, 'c'); + char *path = NULL; + uint8_t flags = 0; + disk_lookups = 0; + int loaded = kv_cache_try_load(&s, &slot, &r, &effective, &path, &flags); + TEST_ASSERT(loaded == session.tokens.len); + TEST_ASSERT(disk_lookups == 1); + TEST_ASSERT(disk_lookup_text && !strcmp(disk_lookup_text, expected_text)); + expect_tokens(&effective, expected.v, expected.len); + TEST_ASSERT(path && !strcmp(path, "fixture.kv")); + TEST_ASSERT(flags == KV_EXT_TOOL_MAP); + free(path); + ds4_tokens_free(&expected); + ds4_tokens_free(&effective); + ds4_tokens_free(&session.tokens); + + if (!explicit_start) { + for (int invalid = 0; invalid < 3; invalid++) { + if (invalid == 1) { + fixture_chat_begin(e, &session.tokens); + ds4_tokens_push(&session.tokens, THINK); + } else if (invalid == 2) { + ds4_tokens_push(&session.tokens, e->glm ? BOS : GMASK); + } + ds4_tokens_push(&session.tokens, MERGED_AB); + path = NULL; + flags = 0; + slot.continued_last_store_tokens = 17; + int invalidations = session.invalidations; + loaded = kv_cache_try_load(&s, &slot, &r, &effective, &path, &flags); + TEST_ASSERT(loaded == 0); + TEST_ASSERT(session.invalidations == invalidations + 1); + TEST_ASSERT(effective.len == 0); + TEST_ASSERT(path == NULL); + TEST_ASSERT(flags == 0); + TEST_ASSERT(slot.continued_last_store_tokens == 0); + free(path); + ds4_tokens_free(&effective); + ds4_tokens_free(&session.tokens); + } + } + request_free(&r); + } + + request r; + char err[160] = {0}; + bool ok = parse_completion_request(e, "{\"prompt\":[97]}", 128, 4096, + &r, err, sizeof(err)); + TEST_ASSERT(ok); + if (ok) { + ds4_tokens effective = {0}; + disk_lookups = 0; + TEST_ASSERT(kv_cache_try_load(&s, &slot, &r, &effective, NULL, NULL) == 0); + TEST_ASSERT(disk_lookups == 0); + ds4_tokens_free(&effective); + request_free(&r); + } + free(disk_lookup_text); + disk_lookup_text = NULL; + TEST_ASSERT(pthread_mutex_destroy(&s.kv_mu) == 0); + TEST_ASSERT(pthread_mutex_destroy(&s.inference_mu) == 0); +} + +static void test_raw_stop_tokens(ds4_engine *e) { + request r = {.kind = REQ_COMPLETION, .think_mode = DS4_THINK_NONE}; + TEST_ASSERT(!request_token_is_stop(e, &r, THINK)); + TEST_ASSERT(!request_token_is_stop(e, &r, END_THINK)); + TEST_ASSERT(request_token_is_stop(e, &r, EOS)); + r.kind = REQ_CHAT; + TEST_ASSERT(request_token_is_stop(e, &r, THINK)); + TEST_ASSERT(request_token_is_stop(e, &r, END_THINK)); + TEST_ASSERT(request_token_is_stop(e, &r, EOS)); + r.think_mode = DS4_THINK_HIGH; + TEST_ASSERT(!request_token_is_stop(e, &r, THINK)); + TEST_ASSERT(!request_token_is_stop(e, &r, END_THINK)); + TEST_ASSERT(request_token_is_stop(e, &r, EOS)); +} + +int main(void) { + for (int glm = 0; glm < 2; glm++) { + ds4_engine engine = {.glm = glm != 0}; + test_raw_strings(&engine); + test_exact_token_ids(&engine); + test_raw_live_prefix(&engine); + test_raw_disk_prefix(&engine); + test_raw_stop_tokens(&engine); + } + if (test_failures) { + fprintf(stderr, "raw completion tests: %d failure(s)\n", test_failures); + return 1; + } + puts("raw completion tests passed (model-free DeepSeek/GLM fixtures)"); + return 0; +}