Add your own node to a model pipeline
Your application needs logic the model does not have: redaction, templating, routing, scoring, any transform of what goes in or comes out. In Modelverse that logic is not a wrapper around the pipeline; it is a node inside it. The zoo's serving nodes and your code meet on one contract, ClikaRT::runtime::Model, and ClikaRT::runtime::Pipeline::create composes any mix of them into one pipeline with one request/response surface.
A node implements three members: schema() declares its input and output tensors by name, phases() declares its execution phases, and Phase_RunOnce does the work. The zoo's tokenizer, generative decoder and detokenizer implement exactly the same three, which is why yours can stand beside them without an adapter layer.
The program
Same two-file project as part 4 of the tutorial; only main.cpp changes. The custom node here uppercases the reply, standing in for any post-processing you own:
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <string>
#include <utility>
#include <vector>
#include <ClikaRT/clika_rt.h>
#include "clika_modelverse/generation/generation_config.h"
#include "clika_modelverse/hub/hub.h"
#include "clika_modelverse/registry/model_registry.h"
#include "clika_modelverse/runtime/decoder_node.h"
#include "clika_modelverse/runtime/pipeline_io.h"
#include "clika_modelverse/runtime/text_nodes.h"
namespace mv = clika_modelverse;
namespace mvr = clika_modelverse::runtime;
namespace crt = ClikaRT::runtime;
using ClikaRT::DataType;
// The detokenizer's reply routes here under this name instead of going
// straight out.
constexpr const char* kDraftText = "draft_text";
// Everything a user writes: declare I/O, declare a phase, do the work.
class ShoutNode final : public crt::Model {
public:
ShoutNode() {
schema_.inputs.push_back(ClikaRT::spec::TensorSpec{
kDraftText, DataType::UInt8, {ClikaRT::spec::TensorSpec::kDynamicDim},
/*optional=*/false});
schema_.outputs.push_back(ClikaRT::spec::TensorSpec{
mvr::kText, DataType::UInt8, {ClikaRT::spec::TensorSpec::kDynamicDim},
/*optional=*/false});
phases_.push_back(crt::PhaseSpec{"shout", crt::PhaseKind::RunOnce});
}
const crt::ModelSchema& schema() const override { return schema_; }
ClikaRT::Span<const crt::PhaseSpec> phases() const override { return phases_; }
void Phase_RunOnce(const crt::PhaseSpec&, crt::PhaseContext& ctx) override {
std::string text = mvr::byte_tensor_to_string(ctx.inputs->get(kDraftText));
std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c) {
return static_cast<char>(std::toupper(c));
});
ctx.outputs->set(mvr::kText, mvr::string_to_byte_tensor(text));
}
private:
crt::ModelSchema schema_;
std::vector<crt::PhaseSpec> phases_;
};
int main() {
// The zoo half, exactly as in the tutorial: snapshot, registry, model.
mv::hub::SnapshotOptions snap;
snap.cache_dir = "models";
const mv::hub::SnapshotResult snapped = mv::hub::snapshot(
"meta-llama/Llama-3.2-1B-Instruct", snap);
mv::LoadOptions load;
load.max_seq = 4096; // the CLI's default context cap, as in part 4
mv::GenerativeModel model =
mv::ModelRegistry::builtin().load_generative(snapped.local_dir, load);
mv::generation::GenerationConfig gen = model.defaults();
gen.max_new_tokens = 24;
gen.stop = {"."}; // raw completion: one sentence is the demo
// The zoo's three serving nodes, constructed directly.
mvr::TokenizerNode tok(model.tokenizer(), model.device(),
/*add_special_tokens=*/false);
mvr::GenerativeDecoderModel dec(model.provider(), gen, /*max_active=*/1,
mv::KVCacheMode::Continuous,
&model.tokenizer(),
/*prefill_chunk_tokens=*/0);
mvr::DetokenizerNode detok(model.tokenizer());
ShoutNode shout; // yours
// One pipeline over all four. Edges wire by name; the one rename (the
// detokenizer's `text` becomes `draft_text`) routes the reply through
// the custom node instead of straight out.
std::vector<crt::PipelineStep> steps(4);
steps[0].name = "tokenize";
steps[0].model = &tok;
steps[1].name = "decode";
steps[1].model = &dec;
steps[2].name = "detokenize";
steps[2].model = &detok;
steps[2].output_map.emplace_back(mvr::kText, kDraftText);
steps[3].name = "shout";
steps[3].model = &shout;
// The pipeline's public request surface: what callers set and read.
crt::ModelSchema ext;
ext.inputs.push_back(ClikaRT::spec::TensorSpec{
mvr::kPrompt, DataType::UInt8, {ClikaRT::spec::TensorSpec::kDynamicDim},
/*optional=*/false});
ext.outputs.push_back(ClikaRT::spec::TensorSpec{
mvr::kText, DataType::UInt8, {ClikaRT::spec::TensorSpec::kDynamicDim},
/*optional=*/false});
crt::Pipeline pipeline = crt::Pipeline::create(std::move(steps), std::move(ext));
// The same Request/Response surface every pipeline speaks.
crt::Request req;
req.inputs.set(mvr::kPrompt, mvr::string_to_byte_tensor(
"Once upon a time, in a port town by a cold sea,"));
const crt::SessionId sid = pipeline.enqueue(std::move(req));
const crt::Response resp = pipeline.await(sid);
std::printf("%s\n", mvr::byte_tensor_to_string(resp.outputs.get(mvr::kText)).c_str());
pipeline.shutdown();
return 0;
}
THERE LIVED A YOUNG GIRL NAMED KAITO.
One build flag matters here: the custom node derives from the serving runtime's Model, so its translation unit compiles with -fno-rtti, matching how the library builds; without it the link fails on typeinfo for ClikaRT::runtime::Model. The ClikaRT integration guide covers the flag and which consumers need it.
What the composition rests on
- Edges wire by tensor name. Each step's outputs feed the next step's same-named inputs;
output_maprenames one edge where names must differ. The single rename above is the whole routing change: without it the detokenizer'stextwould leave the pipeline directly. - The external schema is the caller's contract. Only what it declares is settable and readable from outside; everything between the nodes stays internal. Requests and responses are exactly the ones part 4 used, so a caller cannot tell a customized pipeline from a stock one.
- Placement in the chain is yours. A node before the tokenizer transforms the prompt (templating, redaction); a node after the detokenizer transforms the reply, as here; a fully custom model runs beside a zoo model in the same pipeline.
The 02_custom_node program in Additional examples is this guide's offline twin: it synthesizes a toy checkpoint at run time, so the composition runs in moments with no download and no arguments. When HTTP is the goal, mount an engine on the built-in server instead (Serve an OpenAI-compatible endpoint); a pipeline node changes what a model computes, an engine changes what the server serves. And when the model itself is yours rather than the catalog's, that is the other half of the custom track: the Adding your own model tutorial series, with Register your own model family as its full contract.