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) = 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}