1use serde::{Deserialize, Serialize};
2use tokio::sync::broadcast;
3use tokio_util::sync::CancellationToken;
4
5use crate::error::RuntimeError;
6use crate::event::{NodeEvent, Observable};
7use crate::message::{ImageData, Message, MessageOrigin, MessagePart, MessageRole};
8use crate::provider::{
9 AssistantMessage, CallTiming, DEFAULT_STREAM_BUFFER, LlmRequest, Provider, StopReason,
10 TokenUsage, estimate_tokens,
11};
12use crate::providers::classify_attachment_error;
13use crate::tool::BoxFut;
14
15pub struct OpenAiProvider {
16 name: String,
17 api_key: String,
18 base_url: String,
19 client: reqwest::Client,
20 max_tokens: Option<u32>,
21}
22
23impl OpenAiProvider {
24 pub fn new(name: impl Into<String>, api_key: impl Into<String>) -> Self {
25 Self {
26 name: name.into(),
27 api_key: api_key.into(),
28 base_url: "https://api.openai.com/v1".into(),
29 client: reqwest::Client::new(),
30 max_tokens: None,
31 }
32 }
33
34 pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
35 self.base_url = url.into();
36 self
37 }
38
39 pub fn with_max_tokens(mut self, n: u32) -> Self {
40 self.max_tokens = Some(n);
41 self
42 }
43
44 fn build_body(&self, req: &LlmRequest, stream: bool) -> ChatCompletionsRequest {
45 let mut wire_messages: Vec<ChatMessage> = Vec::new();
46 if let Some(sys) = &req.system {
47 wire_messages.push(ChatMessage {
48 role: "system",
49 content: Some(ChatContent::Text(sys.clone())),
50 tool_calls: None,
51 tool_call_id: None,
52 });
53 }
54 for m in &req.messages {
55 wire_messages.push(build_wire_message(m));
56 }
57 let tools: Vec<WireToolSpec> = req
58 .tools
59 .iter()
60 .map(|t| WireToolSpec {
61 kind: "function",
62 function: WireToolFunction {
63 name: crate::tool_naming::to_wire(&t.name),
64 description: t.description.clone(),
65 parameters: t.input_schema.clone(),
66 },
67 })
68 .collect();
69 ChatCompletionsRequest {
70 model: req.model.clone(),
71 stream,
72 max_tokens: self.max_tokens,
73 messages: wire_messages,
74 tools,
75 stream_options: if stream {
76 Some(StreamOptions {
77 include_usage: true,
78 })
79 } else {
80 None
81 },
82 thinking: if req.thinking_enabled {
83 Some(ThinkingConfig { kind: "enabled" })
84 } else {
85 Some(ThinkingConfig { kind: "disabled" })
86 },
87 }
88 }
89
90 fn build_request(&self, req: &LlmRequest, stream: bool) -> reqwest::RequestBuilder {
91 let body = self.build_body(req, stream);
92 self.client
93 .post(format!("{}/chat/completions", self.base_url))
94 .bearer_auth(&self.api_key)
95 .json(&body)
96 }
97
98 #[doc(hidden)]
99 pub fn wire_body_bytes(&self, req: &LlmRequest, stream: bool) -> Vec<u8> {
100 serde_json::to_vec(&self.build_body(req, stream)).expect("serialize wire body")
101 }
102}
103
104fn build_wire_message(m: &Message) -> ChatMessage {
105 match m.role {
106 MessageRole::System => ChatMessage {
107 role: "system",
108 content: Some(ChatContent::Text(m.text_concat())),
109 tool_calls: None,
110 tool_call_id: None,
111 },
112 MessageRole::Tool => {
113 let (id, content) = extract_tool_result(m);
114 ChatMessage {
115 role: "tool",
116 content: Some(ChatContent::Text(content)),
117 tool_calls: None,
118 tool_call_id: Some(id),
119 }
120 }
121 MessageRole::Assistant => {
122 let (text_parts, tool_uses) = split_assistant_parts(&m.parts);
123 let content = if text_parts.is_empty() {
124 None
125 } else {
126 Some(ChatContent::Text(text_parts.join("")))
127 };
128 let tool_calls = if tool_uses.is_empty() {
129 None
130 } else {
131 Some(tool_uses)
132 };
133 ChatMessage {
134 role: "assistant",
135 content,
136 tool_calls,
137 tool_call_id: None,
138 }
139 }
140 MessageRole::User => {
141 let parts = build_user_parts(&m.parts);
142 let content = if parts.iter().all(|p| matches!(p, ChatPart::Text { .. })) {
143 let joined: String = parts
144 .iter()
145 .filter_map(|p| match p {
146 ChatPart::Text { text } => Some(text.as_str()),
147 _ => None,
148 })
149 .collect();
150 Some(ChatContent::Text(joined))
151 } else {
152 Some(ChatContent::Parts(parts))
153 };
154 ChatMessage {
155 role: "user",
156 content,
157 tool_calls: None,
158 tool_call_id: None,
159 }
160 }
161 }
162}
163
164fn build_user_parts(parts: &[MessagePart]) -> Vec<ChatPart> {
165 let mut out = Vec::with_capacity(parts.len());
166 for p in parts {
167 match p {
168 MessagePart::CompactSummary { summary, .. } => out.push(ChatPart::Text {
169 text: summary.clone(),
170 }),
171 MessagePart::Text { text } => out.push(ChatPart::Text { text: text.clone() }),
172 MessagePart::Image { source } => {
173 let url = match &source.data {
174 ImageData::Base64 { data } => {
175 format!("data:{};base64,{}", source.media_type, data)
176 }
177 ImageData::Path { path } => {
178 let bytes = std::fs::read(path).unwrap_or_default();
179 use base64::Engine;
180 let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
181 format!("data:{};base64,{}", source.media_type, data)
182 }
183 };
184 out.push(ChatPart::ImageUrl {
185 image_url: ImageUrl { url },
186 });
187 }
188 _ => {}
189 }
190 }
191 out
192}
193
194fn extract_tool_result(m: &Message) -> (String, String) {
195 for p in &m.parts {
196 if let MessagePart::ToolResult {
197 tool_use_id,
198 content,
199 ..
200 } = p
201 {
202 return (tool_use_id.clone(), content.clone());
203 }
204 }
205 (String::new(), m.text_concat())
206}
207
208fn split_assistant_parts(parts: &[MessagePart]) -> (Vec<String>, Vec<WireToolCall>) {
209 let mut text = Vec::new();
210 let mut tools = Vec::new();
211 for p in parts {
212 match p {
213 MessagePart::Text { text: t } => text.push(t.clone()),
214 MessagePart::ToolUse { id, name, input } => tools.push(WireToolCall {
215 id: id.clone(),
216 kind: "function",
217 function: WireFunctionCall {
218 name: crate::tool_naming::to_wire(name),
219 arguments: input.to_string(),
220 },
221 }),
222 _ => {}
223 }
224 }
225 (text, tools)
226}
227
228impl Provider for OpenAiProvider {
229 fn name(&self) -> &str {
230 &self.name
231 }
232
233 fn call<'a>(&'a self, req: LlmRequest) -> BoxFut<'a, Result<AssistantMessage, RuntimeError>> {
234 let request = self.build_request(&req, false);
235 let turn_id = next_turn_id_from_req(&req);
236 Box::pin(async move {
237 let resp = request.send().await.map_err(net_err)?;
238 let status = resp.status();
239 let body: ChatCompletionsResponse = if status.is_success() {
240 resp.json().await.map_err(net_err)?
241 } else {
242 let body_text = resp.text().await.unwrap_or_default();
243 if let Some(reason) = classify_attachment_error(status.as_u16(), &body_text) {
244 return Err(RuntimeError::AttachmentError { reason });
245 }
246 return Err(RuntimeError::ToolFailed(format!(
247 "openai http {status}: {body_text}"
248 )));
249 };
250 Ok(response_to_assistant(body, turn_id, &req.tools))
251 })
252 }
253
254 fn call_streaming(&self, req: LlmRequest) -> Observable<AssistantMessage> {
255 let request = self.build_request(&req, true);
256 let turn_id = next_turn_id_from_req(&req);
257 let streaming_tools = req.tools.clone();
258 let (tx, events) = broadcast::channel(DEFAULT_STREAM_BUFFER);
259 let cancel = CancellationToken::new();
260 let cancel_for_task = cancel.clone();
261 let output: BoxFut<'static, Result<AssistantMessage, RuntimeError>> = Box::pin(
262 async move {
263 use eventsource_stream::Eventsource;
264 use futures::StreamExt;
265
266 let resp = tokio::select! {
267 biased;
268 _ = cancel_for_task.cancelled() => return Err(RuntimeError::Cancelled("openai cancelled before send".into())),
269 r = request.send() => r.map_err(net_err)?,
270 };
271 let status = resp.status();
272 if !status.is_success() {
273 let body = resp.text().await.unwrap_or_default();
274 if let Some(reason) = classify_attachment_error(status.as_u16(), &body) {
275 return Err(RuntimeError::AttachmentError { reason });
276 }
277 return Err(RuntimeError::ToolFailed(format!(
278 "openai http {status}: {body}"
279 )));
280 }
281
282 let mut stream = resp.bytes_stream().eventsource();
283 let mut acc_text = String::new();
284 let mut acc_thinking = String::new();
285 let mut cumulative = 0u64;
286 let mut final_usage: Option<OpenAiUsage> = None;
287 let mut resp_model: Option<String> = None;
288 let mut resp_id: Option<String> = None;
289 let mut partial_tool_calls: Vec<PartialToolCall> = Vec::new();
290 let mut stop_reason = StopReason::End;
291 while let Some(event) = tokio::select! {
292 biased;
293 _ = cancel_for_task.cancelled() => None,
294 next = stream.next() => next,
295 } {
296 let event = event.map_err(|e| RuntimeError::ToolFailed(format!("sse: {e}")))?;
297 if event.data == "[DONE]" {
298 break;
299 }
300 if event.data.is_empty() {
301 continue;
302 }
303 let parsed: serde_json::Value = match serde_json::from_str(&event.data) {
304 Ok(v) => v,
305 Err(_) => continue,
306 };
307 if let Some(content) = parsed
308 .pointer("/choices/0/delta/content")
309 .and_then(|v| v.as_str())
310 && !content.is_empty()
311 {
312 acc_text.push_str(content);
313 cumulative += estimate_tokens(content);
314 let _ = tx.send(NodeEvent::LlmChunk {
315 text: content.to_string(),
316 cumulative_tokens: cumulative,
317 });
318 }
319 if let Some(reasoning) = parsed
320 .pointer("/choices/0/delta/reasoning_content")
321 .and_then(|v| v.as_str())
322 && !reasoning.is_empty()
323 {
324 acc_thinking.push_str(reasoning);
325 let _ = tx.send(NodeEvent::ThinkingChunk {
326 text: reasoning.to_string(),
327 });
328 }
329 if let Some(m) = parsed.get("model").and_then(|v| v.as_str()) {
330 resp_model = Some(m.to_string());
331 }
332 if let Some(id) = parsed.get("id").and_then(|v| v.as_str()) {
333 resp_id = Some(id.to_string());
334 }
335 if let Some(tcs) = parsed
336 .pointer("/choices/0/delta/tool_calls")
337 .and_then(|v| v.as_array())
338 {
339 for tc in tcs {
340 let idx =
341 tc.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
342 while partial_tool_calls.len() <= idx {
343 partial_tool_calls.push(PartialToolCall::default());
344 }
345 let slot = &mut partial_tool_calls[idx];
346 if let Some(id) = tc.get("id").and_then(|v| v.as_str()) {
347 slot.id = id.to_string();
348 }
349 if let Some(name) =
350 tc.pointer("/function/name").and_then(|v| v.as_str())
351 {
352 slot.name = name.to_string();
353 }
354 if let Some(args) =
355 tc.pointer("/function/arguments").and_then(|v| v.as_str())
356 {
357 slot.arguments.push_str(args);
358 }
359 }
360 }
361 if let Some(reason) = parsed
362 .pointer("/choices/0/finish_reason")
363 .and_then(|v| v.as_str())
364 {
365 stop_reason = parse_stop_reason(reason);
366 }
367 if let Some(usage_obj) = parsed.get("usage") {
368 if !usage_obj.is_null() {
369 final_usage =
370 serde_json::from_value::<OpenAiUsage>(usage_obj.clone()).ok();
371 }
372 }
373 }
374 if cancel_for_task.is_cancelled() {
375 let _ = tx.send(NodeEvent::LlmDone {
376 total_tokens: cumulative,
377 });
378 return Err(RuntimeError::Cancelled(
379 "openai cancelled mid-stream".into(),
380 ));
381 }
382 let total = final_usage
383 .as_ref()
384 .and_then(|u| u.completion_tokens)
385 .unwrap_or(cumulative);
386 let _ = tx.send(NodeEvent::LlmDone {
387 total_tokens: total,
388 });
389
390 let mut parts: Vec<MessagePart> = Vec::new();
391 if !acc_thinking.is_empty() {
392 parts.push(MessagePart::Thinking {
393 thinking: acc_thinking,
394 signature: None,
395 });
396 }
397 if !acc_text.is_empty() {
398 parts.push(MessagePart::Text { text: acc_text });
399 }
400 for tc in partial_tool_calls {
401 if tc.id.is_empty() && tc.name.is_empty() && tc.arguments.is_empty() {
402 continue;
403 }
404 let input: serde_json::Value = if tc.arguments.is_empty() {
405 serde_json::Value::Object(Default::default())
406 } else {
407 serde_json::from_str(&tc.arguments).unwrap_or(serde_json::Value::Null)
408 };
409 parts.push(MessagePart::ToolUse {
410 id: tc.id,
411 name: crate::tool_naming::from_wire(&tc.name, &streaming_tools),
412 input,
413 });
414 }
415
416 let token_usage = if let Some(u) = &final_usage {
417 let cached = u
418 .prompt_tokens_details
419 .as_ref()
420 .and_then(|d| d.cached_tokens)
421 .unwrap_or(0);
422 let cache_write = u
423 .prompt_tokens_details
424 .as_ref()
425 .and_then(|d| d.cache_write_tokens)
426 .unwrap_or(0);
427 let cache_read = cached.max(u.prompt_cache_hit_tokens.unwrap_or(0));
428 let reasoning = u
429 .completion_tokens_details
430 .as_ref()
431 .and_then(|d| d.reasoning_tokens)
432 .unwrap_or(0);
433 TokenUsage {
434 input: u.prompt_tokens.unwrap_or(0).saturating_sub(cache_read),
435 cached_input: cache_read,
436 output: u.completion_tokens.unwrap_or(0),
437 cache_write,
438 reasoning_tokens: reasoning,
439 }
440 } else {
441 TokenUsage {
442 output: total,
443 ..Default::default()
444 }
445 };
446
447 Ok(AssistantMessage {
448 message: Message {
449 role: MessageRole::Assistant,
450 parts,
451 turn_id,
452 origin: MessageOrigin::User,
453 },
454 stop_reason,
455 token_usage,
456 timing: CallTiming::default(),
457 model: resp_model.unwrap_or_default(),
458 response_id: resp_id,
459 })
460 },
461 );
462 Observable {
463 output,
464 events,
465 cancel,
466 }
467 }
468
469 fn discover_models(
470 &self,
471 ) -> crate::tool::BoxFut<'static, Vec<crate::provider::DiscoveredModel>> {
472 let base_url = self.base_url.clone();
473 let api_key = self.api_key.clone();
474 Box::pin(async move {
475 #[derive(serde::Deserialize)]
476 struct ModelsResponse {
477 #[serde(default)]
478 data: Vec<ModelEntry>,
479 }
480 #[derive(serde::Deserialize)]
481 struct ModelEntry {
482 id: String,
483 }
484
485 let client = reqwest::Client::builder()
486 .timeout(std::time::Duration::from_secs(10))
487 .build()
488 .unwrap_or_default();
489 let resp = match client
490 .get(format!("{base_url}/models"))
491 .bearer_auth(&api_key)
492 .send()
493 .await
494 {
495 Ok(r) if r.status().is_success() => r,
496 _ => return vec![],
497 };
498 let body: ModelsResponse = match resp.json().await {
499 Ok(b) => b,
500 Err(_) => return vec![],
501 };
502 body.data
503 .into_iter()
504 .filter(|m| !m.id.starts_with("ft:"))
505 .map(|m| {
506 let (budget, thinking) =
507 crate::model_registry::lookup_known_model(&m.id).unwrap_or((32_768, false));
508 crate::provider::DiscoveredModel {
509 slug: m.id,
510 context_budget: Some(budget),
511 thinking,
512 }
513 })
514 .collect()
515 })
516 }
517
518 fn test_connection(&self) -> BoxFut<'_, Result<String, String>> {
519 let base_url = self.base_url.clone();
520 let api_key = self.api_key.clone();
521 let name = self.name.clone();
522 Box::pin(async move {
523 let client = reqwest::Client::builder()
524 .timeout(std::time::Duration::from_secs(15))
525 .build()
526 .map_err(|e| e.to_string())?;
527 let resp = client
528 .get(format!("{}/models", base_url.trim_end_matches('/')))
529 .bearer_auth(&api_key)
530 .send()
531 .await
532 .map_err(|e| format!("connection failed — {e}"))?;
533 let status = resp.status();
534 if status.is_success() {
535 Ok(format!("\"{name}\" responded OK"))
536 } else {
537 let body = resp.text().await.unwrap_or_default();
538 Err(format!(
539 "returned {status} — {}",
540 &body[..body.len().min(200)]
541 ))
542 }
543 })
544 }
545}
546
547#[derive(Default)]
548struct PartialToolCall {
549 id: String,
550 name: String,
551 arguments: String,
552}
553
554fn response_to_assistant(
555 body: ChatCompletionsResponse,
556 turn_id: crate::event::TurnId,
557 tools: &[crate::tool::ToolSpec],
558) -> AssistantMessage {
559 let mut parts: Vec<MessagePart> = Vec::new();
560 let mut stop_reason = StopReason::End;
561 if let Some(choice) = body.choices.into_iter().next() {
562 if let Some(msg) = choice.message {
563 if let Some(content) = msg.content {
564 parts.push(MessagePart::Text { text: content });
565 }
566 if let Some(tool_calls) = msg.tool_calls {
567 for tc in tool_calls {
568 let input: serde_json::Value = if tc.function.arguments.is_empty() {
569 serde_json::Value::Object(Default::default())
570 } else {
571 serde_json::from_str(&tc.function.arguments)
572 .unwrap_or(serde_json::Value::Null)
573 };
574 parts.push(MessagePart::ToolUse {
575 id: tc.id,
576 name: crate::tool_naming::from_wire(&tc.function.name, tools),
577 input,
578 });
579 }
580 }
581 }
582 if let Some(reason) = choice.finish_reason {
583 stop_reason = parse_stop_reason(&reason);
584 }
585 }
586 let usage = body.usage.map(|u| {
587 let cached = u
588 .prompt_tokens_details
589 .as_ref()
590 .and_then(|d| d.cached_tokens)
591 .unwrap_or(0);
592 let cache_write = u
593 .prompt_tokens_details
594 .as_ref()
595 .and_then(|d| d.cache_write_tokens)
596 .unwrap_or(0);
597 let cache_read = cached.max(u.prompt_cache_hit_tokens.unwrap_or(0));
598 let reasoning = u
599 .completion_tokens_details
600 .as_ref()
601 .and_then(|d| d.reasoning_tokens)
602 .unwrap_or(0);
603 TokenUsage {
604 input: u.prompt_tokens.unwrap_or(0).saturating_sub(cache_read),
605 cached_input: cache_read,
606 output: u.completion_tokens.unwrap_or(0),
607 cache_write,
608 reasoning_tokens: reasoning,
609 }
610 });
611 AssistantMessage {
612 message: Message {
613 role: MessageRole::Assistant,
614 parts,
615 turn_id,
616 origin: MessageOrigin::User,
617 },
618 stop_reason,
619 token_usage: usage.unwrap_or_default(),
620 timing: CallTiming::default(),
621 model: body.model.unwrap_or_default(),
622 response_id: body.id,
623 }
624}
625
626fn parse_stop_reason(s: &str) -> StopReason {
627 match s {
628 "tool_calls" | "function_call" => StopReason::ToolUse,
629 "length" => StopReason::Length,
630 _ => StopReason::End,
631 }
632}
633
634fn next_turn_id_from_req(req: &LlmRequest) -> crate::event::TurnId {
635 req.messages
636 .first()
637 .map(|m| m.turn_id.clone())
638 .unwrap_or_else(crate::event::TurnId::now)
639}
640
641fn net_err(e: reqwest::Error) -> RuntimeError {
642 RuntimeError::ToolFailed(format!("openai net: {e}"))
643}
644
645#[derive(Serialize)]
646struct ChatCompletionsRequest {
647 model: String,
648 stream: bool,
649 #[serde(skip_serializing_if = "Option::is_none")]
650 max_tokens: Option<u32>,
651 messages: Vec<ChatMessage>,
652 #[serde(skip_serializing_if = "Vec::is_empty")]
653 tools: Vec<WireToolSpec>,
654 #[serde(skip_serializing_if = "Option::is_none")]
655 stream_options: Option<StreamOptions>,
656 #[serde(skip_serializing_if = "Option::is_none")]
657 thinking: Option<ThinkingConfig>,
658}
659
660#[derive(Serialize)]
661struct ThinkingConfig {
662 #[serde(rename = "type")]
663 kind: &'static str,
664}
665
666#[derive(Serialize)]
667struct StreamOptions {
668 include_usage: bool,
669}
670
671#[derive(Serialize)]
672struct WireToolSpec {
673 #[serde(rename = "type")]
674 kind: &'static str,
675 function: WireToolFunction,
676}
677
678#[derive(Serialize)]
679struct WireToolFunction {
680 name: String,
681 #[serde(skip_serializing_if = "Option::is_none")]
682 description: Option<String>,
683 parameters: serde_json::Value,
684}
685
686#[derive(Serialize)]
687struct ChatMessage {
688 role: &'static str,
689 #[serde(skip_serializing_if = "Option::is_none")]
690 content: Option<ChatContent>,
691 #[serde(skip_serializing_if = "Option::is_none")]
692 tool_calls: Option<Vec<WireToolCall>>,
693 #[serde(skip_serializing_if = "Option::is_none")]
694 tool_call_id: Option<String>,
695}
696
697#[derive(Serialize)]
698#[serde(untagged)]
699enum ChatContent {
700 Text(String),
701 Parts(Vec<ChatPart>),
702}
703
704#[derive(Serialize)]
705#[serde(tag = "type", rename_all = "snake_case")]
706enum ChatPart {
707 Text { text: String },
708 ImageUrl { image_url: ImageUrl },
709}
710
711#[derive(Serialize)]
712struct ImageUrl {
713 url: String,
714}
715
716#[derive(Serialize)]
717struct WireToolCall {
718 id: String,
719 #[serde(rename = "type")]
720 kind: &'static str,
721 function: WireFunctionCall,
722}
723
724#[derive(Serialize)]
725struct WireFunctionCall {
726 name: String,
727 arguments: String,
728}
729
730#[derive(Deserialize)]
731struct ChatCompletionsResponse {
732 choices: Vec<ChatChoice>,
733 #[serde(default)]
734 usage: Option<OpenAiUsage>,
735 #[serde(default)]
736 model: Option<String>,
737 #[serde(default)]
738 id: Option<String>,
739}
740
741#[derive(Deserialize, Default)]
742struct OpenAiUsage {
743 #[serde(default)]
744 prompt_tokens: Option<u64>,
745 #[serde(default)]
746 completion_tokens: Option<u64>,
747 #[serde(default)]
748 prompt_tokens_details: Option<PromptTokensDetails>,
749 #[serde(default)]
750 completion_tokens_details: Option<CompletionTokensDetails>,
751 #[serde(default)]
752 prompt_cache_hit_tokens: Option<u64>,
753 #[serde(default)]
754 #[allow(dead_code)]
755 prompt_cache_miss_tokens: Option<u64>,
756}
757
758#[derive(Deserialize, Default)]
759struct PromptTokensDetails {
760 #[serde(default)]
761 cached_tokens: Option<u64>,
762 #[serde(default)]
763 cache_write_tokens: Option<u64>,
764}
765
766#[derive(Deserialize, Default)]
767struct CompletionTokensDetails {
768 #[serde(default)]
769 reasoning_tokens: Option<u64>,
770}
771
772#[derive(Deserialize)]
773struct ChatChoice {
774 #[serde(default)]
775 message: Option<ChatChoiceMessage>,
776 #[serde(default)]
777 finish_reason: Option<String>,
778}
779
780#[derive(Deserialize)]
781struct ChatChoiceMessage {
782 #[serde(default)]
783 content: Option<String>,
784 #[serde(default)]
785 tool_calls: Option<Vec<RespToolCall>>,
786}
787
788#[derive(Deserialize)]
789struct RespToolCall {
790 id: String,
791 function: RespFunctionCall,
792}
793
794#[derive(Deserialize)]
795struct RespFunctionCall {
796 name: String,
797 #[serde(default)]
798 arguments: String,
799}