agent_engine/runtime/openai/
wire.rs1use super::types::{FunctionCall, OaiEvent, ToolCall};
5use serde::Deserialize;
6use std::collections::HashMap;
7
8#[derive(Debug, Deserialize)]
11pub struct RawChunk {
12 #[serde(default)]
13 pub choices: Vec<RawChoice>,
14 #[serde(default)]
15 pub usage: Option<RawUsage>,
16}
17
18#[derive(Debug, Deserialize)]
19pub struct RawChoice {
20 #[serde(default)]
21 pub delta: Option<RawDelta>,
22 #[serde(default)]
23 pub finish_reason: Option<String>,
24}
25
26#[derive(Debug, Deserialize, Default)]
27pub struct RawDelta {
28 #[serde(default)]
29 pub role: Option<String>,
30 #[serde(default)]
31 pub content: Option<String>,
32 #[serde(default)]
33 pub tool_calls: Option<Vec<RawToolCallDelta>>,
34}
35
36#[derive(Debug, Deserialize)]
37pub struct RawToolCallDelta {
38 #[serde(default)]
39 pub index: Option<u32>,
40 #[serde(default)]
41 pub id: Option<String>,
42 #[serde(rename = "type", default)]
43 pub kind: Option<String>,
44 #[serde(default)]
45 pub function: Option<RawFunctionDelta>,
46}
47
48#[derive(Debug, Deserialize)]
49pub struct RawFunctionDelta {
50 #[serde(default)]
51 pub name: Option<String>,
52 #[serde(default)]
53 pub arguments: Option<String>,
54}
55
56#[derive(Debug, Deserialize)]
57pub struct RawUsage {
58 #[serde(default)]
59 pub prompt_tokens: Option<u32>,
60 #[serde(default)]
61 pub completion_tokens: Option<u32>,
62 #[serde(default)]
63 pub prompt_tokens_details: Option<RawPromptTokensDetails>,
64}
65
66#[derive(Debug, Deserialize)]
67pub struct RawPromptTokensDetails {
68 #[serde(default)]
69 pub cached_tokens: Option<u32>,
70}
71
72pub fn parse_sse_line(line: &str) -> Option<OaiEvent> {
75 let line = line.trim_end_matches('\r');
76 if line.is_empty() || line.starts_with(':') {
77 return None;
78 }
79 let data = line.strip_prefix("data:")?.trim_start();
80 if data == "[DONE]" {
81 return Some(OaiEvent::Done);
82 }
83 let chunk: RawChunk = serde_json::from_str(data).ok()?;
84 let delta = chunk.choices.into_iter().next()?.delta?;
85 if let Some(role) = delta.role {
86 return Some(OaiEvent::RoleStart(role));
87 }
88 if let Some(content) = delta.content {
89 if !content.is_empty() {
90 return Some(OaiEvent::TextDelta(content));
91 }
92 }
93 None
94}
95
96#[derive(Debug, Default)]
99pub struct ToolCallAccumulator {
100 pub id: String,
101 pub name: String,
102 pub arguments: String,
103 started: bool,
104}
105
106#[derive(Debug)]
107pub struct StreamDecoder {
108 pub calls: HashMap<u32, ToolCallAccumulator>,
109 pub truncated: bool,
110 pub completed: bool,
111 pub finish_reason: Option<String>,
115 role_emitted: bool,
116 done_emitted: bool,
117}
118
119impl Default for StreamDecoder {
120 fn default() -> Self {
121 Self::new()
122 }
123}
124
125impl StreamDecoder {
126 pub fn new() -> Self {
127 Self {
128 calls: HashMap::new(),
129 truncated: false,
130 completed: false,
131 finish_reason: None,
132 role_emitted: false,
133 done_emitted: false,
134 }
135 }
136
137 pub fn push_line<E: Extend<OaiEvent>>(&mut self, line: &str, sink: &mut E) {
138 let line = line.trim_end_matches('\r');
139 if line.is_empty() || line.starts_with(':') {
140 return;
141 }
142 let payload = match line.strip_prefix("data:").map(str::trim_start) {
143 Some(p) => p,
144 None => return,
145 };
146 if payload == "[DONE]" {
147 self.finish(sink);
148 return;
149 }
150 match serde_json::from_str::<RawChunk>(payload) {
151 Ok(chunk) => self.push_chunk(chunk, sink),
152 Err(e) => sink.extend(Some(OaiEvent::Warning(format!(
156 "sse parse error: {e} (payload {} bytes, not logged)",
157 payload.len()
158 )))),
159 }
160 }
161
162 fn push_chunk<E: Extend<OaiEvent>>(&mut self, chunk: RawChunk, sink: &mut E) {
163 for choice in chunk.choices {
164 let is_finish = choice.finish_reason.is_some();
165 if let Some(delta) = choice.delta {
166 if let Some(role) = delta.role {
167 if !self.role_emitted {
168 self.role_emitted = true;
169 sink.extend(Some(OaiEvent::RoleStart(role)));
170 }
171 }
172 if let Some(text) = delta.content {
173 if !text.is_empty() {
174 sink.extend(Some(OaiEvent::TextDelta(text)));
175 }
176 }
177 if let Some(tcs) = delta.tool_calls {
182 for tc in tcs {
183 let is_resend =
184 is_finish && tc.id.as_ref().is_some_and(|id| !id.is_empty());
185 if !is_resend {
186 self.apply_tool_call_delta(tc, sink);
187 }
188 }
189 }
190 }
191 if let Some(reason) = choice.finish_reason {
192 if self.finish_reason.is_none() {
193 self.finish_reason = Some(reason.clone());
194 }
195 match reason.as_str() {
196 "tool_calls" => self.flush_complete(sink),
197 "length" => {
198 if !self.calls.is_empty() {
199 self.truncated = true;
200 self.flush_complete(sink);
201 }
202 }
203 "stop" | "content_filter" => {}
204 _ => sink.extend(Some(OaiEvent::Warning(
207 "unknown finish_reason (value not logged)".to_string(),
208 ))),
209 }
210 }
211 }
212 if let Some(u) = chunk.usage {
213 let cached = u
214 .prompt_tokens_details
215 .and_then(|d| d.cached_tokens)
216 .unwrap_or(0);
217 sink.extend(Some(OaiEvent::Usage {
218 prompt_tokens: u.prompt_tokens.unwrap_or(0),
219 completion_tokens: u.completion_tokens.unwrap_or(0),
220 cached_tokens: cached,
221 }));
222 }
223 }
224
225 fn apply_tool_call_delta<E: Extend<OaiEvent>>(&mut self, tc: RawToolCallDelta, sink: &mut E) {
226 let idx = tc.index.unwrap_or(0);
227 let acc = self.calls.entry(idx).or_default();
228
229 if let Some(id) = tc.id {
230 if !id.is_empty() {
231 acc.id = id;
232 }
233 }
234 if let Some(f) = tc.function {
235 if let Some(n) = f.name {
236 if !n.is_empty() {
237 acc.name = n;
238 }
239 }
240 if !acc.started && !acc.id.is_empty() && !acc.name.is_empty() {
241 acc.started = true;
242 sink.extend(Some(OaiEvent::ToolCallStart {
243 index: idx,
244 id: acc.id.clone(),
245 name: acc.name.clone(),
246 }));
247 }
248 if let Some(args) = f.arguments {
249 if !args.is_empty() {
250 acc.arguments.push_str(&args);
251 sink.extend(Some(OaiEvent::ToolCallArgumentsDelta {
252 index: idx,
253 id: acc.id.clone(),
254 delta: args,
255 }));
256 }
257 }
258 } else if !acc.started && !acc.id.is_empty() && !acc.name.is_empty() {
259 acc.started = true;
260 sink.extend(Some(OaiEvent::ToolCallStart {
261 index: idx,
262 id: acc.id.clone(),
263 name: acc.name.clone(),
264 }));
265 }
266 }
267
268 pub fn finish<E: Extend<OaiEvent>>(&mut self, sink: &mut E) {
269 self.flush_complete(sink);
270 if !self.done_emitted {
271 self.done_emitted = true;
272 sink.extend(Some(OaiEvent::Done));
273 }
274 }
275
276 fn flush_complete<E: Extend<OaiEvent>>(&mut self, sink: &mut E) {
277 if self.completed || self.calls.is_empty() {
278 return;
279 }
280 self.completed = true;
281 let mut entries: Vec<(u32, ToolCallAccumulator)> = self.calls.drain().collect();
282 entries.sort_by_key(|(k, _)| *k);
283 let calls: Vec<ToolCall> = entries
284 .into_iter()
285 .map(|(_, acc)| ToolCall {
286 id: acc.id,
287 kind: "function".to_string(),
288 function: FunctionCall {
289 name: acc.name,
290 arguments: acc.arguments,
291 },
292 })
293 .collect();
294 sink.extend(Some(OaiEvent::ToolCallsComplete {
295 calls,
296 truncated: self.truncated,
297 }));
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
309
310 #[test]
311 fn parse_error_warning_never_contains_payload_bytes() {
312 const SENTINEL: &str = "PH1-WIRE-SENTINEL-4c1a-RAW";
313 let mut decoder = StreamDecoder::new();
314 let mut sink: Vec<OaiEvent> = Vec::new();
315 decoder.push_line(&format!("data: {{\"broken\": \"{SENTINEL}"), &mut sink);
316 let [OaiEvent::Warning(w)] = sink.as_slice() else {
317 panic!("malformed payload must produce exactly one warning, got {sink:?}");
318 };
319 assert!(w.contains("sse parse error"), "{w}");
320 assert!(
321 !w.contains(SENTINEL),
322 "provider payload bytes leaked into a warning destined for logs: {w}"
323 );
324 }
325
326 #[test]
327 fn unknown_finish_reason_warning_never_contains_provider_value() {
328 const SENTINEL: &str = "PH1-FINISH-SENTINEL-7e2b-RAW";
329 let mut decoder = StreamDecoder::new();
330 let mut sink: Vec<OaiEvent> = Vec::new();
331 decoder.push_line(
332 &format!("data: {{\"choices\":[{{\"delta\":{{}},\"finish_reason\":\"{SENTINEL}\"}}]}}"),
333 &mut sink,
334 );
335 let warning = sink.iter().find_map(|e| match e {
336 OaiEvent::Warning(w) => Some(w.clone()),
337 _ => None,
338 });
339 let w = warning.expect("unknown finish_reason must still warn");
340 assert!(
341 !w.contains(SENTINEL),
342 "provider-controlled finish_reason leaked into a warning destined for logs: {w}"
343 );
344 }
345}