harness_gateway_client/wire/
stream.rs1use std::collections::BTreeMap;
23
24use promptforge::metrics::{CallMetrics, ClientTiming};
25use promptforge::model::{Completion, CompletionError, RawExchange};
26use serde_json::{Map, Value};
27
28use super::classify::classify_stream_error;
29use super::delta::StreamDelta;
30use super::parse::{normalize, response_metadata};
31use crate::failure::malformed;
32
33#[derive(Debug, Default)]
41pub(super) struct SseScanner {
42 buffer: Vec<u8>,
43 scanned: usize,
45}
46
47impl SseScanner {
48 #[must_use]
50 pub(super) fn new() -> SseScanner {
51 SseScanner {
52 buffer: Vec::new(),
53 scanned: 0,
54 }
55 }
56
57 pub(super) fn extend(&mut self, bytes: &[u8]) {
59 self.buffer.extend_from_slice(bytes);
60 }
61
62 pub(super) fn next_data(&mut self) -> Option<String> {
65 loop {
66 let Some(offset) = self.buffer[self.scanned..]
67 .iter()
68 .position(|byte| *byte == b'\n')
69 else {
70 self.scanned = self.buffer.len();
71 return None;
72 };
73 let end = self.scanned + offset;
74 self.scanned = 0;
75 let line: Vec<u8> = self.buffer.drain(..=end).collect();
76 let line = String::from_utf8_lossy(&line);
77 let line = line.trim_end_matches(['\r', '\n']);
78 if line.is_empty() || line.starts_with(':') {
79 continue;
80 }
81 let Some(data) = line.strip_prefix("data:") else {
82 continue;
83 };
84 return Some(data.trim_start().to_owned());
85 }
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub(super) enum Applied {
92 Chunk {
96 delta: bool,
98 },
99 Done,
101}
102
103#[derive(Debug, Default)]
107struct ToolCallParts {
108 id: String,
109 name: String,
110 arguments: String,
111}
112
113#[derive(Debug, Default)]
125pub(super) struct StreamAccumulator {
126 content: Option<String>,
128 reasoning: Option<String>,
130 tool_calls: BTreeMap<u64, ToolCallParts>,
131 finish_reason: Option<String>,
132 model: Option<String>,
133 sections: Map<String, Value>,
135}
136
137impl StreamAccumulator {
138 #[must_use]
140 pub(super) fn new() -> StreamAccumulator {
141 StreamAccumulator::default()
142 }
143
144 pub(super) fn apply(
154 &mut self,
155 data: &str,
156 on_delta: &impl Fn(StreamDelta),
157 ) -> Result<Applied, CompletionError> {
158 if data == "[DONE]" {
159 return Ok(Applied::Done);
160 }
161 let chunk: Value = serde_json::from_str(data)
162 .map_err(|error| malformed("stream chunk was not valid JSON").with_source(error))?;
163 if let Some(envelope) = chunk.get("error").filter(|error| !error.is_null()) {
168 let message = envelope
169 .get("message")
170 .and_then(Value::as_str)
171 .unwrap_or("stream error envelope omitted its message");
172 return Err(classify_stream_error(&escape_controls(message, 2000)));
173 }
174 if let Some(Value::String(model)) = chunk.get("model")
175 && !model.is_empty()
176 {
177 self.model = Some(model.clone());
178 }
179 for key in ["usage", "timings", "metrics"] {
180 if let Some(section) = chunk.get(key)
181 && !section.is_null()
182 {
183 self.sections.insert(key.to_owned(), section.clone());
184 }
185 }
186 let choices = match chunk.get("choices") {
189 None | Some(Value::Null) => return Ok(Applied::Chunk { delta: false }),
190 Some(Value::Array(choices)) => choices,
191 Some(_) => {
192 return Err(malformed(
193 "stream chunk `choices` was present but not an array",
194 ));
195 }
196 };
197 let mut held_delta = false;
198 for choice in choices {
199 if self.apply_choice(choice, on_delta)? {
200 held_delta = true;
201 }
202 }
203 Ok(Applied::Chunk { delta: held_delta })
204 }
205
206 fn apply_choice(
208 &mut self,
209 choice: &Value,
210 on_delta: &impl Fn(StreamDelta),
211 ) -> Result<bool, CompletionError> {
212 let Some(index) = choice.get("index").and_then(Value::as_u64) else {
213 return Err(malformed("stream choice had no integer index"));
214 };
215 if index != 0 {
217 return Ok(false);
218 }
219 match choice.get("finish_reason") {
220 None | Some(Value::Null) => {}
221 Some(Value::String(reason)) => self.finish_reason = Some(reason.clone()),
222 Some(_) => {
223 return Err(malformed(
224 "stream choice `finish_reason` was present but not a string",
225 ));
226 }
227 }
228 let delta = match choice.get("delta") {
229 None | Some(Value::Null) => return Ok(false),
231 Some(delta @ Value::Object(_)) => delta,
232 Some(_) => {
233 return Err(malformed(
234 "stream choice `delta` was present but not an object",
235 ));
236 }
237 };
238 let mut held = false;
239 if let Some(text) = append_string_fragment(delta, "content", &mut self.content, "content")?
240 && !text.is_empty()
241 {
242 held = true;
243 on_delta(StreamDelta::Text(text));
244 }
245 for key in ["reasoning_content", "reasoning", "thinking"] {
246 if let Some(text) = append_string_fragment(delta, key, &mut self.reasoning, key)?
247 && !text.is_empty()
248 {
249 held = true;
250 on_delta(StreamDelta::Reasoning(text));
251 }
252 }
253 match delta.get("tool_calls") {
254 None | Some(Value::Null) => {}
255 Some(Value::Array(fragments)) => {
256 for fragment in fragments {
257 self.apply_tool_fragment(fragment)?;
258 }
259 if !fragments.is_empty() {
260 held = true;
261 }
262 }
263 Some(_) => {
264 return Err(malformed(
265 "stream delta `tool_calls` was present but not an array",
266 ));
267 }
268 }
269 Ok(held)
270 }
271
272 fn apply_tool_fragment(&mut self, fragment: &Value) -> Result<(), CompletionError> {
274 let Some(index) = fragment.get("index").and_then(Value::as_u64) else {
275 return Err(malformed("stream tool-call fragment had no integer index"));
276 };
277 let parts = self.tool_calls.entry(index).or_default();
278 match fragment.get("id") {
279 None | Some(Value::Null) => {}
280 Some(Value::String(id)) => parts.id.push_str(id),
281 Some(_) => {
282 return Err(malformed("stream tool-call fragment `id` was not a string"));
283 }
284 }
285 let function = match fragment.get("function") {
286 None | Some(Value::Null) => return Ok(()),
287 Some(function @ Value::Object(_)) => function,
288 Some(_) => {
289 return Err(malformed(
290 "stream tool-call fragment `function` was not an object",
291 ));
292 }
293 };
294 for (key, slot) in [
295 ("name", &mut parts.name),
296 ("arguments", &mut parts.arguments),
297 ] {
298 match function.get(key) {
299 None | Some(Value::Null) => {}
300 Some(Value::String(piece)) => slot.push_str(piece),
301 Some(_) => {
302 return Err(malformed(format!(
303 "stream tool-call fragment `{key}` was not a string"
304 )));
305 }
306 }
307 }
308 Ok(())
309 }
310
311 pub(super) fn finish(
330 self,
331 request_body: Value,
332 client_timing: Option<ClientTiming>,
333 ) -> Result<Completion, CompletionError> {
334 if !self.tool_calls.is_empty()
338 && matches!(
339 self.finish_reason.as_deref(),
340 Some("length" | "content_filter")
341 )
342 {
343 let reason = self.finish_reason.unwrap_or_default();
344 return Err(malformed(format!(
345 "tool-call batch truncated by finish_reason {reason:?}: \
346 partial arguments must not execute"
347 )));
348 }
349 let response_body = self.into_body();
350 let turn = normalize(&response_body)?;
351 let metadata = response_metadata(&response_body);
352 let metrics = CallMetrics {
353 client: client_timing,
354 ..metadata.metrics
355 };
356 let measured = metrics.usage.is_some()
357 || metrics.llama.is_some()
358 || metrics.vllm.is_some()
359 || metrics.client.is_some();
360 let mut completion = Completion::from_result(turn.outcome, metadata.model)?
361 .with_metadata_diagnostics(metadata.diagnostics);
362 if let Some(Value::String(model)) = request_body.get("model") {
363 completion = completion.with_model(model.clone());
364 }
365 if let Some(reason) = turn.finish_reason {
366 completion = completion.with_finish_reason(reason);
367 }
368 if let Some(reasoning) = turn.reasoning_content {
369 completion = completion.with_reasoning_content(reasoning);
370 }
371 if measured {
372 completion = completion.with_metrics(metrics);
373 }
374 Ok(completion.with_raw(RawExchange::new(request_body, response_body)))
375 }
376
377 fn into_body(self) -> Value {
381 let mut message = Map::new();
382 message.insert("role".to_owned(), Value::String("assistant".to_owned()));
383 message.insert(
384 "content".to_owned(),
385 match self.content {
386 Some(text) => Value::String(text),
387 None => Value::Null,
388 },
389 );
390 if let Some(reasoning) = self.reasoning.filter(|text| !text.is_empty()) {
391 message.insert("reasoning_content".to_owned(), Value::String(reasoning));
392 }
393 if !self.tool_calls.is_empty() {
394 let calls: Vec<Value> = self
395 .tool_calls
396 .into_values()
397 .map(|parts| {
398 serde_json::json!({
399 "id": parts.id,
400 "type": "function",
401 "function": { "name": parts.name, "arguments": parts.arguments },
402 })
403 })
404 .collect();
405 message.insert("tool_calls".to_owned(), Value::Array(calls));
406 }
407 let mut choice = Map::new();
408 choice.insert("index".to_owned(), Value::from(0));
409 choice.insert("message".to_owned(), Value::Object(message));
410 if let Some(reason) = self.finish_reason {
411 choice.insert("finish_reason".to_owned(), Value::String(reason));
412 }
413 let mut body = Map::new();
414 if let Some(model) = self.model {
415 body.insert("model".to_owned(), Value::String(model));
416 }
417 body.insert(
418 "choices".to_owned(),
419 Value::Array(vec![Value::Object(choice)]),
420 );
421 for (key, value) in self.sections {
422 body.insert(key, value);
423 }
424 Value::Object(body)
425 }
426}
427
428fn append_string_fragment(
434 delta: &Value,
435 key: &str,
436 slot: &mut Option<String>,
437 label: &str,
438) -> Result<Option<String>, CompletionError> {
439 match delta.get(key) {
440 None | Some(Value::Null) => Ok(None),
441 Some(Value::String(text)) => {
442 slot.get_or_insert_with(String::new).push_str(text);
443 Ok(Some(text.clone()))
444 }
445 Some(_) => Err(malformed(format!(
446 "stream delta `{label}` was present but not a string"
447 ))),
448 }
449}
450
451#[must_use]
468pub fn escape_controls(body: &str, max: usize) -> String {
469 if body.is_empty() {
470 return "(empty body)".to_owned();
471 }
472 let mut escaped = String::with_capacity(body.len());
473 for ch in body.chars().take(max) {
474 if ch.is_control() {
475 for part in ch.escape_default() {
476 escaped.push(part);
477 }
478 } else {
479 escaped.push(ch);
480 }
481 }
482 escaped
483}
484
485#[cfg(test)]
486#[path = "stream-tests.rs"]
487mod tests;
488
489#[cfg(test)]
490#[path = "stream-metrics-tests.rs"]
491mod metrics_tests;