===SYSTEM===
You are a coding agent. Apply the requested edit and re-emit the complete file in a single fenced code block. Do not explain.
===PROMPT===
I ran `read_file src/serve/serve_prompt_tokens.c` and got:

```c
/* SPDX-License-Identifier: AGPL-3.0-or-later */
/* Copyright (C) 2026 David (Corey) Herrera */
/**
 * @file serve_prompt_tokens.c
 * @brief Implementation of the ChatML prompt tokenizer and the client-re-render
 *        projection (see serve_prompt_tokens.h).
 *
 * @status Complete.
 * @issues None
 * @todo None
 */

#include "serve_prompt_tokens.h"

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include "../core/bpe_pretokenize.h"
#include "../core/chat.h"
#include "../core/engine.h"
#include "json_reader.h"
#include "tokenizer/tokenizer.h"

/* ============================================================
 * Token builder — mirrors src/core/chat.c's ChatML(+think) layout, but over a
 * full OpenAI messages[] array (multi-turn history), reusing the same
 * tokenization primitives (special-token lookup + qwen35 BPE text encode).
 * ============================================================ */

typedef struct {
    uint32_t *tokens;
    int len;
    int cap;
} token_builder;

static bool tb_init(token_builder *tb)
{
    tb->cap = 256;
    tb->len = 0;
    tb->tokens = malloc((size_t)tb->cap * sizeof(uint32_t));
    return tb->tokens != NULL;
}

static void tb_push(token_builder *tb, uint32_t id)
{
    if (tb->len >= tb->cap) {
        tb->cap *= 2;
        uint32_t *grown = realloc(tb->tokens, (size_t)tb->cap * sizeof(uint32_t));
        if (!grown) return;
        tb->tokens = grown;
    }
    tb->tokens[tb->len++] = id;
}

static void tb_push_special(token_builder *tb, ft_tokenizer *tok, const char *s)
{
    int id = ft_token_to_id(tok, s);
    if (id >= 0) tb_push(tb, (uint32_t)id);
}

/* Encode plain text with the model's tokenizer (qwen35 BPE for the mini, else
 * the SentencePiece path) and append the ids. */
static void tb_push_text(token_builder *tb, ft_tokenizer *tok, const char *text)
{
    if (!text || !*text) return;
    int n = 0;
    int *ids = (ft_get_model_type(tok) == 0)
                   ? pn_bpe_encode(tok, text, &n)
                   : ft_encode(tok, text, &n);
    if (!ids) return;
    for (int i = 0; i < n; i++)
        if (ids[i] >= 0) tb_push(tb, (uint32_t)ids[i]);
    free(ids);
}

/* The tool-call wrapper.  The vocab holds these as SINGLE special ids
 * (nex-n2-mini: "<tool_call>" is id 248058), and tb_push_text would byte-encode
 * the rendered STRING into the four ordinary ids "<","tool","_call",">".  Both
 * are legal tokenizations of the same characters, so the ONLY thing that matters
 * is that the prompt builder and the re-render projection pick the same one —
 * which they do by both going through sg_encode_assistant_content.  Only
 * assistant turns: a user/tool message that happens to contain the literal text
 * stays ordinary text. */
static const char *const TOOL_WRAPPER_MARKERS[] = { "<tool_call>", "</tool_call>" };
#define TOOL_WRAPPER_MARKER_COUNT \
    (sizeof(TOOL_WRAPPER_MARKERS) / sizeof(TOOL_WRAPPER_MARKERS[0]))

/* Encode assistant-turn content, emitting any tool-call wrapper marker as its
 * special id.  A marker the vocab does not know (id < 0) is left alone and
 * encodes as ordinary text, so a model without these tokens tokenizes
 * byte-identically to a plain tb_push_text. */
static void tb_push_assistant_content(token_builder *tb, ft_tokenizer *tok,
                                      const char *content)
{
    if (!content || !*content) return;
    const char *pos = content;
    while (*pos) {
        const char *best = NULL;
        size_t best_len = 0;
        int best_id = -1;
        for (size_t i = 0; i < TOOL_WRAPPER_MARKER_COUNT; i++) {
            int id = ft_token_to_id(tok, TOOL_WRAPPER_MARKERS[i]);
            if (id < 0) continue;
            const char *at = strstr(pos, TOOL_WRAPPER_MARKERS[i]);
            if (at && (!best || at < best)) {
                best = at;
                best_len = strlen(TOOL_WRAPPER_MARKERS[i]);
                best_id = id;
            }
        }
        if (!best) {
            tb_push_text(tb, tok, pos);
            return;
        }
        if (best > pos) {
            size_t head_len = (size_t)(best - pos);
            char *head = malloc(head_len + 1);
            if (head) {
                memcpy(head, pos, head_len);
                head[head_len] = '\0';
                tb_push_text(tb, tok, head);
                free(head);
            }
        }
        tb_push(tb, (uint32_t)best_id);
        pos = best + best_len;
    }
}

/* Emit one "<|im_start|>{role}\n{content}<|im_end|>\n" block.  An assistant
 * turn's content goes through tb_push_assistant_content so a rendered tool call
 * reproduces the ids the projection recorded (see TOOL_WRAPPER_MARKERS). */
static void tb_push_turn(token_builder *tb, ft_tokenizer *tok,
                         const char *role, const char *content)
{
    char header[64];
    snprintf(header, sizeof(header), "%s\n", role);
    tb_push_special(tb, tok, "<|im_start|>");
    tb_push_text(tb, tok, header);
    if (strcmp(role, "assistant") == 0)
        tb_push_assistant_content(tb, tok, content);
    else
        tb_push_text(tb, tok, content);
    tb_push_special(tb, tok, "<|im_end|>");
    tb_push_text(tb, tok, "\n");
}

/* Whitespace the streaming emitter left-trims off the head of each channel and
 * that a resent think block therefore cannot recover from the text alone (see
 * serve_emitter.c's em_emit).  These are the canonical Qwen/N2 separators the
 * model itself emits: "\n" right after the forced <think>, "\n\n" after the
 * "</think>" close. */
static const char THINK_LEAD_WS[] = "\n";
static const char THINK_TAIL_WS[] = "\n\n";

/* Emit the think block of a HISTORY assistant turn — "<think>{reasoning}</think>"
 * plus the separators the emitter trimmed — for a client that resent the
 * reasoning verbatim.  <think> and </think> go in as SPECIAL ids: tb_push_text
 * would byte-encode them as ordinary text and never reach id 248068.
 *
 * This is the ONE definition of that span.  Both the prompt builder (below) and
 * the reasoning-bearing resume projection (sg_encode_history_think_block) go
 * through it, for the same reason tb_push_assistant_content is shared — a
 * second implementation is free to drift, and a one-token drift costs a full
 * re-prefill. */
static void tb_push_history_think_block(token_builder *tb, ft_tokenizer *tok,
                                        const char *reasoning)
{
    tb_push_special(tb, tok, "<think>");
    tb_push_text(tb, tok, THINK_LEAD_WS);
    tb_push_text(tb, tok, reasoning);
    tb_push_special(tb, tok, "</think>");
    tb_push_text(tb, tok, THINK_TAIL_WS);
}

/* Emit one assistant turn whose reasoning the client resent verbatim:
 * "<|im_start|>assistant\n<think>{reasoning}</think>{content}<|im_end|>\n". */
static void tb_push_assistant_think_turn(token_builder *tb, ft_tokenizer *tok,
                                         const char *reasoning,
                                         const char *content)
{
    tb_push_special(tb, tok, "<|im_start|>");
    tb_push_text(tb, tok, "assistant\n");
    tb_push_history_think_block(tb, tok, reasoning);
    tb_push_assistant_content(tb, tok, content);
    tb_push_special(tb, tok, "<|im_end|>");
    tb_push_text(tb, tok, "\n");
}

/* Emit a tool-result turn per the model's chat_template: a tool message is fed
 * back inside a <tool_response> wrapper on a user turn, so multi-step tool loops
 * reconstruct the prompt shape the model was trained on. */
static void tb_push_tool_response(token_builder *tb, ft_tokenizer *tok,
                                  const char *content)
{
    tb_push_special(tb, tok, "<|im_start|>");
    tb_push_text(tb, tok, "user\n<tool_response>\n");
    tb_push_text(tb, tok, content);
    tb_push_text(tb, tok, "\n</tool_response>");
    tb_push_special(tb, tok, "<|im_end|>");
    tb_push_text(tb, tok, "\n");
}

bool sg_no_think_enabled(void)
{
    static int cached = -1;
    if (cached < 0) {
        const char *e = getenv("PANTHEON_NO_THINK");
        cached = (e && e[0] == '1') ? 1 : 0;
    }
    return cached != 0;
}

bool sg_chat_style_is_think(int chat_style)
{
    return chat_style == PN_CHAT_CHATML_THINK ||
           chat_style == PN_CHAT_CHATML_THINK_NL;
}

bool sg_thinking_suppressed(sg_thinking_mode mode)
{
    if (mode == SG_THINKING_OFF) return true;
    if (mode == SG_THINKING_ON)  return false;
    return sg_no_think_enabled();      /* SG_THINKING_DEFAULT → the env decides */
}

/* Push the forced generation prefill for the assistant turn about to be
 * generated — the tokens the model did not choose.  Returns how many. */
static int tb_push_generation_prefill(token_builder *tb, ft_tokenizer *tok,
                                      int chat_style, sg_thinking_mode thinking)
{
    int before = tb->len;
    if (sg_chat_style_is_think(chat_style) && sg_thinking_suppressed(thinking)) {
        /* No-Think: prefill a CLOSED empty think block so a hybrid
         * Think/No-Think model (MiniCPM5, saber-orch) skips reasoning and acts
         * DIRECTLY.  These four pushes are byte-for-byte the Qwen3.x template's
         * enable_thinking=false serialization ("<think>\n\n</think>\n\n"), which
         * is the only reasoning control that family's template exposes — hence a
         * BINARY switch and not a graded effort level.  Requested per-request via
         * `enable_thinking`, or process-wide via PANTHEON_NO_THINK. */
        tb_push_special(tb, tok, "<think>");
        tb_push_text(tb, tok, "\n\n");
        tb_push_special(tb, tok, "</think>");
        tb_push_text(tb, tok, "\n\n");
    } else if (chat_style == PN_CHAT_CHATML_THINK) {
        tb_push_special(tb, tok, "<think>");
    } else if (chat_style == PN_CHAT_CHATML_THINK_NL) {
        /* Bonsai-27B: the model's own template prefills '<think>\n' — the
         * newline is part of the trained prefill.  Prefilling bare <think>
         * leaves "\n\n</think>\n\n" (the template's NON-thinking serialization)
         * as the natural greedy continuation, so the model deterministically
         * closes the think block empty (Phase 3b bug). */
        tb_push_special(tb, tok, "<think>");
        tb_push_text(tb, tok, "\n");
    }
    return tb->len - before;
}

uint32_t *sg_build_prompt_tokens(struct pn_model *model,
                                 const sg_message *messages, int n_messages,
                                 sg_thinking_mode thinking,
                                 int *n_out, int *sys_len_out,
                                 int *think_prefill_out)
{
    ft_tokenizer *tok = model->tokenizer;
    token_builder tb;
    if (sys_len_out) *sys_len_out = 0;
    if (think_prefill_out) *think_prefill_out = 0;
    if (!tb_init(&tb)) return NULL;

    /* Templates that lead with {{ bos_token }} (e.g. MiniCPM5) need BOS at
     * position 0 — the model was trained with it and degenerates to whitespace
     * without it.  Detected at load (model->chat_add_bos). */
    if (model->chat_add_bos) {
        int bid = ft_bos_id(tok);
        if (bid >= 0) tb_push(&tb, (uint32_t)bid);
    }

    for (int i = 0; i < n_messages; i++) {
        const char *role = messages[i].role ? messages[i].role : "user";
        const char *content = messages[i].content ? messages[i].content : "";
        const char *reasoning = messages[i].reasoning;
        if (strcmp(role, "tool") == 0)
            tb_push_tool_response(&tb, tok, content);
        else if (reasoning && *reasoning && strcmp(role, "assistant") == 0 &&
                 sg_chat_style_is_think(model->chat_style))
            tb_push_assistant_think_turn(&tb, tok, reasoning, content);
        else
            tb_push_turn(&tb, tok, role, content);
        if (i == 0 && sys_len_out && strcmp(role, "system") == 0)
            *sys_len_out = tb.len;
    }

    tb_push_special(&tb, tok, "<|im_start|>");
    tb_push_text(&tb, tok, "assistant\n");
    int prefill_n = tb_push_generation_prefill(&tb, tok, model->chat_style,
                                               thinking);
    if (think_prefill_out) *think_prefill_out = prefill_n;
    *n_out = tb.len;
    return tb.tokens;
}

uint32_t *sg_encode_history_think_block(struct pn_model *model,
                                        const char *reasoning, int *n_out)
{
    *n_out = 0;
    if (!model || !reasoning || !reasoning[0]) return NULL;
    /* Match sg_build_prompt_tokens exactly: a non-think chat style renders an
     * assistant history turn WITHOUT a think block whatever `reasoning` says, so
     * projecting one here would predict a prompt the builder never produces. */
    if (!sg_chat_style_is_think(model->chat_style)) return NULL;
    token_builder tb;
    if (!tb_init(&tb)) return NULL;
    tb_push_history_think_block(&tb, model->tokenizer, reasoning);
    if (tb.len <= 0) {
        free(tb.tokens);
        return NULL;
    }
    *n_out = tb.len;
    return tb.tokens;
}

uint32_t *sg_encode_assistant_content(struct pn_model *model, const char *text,
                                      int *n_out)
{
    *n_out = 0;
    if (!model || !text || !text[0]) return NULL;
    token_builder tb;
    if (!tb_init(&tb)) return NULL;
    tb_push_assistant_content(&tb, model->tokenizer, text);
    if (tb.len <= 0) {
        free(tb.tokens);
        return NULL;
    }
    *n_out = tb.len;
    return tb.tokens;
}

/* ============================================================
 * Client-re-render projection
 * ============================================================ */

/* Re-render the serve's OWN parsed calls into assistant-history syntax, through
 * the very function the next request's `tool_calls` array will go through.
 *
 * The detour via JSON is deliberate and is the whole point: the calls are put
 * back into the exact OpenAI wire shape the reply is about to carry, then handed
 * to tc_render_assistant_tool_calls.  Rendering them any other way would be a
 * SECOND implementation of the history syntax, free to drift from the one that
 * actually runs next turn — and a one-token drift costs a full re-prefill.
 * Returns an owned string, or NULL on OOM / no calls. */
static char *render_own_calls_as_history(const tc_tool_call *calls, int n,
                                         bool content_empty, tc_dialect dialect)
{
    if (!calls || n <= 0) return NULL;
    json_writer jw;
    if (!jw_init(&jw)) return NULL;
    jw_raw(&jw, "[");
    for (int i = 0; i < n; i++) {
        if (i) jw_raw(&jw, ",");
        jw_raw(&jw, "{\"id\":");
        jw_string(&jw, calls[i].id ? calls[i].id : "");
        jw_raw(&jw, ",\"type\":\"function\",\"function\":{\"name\":");
        jw_string(&jw, calls[i].name ? calls[i].name : "");
        jw_raw(&jw, ",\"arguments\":");
        jw_string(&jw, calls[i].arguments ? calls[i].arguments : "{}");
        jw_raw(&jw, "}}");
    }
    jw_raw(&jw, "]");
    char *rendered = NULL;
    if (!jw.oom) {
        jr_value *dom = jr_parse(jw.data);
        rendered = tc_render_assistant_tool_calls(dom, content_empty, dialect);
        jr_free(dom);
    }
    jw_free(&jw);
    return rendered;
}

/* Concatenate the turn's content and its rendered calls exactly as
 * combine_assistant_tool_calls (openai_chat.c) will next turn.  Returns an owned
 * string, or NULL on OOM. */
static char *join_content_and_calls(const char *content, const char *calls_xml)
{
    size_t clen = content ? strlen(content) : 0;
    size_t xlen = calls_xml ? strlen(calls_xml) : 0;
    char *joined = malloc(clen + xlen + 1);
    if (!joined) return NULL;
    if (clen) memcpy(joined, content, clen);
    if (xlen) memcpy(joined + clen, calls_xml, xlen);
    joined[clen + xlen] = '\0';
    return joined;
}

uint32_t *sg_project_client_rerender(struct pn_model *model,
                                     const char *content, const char *tool_raw,
                                     const void *tools, tc_dialect dialect,
                                     int *n_out)
{
    *n_out = 0;
    if (!model) return NULL;
    if (!content) content = "";

    char *calls_xml = NULL;
    if (tool_raw && tool_raw[0]) {
        tc_tool_call *calls = NULL;
        int n_calls = 0;
        if (tc_parse_tool_calls(tool_raw, (const jr_value *)tools, dialect,
                                &calls, &n_calls) == 0 && n_calls > 0)
            calls_xml = render_own_calls_as_history(calls, n_calls,
                                                    content[0] == '\0', dialect);
        tc_tool_calls_free(calls, n_calls);
        /* A diverted block that will not parse back is a turn whose re-render we
         * cannot predict — disable the rung rather than record a guess. */
        if (!calls_xml) return NULL;
    }

    char *joined = join_content_and_calls(content, calls_xml);
    free(calls_xml);
    if (!joined) return NULL;
    uint32_t *ids = sg_encode_assistant_content(model, joined, n_out);
    free(joined);
    return ids;
}
```

Rename every occurrence of the identifier `n_tokens` to `token_count` throughout this file, and leave everything else exactly as it is. Re-emit the complete file.
