Onboarding a model to cpp_server (Dynamo)
Touchpoints at a glance
| # |
Where |
What to add |
| 0 |
ask the user |
the model's full HuggingFace id (e.g. openai/gpt-oss-120b, MiniMaxAI/MiniMax-M2.7) — required, do not guess |
| 1 |
include/config/types.hpp |
ModelType + Model enum values, MODEL_MAPPINGS (Model→HF id), modelTypeFromDeviceBackend short-name branch |
| 2 |
src/config/settings.cpp |
modelType() resolver: HF id → ModelType (else silently falls back to DeepSeek) |
| 3 |
scripts/fetch_tokenizers.sh |
download all needed files into tokenizers/<hf-id>/ |
| 4 |
src/utils/tokenizers/tokenizer.cpp |
tokenizerDirForModel, createTokenizer (defaults to DeepseekTokenizer), staticInfoFor + a StaticTokenizerInfo (eos/stop/think token ids, think-marker history flags) |
| 5 |
src/dynamo/discovery.cpp |
runtimeParsersForModelType (reasoning + tool-call parser ids), buildMdcJson (publishes generation_config.json) |
| 6 |
deploy + verify |
deploy.sh --hf-model-id <hf-id>, confirm loads / registers / answers |
Helper: python3 render_think_history.py [<hf-id>] (this directory) renders the
fetched chat templates and prints the think ids and history flags step 4 needs.
Reference: tenstorrent/tt-inference-server#4143 (and the GPT-OSS/MiniMax onboarding commits).
How the pieces connect
The worker resolves behavior from MODEL (HF id) → ModelType
(settings.cpp::modelType), which selects the tokenizer dir, the tokenizer impl
(default DeepseekTokenizer), and the static token info (eos/stop/think ids).
The Dynamo frontend, separately, reads the MDC the worker publishes in
src/dynamo/discovery.cpp to learn the tokenizer files, the generation_config.json, and
which reasoning/tool-call parsers to apply. Both halves must agree.
The recurring failure mode: the model loads but a table wasn't updated — falls
back to DeepSeek (missing modelType() entry), wrong/no reasoning + tool output
(missing runtimeParsersForModelType branch), or the frontend hard-fails to load
the model because eos_token_id is absent (model carries it only in
generation_config.json, which buildMdcJson must publish).
Checklist
Get the full HF id from the user. Everything keys off it; don't assume.
Register the model in include/config/types.hpp: add the value to both
enum class ModelType and enum class Model, add {Model::X, "<hf-id>"} to
MODEL_MAPPINGS, and add the LLM_DEVICE_BACKEND short-name branch to
modelTypeFromDeviceBackend (e.g. "gpt-oss" -> GPT_OSS_120B). Then map the HF
id in src/config/settings.cpp modelType()
(if (m == "<hf-id>") return ModelType::X;) — without it the worker silently
serves as DeepSeek.
In src/config/settings.cpp, in resolveBlazeNumberOfPipelineStages, add the case for the model
Ask the user for the number of stages, if he did not already specify this
Fetch tokenizer files in scripts/fetch_tokenizers.sh so
tokenizers/<hf-id>/ has tokenizer.json, tokenizer_config.json, config.json,
generation_config.json, chat_template.jinja if jinja files exist. Steps 4–5 read eos ids out of
config.json / generation_config.json and discovery publishes
generation_config.json, so all must be present.
Tokenizer in src/utils/tokenizers/tokenizer.cpp:
tokenizerDirForModel → the HF dir (default falls back to DeepSeek).
createTokenizer → reuse DeepseekTokenizer for chat-template/tool-call
behavior unless the model needs a dedicated impl (the default-to-deepseek rule).
- Add a
StaticTokenizerInfo (e.g. gptOss120bInfo()) and wire it into
staticInfoFor. Set, verifying ids against the fetched tokenizer:
eosTokenId = eos_token_id from config.json (the single primary id),
stopTokenIds = the remaining ids from generation_config.json eos_token_id
(often a list — e.g. gpt-oss [200002, 199999, 200012] → eos 200002, stops {199999, 200012}),
- for reasoning models,
thinkStartTokenId/thinkEndTokenId — read the
pair out of the model's own chat template, do not copy a sibling's
(MiniMax-M3 reasons in <mm:think>/</mm:think> while M2.7 uses
<think>/</think>, and both pairs exist in M3's vocab, so the wrong
ids parse fine and silently never match). Harmony-style models (gpt-oss)
have no think tokens: leave the ids unset, because <|channel|> there
also opens the final channel and would trip the marker state machine
on ordinary answers.
thinkStartInHistory / thinkEndInHistory — see below.
Think-marker history flags. Every delimiter occupies a KV row, but the
prefix cache reconstructs "first free KV row" as
matched_tokens + accumulatedThinkTokens, where matched_tokens counts only
HASHED tokens. So each delimiter row must be counted — unless the chat
template re-renders that delimiter into later turns' prompts, in which case
the next prompt supplies the row itself and counting it again shifts the
turn. Get it wrong and nothing fails loudly: the conversation drifts a row or
two per turn and long multi-turn sessions degrade into nonsense.
Derive the values, don't guess — run from this directory:
python3 render_think_history.py <hf-id> # omit the id to sweep every model
It renders a two-turn conversation and reports, per delimiter, whether the
past assistant turn still contains it. Use the reasoning NOT echoed
block (clients rarely send reasoning_content back) and set the flags for
the pair you configured as thinkStartTokenId/thinkEndTokenId — the script
lists every think-like special token, and only that pair matters. The three
shapes seen so far:
| Template renders a past think block as |
Flags |
| nothing (DeepSeek-R1, Gemma-4, MiniMax-M2.7) |
false, false |
| a bare closing tag (GLM-5.1, MiniMax-M3) |
false, true |
empty <think></think> (Kimi, GLM-5.2) |
true, true |
Known limitations — the flags are static, but the rendering they describe is
not, so they are only right for the Dynamo path with a non-echoing client:
- Two renderers, one flag. These values describe the model's own
chat_template.jinja, which is what the Dynamo frontend applies before
sending token_ids. The local Drogon route (/v1/chat/completions →
ChatCompletionRequest::toLLMRequest) instead renders through
DeepseekTokenizer::applyChatTemplate, which every ModelType currently
shares and which emits a past assistant turn as <|Assistant|>content —
no delimiters, for any model. So on that path the true flags are always
false, false, and any model registered otherwise (Kimi, GLM-5.1/5.2,
MiniMax-M3) undercounts 1–2 KV rows per turn there. Fixing it properly
means giving those models real templates rather than adjusting the flags.
- Echoed reasoning. A client that DOES send
reasoning_content back
changes the rendering again (the script prints that case too); the
accounting is then off by the reasoning length for that request.
Pin whatever you choose in ConversationHasherThinkRows
(tests/unit/model/conversation_hasher_test.cpp).
Dynamo discovery in src/dynamo/discovery.cpp:
runtimeParsersForModelType → return {reasoning_parser, tool_call_parser}
id strings for the model — these tell the frontend which parsers to apply
(e.g. gpt_oss → {"gpt_oss","harmony"}, minimax_m2 → {"minimax_append_think","minimax_m2"};
DeepSeek default {"deepseek_r1", nullptr}).
buildMdcJson already publishes generation_config.json when present (so
models like MiniMax that omit eos_token_id from config.json still load).
Nothing to change unless the model needs extra MDC fields — just confirm the
file is fetched (step 3).
Deploy & verify (see run-dynamo-server). deploy.sh serves any model via
--hf-model-id <hf-id> — no script change needed. Then:
curl -s "http://dynamo-frontend:8000/v1/models"
curl -s "http://dynamo-frontend:8000/v1/chat/completions" -H 'Content-Type: application/json' \
-d '{"model":"<hf-id>","messages":[{"role":"user","content":"hi"}],"max_tokens":16}'
Confirm: docker logs tt-cpp-worker shows the new tokenizer loaded and the worker
registered with etcd; a reasoning model reports reasoning_tokens in the final-chunk
usage; tool-call output parses (the runtimeParsersForModelType ids are correct);
and the frontend didn't reject the model for a missing eos_token_id.
1---2name: add-model-dynamo3description: Checklist for onboarding a new LLM to the cpp_server inference backend so it serves through Dynamo — registering the model type, fetching tokenizer files, tokenizer static data (eos/stop/think tokens), and Dynamo discovery (reasoning + tool-call parsers, generation_config publishing). Use whenever a new model is being onboarded to cpp_server, a HuggingFace model is being wired into the Dynamo deploy, or a model "loads but generates wrong / isn't discoverable / isn't selectable".4---56# Onboarding a model to cpp_server (Dynamo)78## Touchpoints at a glance910| # | Where | What to add |11|---|-------|-------------|12| 0 | **ask the user** | the model's **full HuggingFace id** (e.g. `openai/gpt-oss-120b`, `MiniMaxAI/MiniMax-M2.7`) — required, do not guess |13| 1 | `include/config/types.hpp` | `ModelType` + `Model` enum values, `MODEL_MAPPINGS` (`Model`→HF id), `modelTypeFromDeviceBackend` short-name branch |14| 2 | `src/config/settings.cpp` | `modelType()` resolver: HF id → `ModelType` (else silently falls back to DeepSeek) |15| 3 | `scripts/fetch_tokenizers.sh` | download **all** needed files into `tokenizers/<hf-id>/` |16| 4 | `src/utils/tokenizers/tokenizer.cpp` | `tokenizerDirForModel`, `createTokenizer` (defaults to `DeepseekTokenizer`), `staticInfoFor` + a `StaticTokenizerInfo` (eos/stop/think token ids, **think-marker history flags**) |17| 5 | `src/dynamo/discovery.cpp` | `runtimeParsersForModelType` (reasoning + tool-call parser ids), `buildMdcJson` (publishes `generation_config.json`) |18| 6 | deploy + verify | `deploy.sh --hf-model-id <hf-id>`, confirm loads / registers / answers |1920Helper: `python3 render_think_history.py [<hf-id>]` (this directory) renders the21fetched chat templates and prints the think ids and history flags step 4 needs.2223Reference: [tenstorrent/tt-inference-server#4143](https://github.com/tenstorrent/tt-inference-server/pull/4143) (and the GPT-OSS/MiniMax onboarding commits).2425## How the pieces connect2627The worker resolves behavior from `MODEL` (HF id) → `ModelType`28(`settings.cpp::modelType`), which selects the tokenizer dir, the tokenizer impl29(default `DeepseekTokenizer`), and the **static token info** (eos/stop/think ids).30The Dynamo frontend, separately, reads the **MDC** the worker publishes in31`src/dynamo/discovery.cpp` to learn the tokenizer files, the `generation_config.json`, and32which **reasoning/tool-call parsers** to apply. Both halves must agree.3334The recurring failure mode: the model loads but a table wasn't updated — falls35back to DeepSeek (missing `modelType()` entry), wrong/no reasoning + tool output36(missing `runtimeParsersForModelType` branch), or the frontend hard-fails to load37the model because `eos_token_id` is absent (model carries it only in38`generation_config.json`, which `buildMdcJson` must publish).3940## Checklist41421. **Get the full HF id from the user.** Everything keys off it; don't assume.43442. **Register the model** in `include/config/types.hpp`: add the value to both45 `enum class ModelType` and `enum class Model`, add `{Model::X, "<hf-id>"}` to46 `MODEL_MAPPINGS`, and add the `LLM_DEVICE_BACKEND` short-name branch to47 `modelTypeFromDeviceBackend` (e.g. `"gpt-oss" -> GPT_OSS_120B`). Then map the HF48 id in `src/config/settings.cpp` `modelType()`49 (`if (m == "<hf-id>") return ModelType::X;`) — without it the worker silently50 serves as DeepSeek.51 In src/config/settings.cpp, in resolveBlazeNumberOfPipelineStages, add the case for the model52 Ask the user for the number of stages, if he did not already specify this53543. **Fetch tokenizer files** in `scripts/fetch_tokenizers.sh` so55 `tokenizers/<hf-id>/` has **tokenizer.json, tokenizer_config.json, config.json,56 generation_config.json, chat_template.jinja** if jinja files exist. Steps 4–5 read eos ids out of57 `config.json` / `generation_config.json` and discovery publishes58 `generation_config.json`, so all must be present.59604. **Tokenizer** in `src/utils/tokenizers/tokenizer.cpp`:61 - `tokenizerDirForModel` → the HF dir (default falls back to DeepSeek).62 - `createTokenizer` → reuse `DeepseekTokenizer` for chat-template/tool-call63 behavior unless the model needs a dedicated impl (the **default-to-deepseek** rule).64 - Add a `StaticTokenizerInfo` (e.g. `gptOss120bInfo()`) and wire it into65 `staticInfoFor`. Set, **verifying ids against the fetched tokenizer**:66 - `eosTokenId` = `eos_token_id` from **config.json** (the single primary id),67 - `stopTokenIds` = the remaining ids from **generation_config.json** `eos_token_id`68 (often a list — e.g. gpt-oss `[200002, 199999, 200012]` → eos `200002`, stops `{199999, 200012}`),69 - for reasoning models, `thinkStartTokenId`/`thinkEndTokenId` — **read the70 pair out of the model's own chat template, do not copy a sibling's**71 (MiniMax-M3 reasons in `<mm:think>`/`</mm:think>` while M2.7 uses72 `<think>`/`</think>`, and both pairs exist in M3's vocab, so the wrong73 ids parse fine and silently never match). Harmony-style models (gpt-oss)74 have no think tokens: leave the ids unset, because `<|channel|>` there75 also opens the `final` channel and would trip the marker state machine76 on ordinary answers.77 - `thinkStartInHistory` / `thinkEndInHistory` — see below.7879 **Think-marker history flags.** Every delimiter occupies a KV row, but the80 prefix cache reconstructs "first free KV row" as81 `matched_tokens + accumulatedThinkTokens`, where `matched_tokens` counts only82 HASHED tokens. So each delimiter row must be counted — unless the chat83 template re-renders that delimiter into later turns' prompts, in which case84 the next prompt supplies the row itself and counting it again shifts the85 turn. Get it wrong and nothing fails loudly: the conversation drifts a row or86 two per turn and long multi-turn sessions degrade into nonsense.8788 Derive the values, don't guess — run from this directory:8990```bash91python3 render_think_history.py <hf-id> # omit the id to sweep every model92```9394 It renders a two-turn conversation and reports, per delimiter, whether the95 **past** assistant turn still contains it. Use the `reasoning NOT echoed`96 block (clients rarely send `reasoning_content` back) and set the flags for97 the pair you configured as `thinkStartTokenId`/`thinkEndTokenId` — the script98 lists every think-like special token, and only that pair matters. The three99 shapes seen so far:100101 | Template renders a past think block as | Flags |102 |---|---|103 | nothing (DeepSeek-R1, Gemma-4, MiniMax-M2.7) | `false, false` |104 | a bare closing tag (GLM-5.1, MiniMax-M3) | `false, true` |105 | empty `<think></think>` (Kimi, GLM-5.2) | `true, true` |106107 Known limitations — the flags are static, but the rendering they describe is108 not, so they are only right for the Dynamo path with a non-echoing client:109110 - **Two renderers, one flag.** These values describe the model's own111 `chat_template.jinja`, which is what the Dynamo frontend applies before112 sending `token_ids`. The local Drogon route (`/v1/chat/completions` →113 `ChatCompletionRequest::toLLMRequest`) instead renders through114 `DeepseekTokenizer::applyChatTemplate`, which every `ModelType` currently115 shares and which emits a past assistant turn as `<|Assistant|>content` —116 no delimiters, for any model. So on that path the true flags are always117 `false, false`, and any model registered otherwise (Kimi, GLM-5.1/5.2,118 MiniMax-M3) undercounts 1–2 KV rows per turn there. Fixing it properly119 means giving those models real templates rather than adjusting the flags.120 - **Echoed reasoning.** A client that DOES send `reasoning_content` back121 changes the rendering again (the script prints that case too); the122 accounting is then off by the reasoning length for that request.123124 Pin whatever you choose in `ConversationHasherThinkRows`125 (`tests/unit/model/conversation_hasher_test.cpp`).1261275. **Dynamo discovery** in `src/dynamo/discovery.cpp`:128 - `runtimeParsersForModelType` → return `{reasoning_parser, tool_call_parser}`129 id strings for the model — these tell the frontend which parsers to apply130 (e.g. `gpt_oss → {"gpt_oss","harmony"}`, `minimax_m2 → {"minimax_append_think","minimax_m2"}`;131 DeepSeek default `{"deepseek_r1", nullptr}`).132 - `buildMdcJson` already publishes `generation_config.json` when present (so133 models like MiniMax that omit `eos_token_id` from `config.json` still load).134 Nothing to change unless the model needs extra MDC fields — just confirm the135 file is fetched (step 3).1361376. **Deploy & verify** (see `run-dynamo-server`). `deploy.sh` serves any model via138 `--hf-model-id <hf-id>` — no script change needed. Then:139140```bash141curl -s "http://dynamo-frontend:8000/v1/models"142curl -s "http://dynamo-frontend:8000/v1/chat/completions" -H 'Content-Type: application/json' \143 -d '{"model":"<hf-id>","messages":[{"role":"user","content":"hi"}],"max_tokens":16}'144```145146Confirm: `docker logs tt-cpp-worker` shows the new tokenizer loaded and the worker147registered with etcd; a reasoning model reports `reasoning_tokens` in the final-chunk148usage; tool-call output parses (the `runtimeParsersForModelType` ids are correct);149and the frontend didn't reject the model for a missing `eos_token_id`.