Skip to main content

atman_runtime/providers/
openai.rs

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}