1616// Protocol (one JSON object per line; identical to worker_client.py):
1717// worker -> stdout, once: {"ready": true}
1818// client -> stdin, per request: {"prompt": str, "max_new_tokens": int,
19- // "temperature": float}
19+ // "temperature": float, "stop": [str, ...] }
2020// worker -> stdout, per request: {"token": str} * (streamed)
2121// {"done": true, "prompt_tokens": int,
22- // "completion_tokens": int}
22+ // "completion_tokens": int,
23+ // "finish_reason": "stop" | "length"}
2324// or {"error": str}
2425//
2526// stdout carries ONLY protocol JSON; all logs go to stderr (ET_LOG). One
@@ -74,6 +75,11 @@ void handle_request(
7475 const std::string prompt = req.at (" prompt" ).get <std::string>();
7576 int64_t max_new = req.value (" max_new_tokens" , static_cast <int64_t >(-1 ));
7677 const float temperature = req.value (" temperature" , 0 .0f );
78+ // Stop strings (the request's `stop` sequences): terminate at the token
79+ // boundary where one appears so we don't generate to EOS/max_new past it. The
80+ // control plane also enforces these as a backstop.
81+ const std::vector<std::string> stops =
82+ req.value (" stop" , std::vector<std::string>{});
7783
7884 if (session.reset () != Error::Ok) {
7985 throw std::runtime_error (" session reset failed" );
@@ -90,12 +96,21 @@ void handle_request(
9096 }
9197 const int64_t num_prompt = static_cast <int64_t >(ids.size ());
9298
93- if (max_new <= 0 ) {
94- // Fill the remaining context window when the client doesn't bound it.
95- const auto it = metadata.find (llm::kMaxContextLen );
96- max_new = (it != metadata.end ())
97- ? std::max<int64_t >(1 , it->second - num_prompt)
98- : 2048 ;
99+ // Bound generation to the context window: default to filling the remaining
100+ // room, and clamp an explicit max_new_tokens too, so decode never steps past
101+ // the window (which would error mid-generation after partial output).
102+ const auto ctx_it = metadata.find (llm::kMaxContextLen );
103+ if (ctx_it != metadata.end ()) {
104+ const int64_t room = ctx_it->second - num_prompt;
105+ if (room <= 0 ) {
106+ throw std::runtime_error (
107+ " prompt fills the context window; no room to generate" );
108+ }
109+ if (max_new <= 0 || max_new > room) {
110+ max_new = room;
111+ }
112+ } else if (max_new <= 0 ) {
113+ max_new = 2048 ;
99114 }
100115
101116 llm::SamplingConfig sampling;
@@ -104,32 +119,55 @@ void handle_request(
104119 throw std::runtime_error (" prefill failed" );
105120 }
106121
107- std::string buf; // holds bytes not yet forming a complete UTF-8 prefix
122+ std::string buf; // bytes not yet forming a complete UTF-8 prefix
123+ std::string pending; // complete-UTF-8 text held back for stop-string matching
108124 int64_t num_generated = 0 ;
125+ std::string finish = " length" ; // EOS or stop string -> "stop"
126+ bool stop_string = false ; // a request stop string was matched
109127 for (int64_t step = 0 ; step < max_new; ++step) {
110128 auto step_result = session.decode_one (sampling);
111129 if (step_result.error () != Error::Ok) {
112130 throw std::runtime_error (" decode failed" );
113131 }
114132 const auto & d = step_result.get ();
115133 if (d.is_terminal ) {
116- break ; // terminal step: not generated output
134+ finish = " stop" ;
135+ break ; // terminal step (EOS / cooperative stop): not emitted or counted
117136 }
118137 ++num_generated;
119138 buf += d.text_piece ;
120139 const size_t cut = llm::utf8_complete_prefix_len (buf);
121140 if (cut > 0 ) {
122- emit ({{ " token " , buf.substr (0 , cut)}} );
141+ pending += buf.substr (0 , cut);
123142 buf.erase (0 , cut);
124143 }
144+ bool stop_hit = false ;
145+ const size_t safe = llm::stop_safe_prefix_len (pending, stops, stop_hit);
146+ if (safe > 0 ) {
147+ emit ({{" token" , pending.substr (0 , safe)}});
148+ pending.erase (0 , safe);
149+ }
150+ if (stop_hit) {
151+ finish = " stop" ; // reached a stop string: drop it and everything after
152+ stop_string = true ;
153+ break ;
154+ }
125155 }
126- if (!buf.empty ()) {
127- emit ({{" token" , buf}}); // flush any trailing bytes (replaced if incomplete)
156+ if (!stop_string) {
157+ // EOS or length: flush held-back text + any trailing incomplete bytes
158+ // (replaced if invalid). A stop-string hit drops the remainder instead.
159+ pending += buf;
160+ if (!pending.empty ()) {
161+ emit ({{" token" , pending}});
162+ }
128163 }
164+ // finish_reason: "stop" if the model emitted EOS or hit a stop string, else
165+ // "length" — it ran to max_new (possibly clamped to the context window).
129166 emit (
130167 {{" done" , true },
131168 {" prompt_tokens" , num_prompt},
132- {" completion_tokens" , num_generated}});
169+ {" completion_tokens" , num_generated},
170+ {" finish_reason" , finish}});
133171}
134172
135173} // namespace
0 commit comments