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) = tc
350 .pointer("/function/name")
351 .and_then(|v| v.as_str())
352 .filter(|name| !name.is_empty())
353 {
354 slot.name = name.to_string();
355 }
356 if let Some(args) =
357 tc.pointer("/function/arguments").and_then(|v| v.as_str())
358 {
359 slot.arguments.push_str(args);
360 }
361 }
362 }
363 if let Some(reason) = parsed
364 .pointer("/choices/0/finish_reason")
365 .and_then(|v| v.as_str())
366 {
367 stop_reason = parse_stop_reason(reason);
368 }
369 if let Some(usage_obj) = parsed.get("usage") {
370 if !usage_obj.is_null() {
371 final_usage =
372 serde_json::from_value::<OpenAiUsage>(usage_obj.clone()).ok();
373 }
374 }
375 }
376 if cancel_for_task.is_cancelled() {
377 let _ = tx.send(NodeEvent::LlmDone {
378 total_tokens: cumulative,
379 });
380 return Err(RuntimeError::Cancelled(
381 "openai cancelled mid-stream".into(),
382 ));
383 }
384 let total = final_usage
385 .as_ref()
386 .and_then(|u| u.completion_tokens)
387 .unwrap_or(cumulative);
388 let _ = tx.send(NodeEvent::LlmDone {
389 total_tokens: total,
390 });
391
392 let mut parts: Vec<MessagePart> = Vec::new();
393 if !acc_thinking.is_empty() {
394 parts.push(MessagePart::Thinking {
395 thinking: acc_thinking,
396 signature: None,
397 });
398 }
399 if !acc_text.is_empty() {
400 parts.push(MessagePart::Text { text: acc_text });
401 }
402 for tc in partial_tool_calls {
403 if tc.id.is_empty() && tc.name.is_empty() && tc.arguments.is_empty() {
404 continue;
405 }
406 let input: serde_json::Value = if tc.arguments.is_empty() {
407 serde_json::Value::Object(Default::default())
408 } else {
409 serde_json::from_str(&tc.arguments).unwrap_or(serde_json::Value::Null)
410 };
411 parts.push(MessagePart::ToolUse {
412 id: tc.id,
413 name: crate::tool_naming::from_wire(&tc.name, &streaming_tools),
414 input,
415 });
416 }
417
418 let token_usage = if let Some(u) = &final_usage {
419 let cached = u
420 .prompt_tokens_details
421 .as_ref()
422 .and_then(|d| d.cached_tokens)
423 .unwrap_or(0);
424 let cache_write = u
425 .prompt_tokens_details
426 .as_ref()
427 .and_then(|d| d.cache_write_tokens)
428 .unwrap_or(0);
429 let cache_read = cached.max(u.prompt_cache_hit_tokens.unwrap_or(0));
430 let reasoning = u
431 .completion_tokens_details
432 .as_ref()
433 .and_then(|d| d.reasoning_tokens)
434 .unwrap_or(0);
435 TokenUsage {
436 input: u.prompt_tokens.unwrap_or(0).saturating_sub(cache_read),
437 cached_input: cache_read,
438 output: u.completion_tokens.unwrap_or(0),
439 cache_write,
440 reasoning_tokens: reasoning,
441 }
442 } else {
443 TokenUsage {
444 output: total,
445 ..Default::default()
446 }
447 };
448
449 Ok(AssistantMessage {
450 message: Message {
451 role: MessageRole::Assistant,
452 parts,
453 turn_id,
454 origin: MessageOrigin::User,
455 },
456 stop_reason,
457 token_usage,
458 timing: CallTiming::default(),
459 model: resp_model.unwrap_or_default(),
460 response_id: resp_id,
461 })
462 },
463 );
464 Observable {
465 output,
466 events,
467 cancel,
468 }
469 }
470
471 fn discover_models(
472 &self,
473 ) -> crate::tool::BoxFut<'static, Vec<crate::provider::DiscoveredModel>> {
474 let base_url = self.base_url.clone();
475 let api_key = self.api_key.clone();
476 Box::pin(async move {
477 #[derive(serde::Deserialize)]
478 struct ModelsResponse {
479 #[serde(default)]
480 data: Vec<ModelEntry>,
481 }
482 #[derive(serde::Deserialize)]
483 struct ModelEntry {
484 id: String,
485 }
486
487 let client = reqwest::Client::builder()
488 .timeout(std::time::Duration::from_secs(10))
489 .build()
490 .unwrap_or_default();
491 let resp = match client
492 .get(format!("{base_url}/models"))
493 .bearer_auth(&api_key)
494 .send()
495 .await
496 {
497 Ok(r) if r.status().is_success() => r,
498 _ => return vec![],
499 };
500 let body: ModelsResponse = match resp.json().await {
501 Ok(b) => b,
502 Err(_) => return vec![],
503 };
504 body.data
505 .into_iter()
506 .filter(|m| !m.id.starts_with("ft:"))
507 .map(|m| {
508 let (budget, thinking) =
509 crate::model_registry::lookup_known_model(&m.id).unwrap_or((32_768, false));
510 crate::provider::DiscoveredModel {
511 slug: m.id,
512 context_budget: Some(budget),
513 thinking,
514 }
515 })
516 .collect()
517 })
518 }
519
520 fn test_connection(&self) -> BoxFut<'_, Result<String, String>> {
521 let base_url = self.base_url.clone();
522 let api_key = self.api_key.clone();
523 let name = self.name.clone();
524 Box::pin(async move {
525 let client = reqwest::Client::builder()
526 .timeout(std::time::Duration::from_secs(15))
527 .build()
528 .map_err(|e| e.to_string())?;
529 let resp = client
530 .get(format!("{}/models", base_url.trim_end_matches('/')))
531 .bearer_auth(&api_key)
532 .send()
533 .await
534 .map_err(|e| format!("connection failed — {e}"))?;
535 let status = resp.status();
536 if status.is_success() {
537 Ok(format!("\"{name}\" responded OK"))
538 } else {
539 let body = resp.text().await.unwrap_or_default();
540 Err(format!(
541 "returned {status} — {}",
542 &body[..body.len().min(200)]
543 ))
544 }
545 })
546 }
547}
548
549#[derive(Default)]
550struct PartialToolCall {
551 id: String,
552 name: String,
553 arguments: String,
554}
555
556fn response_to_assistant(
557 body: ChatCompletionsResponse,
558 turn_id: crate::event::TurnId,
559 tools: &[crate::tool::ToolSpec],
560) -> AssistantMessage {
561 let mut parts: Vec<MessagePart> = Vec::new();
562 let mut stop_reason = StopReason::End;
563 if let Some(choice) = body.choices.into_iter().next() {
564 if let Some(msg) = choice.message {
565 if let Some(content) = msg.content {
566 parts.push(MessagePart::Text { text: content });
567 }
568 if let Some(tool_calls) = msg.tool_calls {
569 for tc in tool_calls {
570 let input: serde_json::Value = if tc.function.arguments.is_empty() {
571 serde_json::Value::Object(Default::default())
572 } else {
573 serde_json::from_str(&tc.function.arguments)
574 .unwrap_or(serde_json::Value::Null)
575 };
576 parts.push(MessagePart::ToolUse {
577 id: tc.id,
578 name: crate::tool_naming::from_wire(&tc.function.name, tools),
579 input,
580 });
581 }
582 }
583 }
584 if let Some(reason) = choice.finish_reason {
585 stop_reason = parse_stop_reason(&reason);
586 }
587 }
588 let usage = body.usage.map(|u| {
589 let cached = u
590 .prompt_tokens_details
591 .as_ref()
592 .and_then(|d| d.cached_tokens)
593 .unwrap_or(0);
594 let cache_write = u
595 .prompt_tokens_details
596 .as_ref()
597 .and_then(|d| d.cache_write_tokens)
598 .unwrap_or(0);
599 let cache_read = cached.max(u.prompt_cache_hit_tokens.unwrap_or(0));
600 let reasoning = u
601 .completion_tokens_details
602 .as_ref()
603 .and_then(|d| d.reasoning_tokens)
604 .unwrap_or(0);
605 TokenUsage {
606 input: u.prompt_tokens.unwrap_or(0).saturating_sub(cache_read),
607 cached_input: cache_read,
608 output: u.completion_tokens.unwrap_or(0),
609 cache_write,
610 reasoning_tokens: reasoning,
611 }
612 });
613 AssistantMessage {
614 message: Message {
615 role: MessageRole::Assistant,
616 parts,
617 turn_id,
618 origin: MessageOrigin::User,
619 },
620 stop_reason,
621 token_usage: usage.unwrap_or_default(),
622 timing: CallTiming::default(),
623 model: body.model.unwrap_or_default(),
624 response_id: body.id,
625 }
626}
627
628fn parse_stop_reason(s: &str) -> StopReason {
629 match s {
630 "tool_calls" | "function_call" => StopReason::ToolUse,
631 "length" => StopReason::Length,
632 _ => StopReason::End,
633 }
634}
635
636fn next_turn_id_from_req(req: &LlmRequest) -> crate::event::TurnId {
637 req.messages
638 .first()
639 .map(|m| m.turn_id.clone())
640 .unwrap_or_else(crate::event::TurnId::now)
641}
642
643fn net_err(e: reqwest::Error) -> RuntimeError {
644 RuntimeError::ToolFailed(format!("openai net: {e}"))
645}
646
647#[derive(Serialize)]
648struct ChatCompletionsRequest {
649 model: String,
650 stream: bool,
651 #[serde(skip_serializing_if = "Option::is_none")]
652 max_tokens: Option<u32>,
653 messages: Vec<ChatMessage>,
654 #[serde(skip_serializing_if = "Vec::is_empty")]
655 tools: Vec<WireToolSpec>,
656 #[serde(skip_serializing_if = "Option::is_none")]
657 stream_options: Option<StreamOptions>,
658 #[serde(skip_serializing_if = "Option::is_none")]
659 thinking: Option<ThinkingConfig>,
660}
661
662#[derive(Serialize)]
663struct ThinkingConfig {
664 #[serde(rename = "type")]
665 kind: &'static str,
666}
667
668#[derive(Serialize)]
669struct StreamOptions {
670 include_usage: bool,
671}
672
673#[derive(Serialize)]
674struct WireToolSpec {
675 #[serde(rename = "type")]
676 kind: &'static str,
677 function: WireToolFunction,
678}
679
680#[derive(Serialize)]
681struct WireToolFunction {
682 name: String,
683 #[serde(skip_serializing_if = "Option::is_none")]
684 description: Option<String>,
685 parameters: serde_json::Value,
686}
687
688#[derive(Serialize)]
689struct ChatMessage {
690 role: &'static str,
691 #[serde(skip_serializing_if = "Option::is_none")]
692 content: Option<ChatContent>,
693 #[serde(skip_serializing_if = "Option::is_none")]
694 tool_calls: Option<Vec<WireToolCall>>,
695 #[serde(skip_serializing_if = "Option::is_none")]
696 tool_call_id: Option<String>,
697}
698
699#[derive(Serialize)]
700#[serde(untagged)]
701enum ChatContent {
702 Text(String),
703 Parts(Vec<ChatPart>),
704}
705
706#[derive(Serialize)]
707#[serde(tag = "type", rename_all = "snake_case")]
708enum ChatPart {
709 Text { text: String },
710 ImageUrl { image_url: ImageUrl },
711}
712
713#[derive(Serialize)]
714struct ImageUrl {
715 url: String,
716}
717
718#[derive(Serialize)]
719struct WireToolCall {
720 id: String,
721 #[serde(rename = "type")]
722 kind: &'static str,
723 function: WireFunctionCall,
724}
725
726#[derive(Serialize)]
727struct WireFunctionCall {
728 name: String,
729 arguments: String,
730}
731
732#[derive(Deserialize)]
733struct ChatCompletionsResponse {
734 choices: Vec<ChatChoice>,
735 #[serde(default)]
736 usage: Option<OpenAiUsage>,
737 #[serde(default)]
738 model: Option<String>,
739 #[serde(default)]
740 id: Option<String>,
741}
742
743#[derive(Deserialize, Default)]
744struct OpenAiUsage {
745 #[serde(default)]
746 prompt_tokens: Option<u64>,
747 #[serde(default)]
748 completion_tokens: Option<u64>,
749 #[serde(default)]
750 prompt_tokens_details: Option<PromptTokensDetails>,
751 #[serde(default)]
752 completion_tokens_details: Option<CompletionTokensDetails>,
753 #[serde(default)]
754 prompt_cache_hit_tokens: Option<u64>,
755 #[serde(default)]
756 #[allow(dead_code)]
757 prompt_cache_miss_tokens: Option<u64>,
758}
759
760#[derive(Deserialize, Default)]
761struct PromptTokensDetails {
762 #[serde(default)]
763 cached_tokens: Option<u64>,
764 #[serde(default)]
765 cache_write_tokens: Option<u64>,
766}
767
768#[derive(Deserialize, Default)]
769struct CompletionTokensDetails {
770 #[serde(default)]
771 reasoning_tokens: Option<u64>,
772}
773
774#[derive(Deserialize)]
775struct ChatChoice {
776 #[serde(default)]
777 message: Option<ChatChoiceMessage>,
778 #[serde(default)]
779 finish_reason: Option<String>,
780}
781
782#[derive(Deserialize)]
783struct ChatChoiceMessage {
784 #[serde(default)]
785 content: Option<String>,
786 #[serde(default)]
787 tool_calls: Option<Vec<RespToolCall>>,
788}
789
790#[derive(Deserialize)]
791struct RespToolCall {
792 id: String,
793 function: RespFunctionCall,
794}
795
796#[derive(Deserialize)]
797struct RespFunctionCall {
798 name: String,
799 #[serde(default)]
800 arguments: String,
801}