Skip to main content

atman_runtime/providers/
codex.rs

1use serde::{Deserialize, Serialize};
2use tokio::sync::broadcast;
3use tokio_util::sync::CancellationToken;
4
5use crate::error::RuntimeError;
6use crate::event::{NodeEvent, Observable, TurnId};
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::tool::BoxFut;
13use anyhow::Context;
14
15const CODEX_BASE: &str = "https://chatgpt.com/backend-api/codex";
16
17/// ChatGPT backend provider. Requires `originator: codex_cli_rs` header for Cloudflare.
18pub struct CodexProvider {
19    name: String,
20    access_token: String,
21    account_id: String,
22    client: reqwest::Client,
23}
24
25impl CodexProvider {
26    pub fn new(
27        name: impl Into<String>,
28        access_token: impl Into<String>,
29        account_id: impl Into<String>,
30    ) -> Self {
31        Self {
32            name: name.into(),
33            access_token: access_token.into(),
34            account_id: account_id.into(),
35            client: reqwest::Client::new(),
36        }
37    }
38
39    fn build_body(&self, req: &LlmRequest) -> ResponsesRequest {
40        let model = req
41            .model
42            .split_once('/')
43            .map(|(_, slug)| slug)
44            .or_else(|| req.model.split_once(':').map(|(_, slug)| slug))
45            .unwrap_or(&req.model)
46            .to_string();
47
48        let input = build_input_items(req);
49        let tools = build_tools(&req.tools);
50
51        ResponsesRequest {
52            model,
53            input,
54            instructions: req.system.clone(),
55            tools,
56            stream: true,
57            store: false,
58            reasoning: Some(ReasoningConfig {
59                effort: Some("medium".into()),
60                summary: "auto".into(),
61            }),
62            text: Some(TextConfig {
63                verbosity: "medium".into(),
64            }),
65            include: Some(vec!["reasoning.encrypted_content".into()]),
66        }
67    }
68
69    fn build_request(&self, req: &LlmRequest) -> reqwest::RequestBuilder {
70        let body = self.build_body(req);
71        self.client
72            .post(format!("{CODEX_BASE}/responses"))
73            .bearer_auth(&self.access_token)
74            .header("chatgpt-account-id", &self.account_id)
75            .header("originator", "codex_cli_rs")
76            .header("OpenAI-Beta", "responses=experimental")
77            .header("accept", "text/event-stream")
78            .json(&body)
79    }
80}
81
82fn build_input_items(req: &LlmRequest) -> Vec<InputItem> {
83    let mut tool_names: std::collections::HashMap<String, String> =
84        std::collections::HashMap::new();
85    for m in &req.messages {
86        if m.role == MessageRole::Assistant {
87            for p in &m.parts {
88                if let MessagePart::ToolUse { id, name, .. } = p {
89                    // Codex Responses API requires "_" in function names.
90                    tool_names.insert(id.clone(), crate::tool_naming::to_wire(name));
91                }
92            }
93        }
94    }
95
96    let mut items: Vec<InputItem> = Vec::new();
97
98    for m in &req.messages {
99        match m.role {
100            MessageRole::User => {
101                let content = build_user_content(&m.parts);
102                items.push(InputItem {
103                    role: Some("user".into()),
104                    content: Some(content),
105                    item_type: Some("message".into()),
106                    call_id: None,
107                    name: None,
108                    arguments: None,
109                    output: None,
110                });
111            }
112            MessageRole::Assistant => {
113                let (text, tool_calls) = split_assistant_parts(&m.parts);
114                if let Some(t) = text {
115                    items.push(InputItem {
116                        role: Some("assistant".into()),
117                        content: Some(t),
118                        item_type: Some("message".into()),
119                        call_id: None,
120                        name: None,
121                        arguments: None,
122                        output: None,
123                    });
124                }
125                for tc in tool_calls {
126                    items.push(InputItem {
127                        role: None,
128                        content: None,
129                        item_type: Some("function_call".into()),
130                        call_id: Some(tc.id),
131                        name: Some(tc.name),
132                        arguments: Some(tc.arguments),
133                        output: None,
134                    });
135                }
136            }
137            MessageRole::Tool => {
138                for p in &m.parts {
139                    if let MessagePart::ToolResult {
140                        tool_use_id,
141                        content,
142                        ..
143                    } = p
144                    {
145                        let name = tool_names.get(tool_use_id).cloned();
146                        items.push(InputItem {
147                            role: None,
148                            content: None,
149                            item_type: Some("function_call_output".into()),
150                            call_id: Some(tool_use_id.clone()),
151                            name,
152                            arguments: None,
153                            output: Some(content.clone()),
154                        });
155                    }
156                }
157            }
158            MessageRole::System => {}
159        }
160    }
161
162    items
163}
164
165fn build_user_content(parts: &[MessagePart]) -> String {
166    let mut parts_out: Vec<serde_json::Value> = Vec::new();
167    for p in parts {
168        match p {
169            MessagePart::Text { text } => {
170                parts_out.push(serde_json::json!({"type": "input_text", "text": text}));
171            }
172            MessagePart::Image { source } => {
173                let data = match &source.data {
174                    ImageData::Base64 { data } => data.clone(),
175                    ImageData::Path { path } => {
176                        let bytes = std::fs::read(path).unwrap_or_default();
177                        use base64::Engine;
178                        base64::engine::general_purpose::STANDARD.encode(&bytes)
179                    }
180                };
181                parts_out.push(serde_json::json!({
182                    "type": "input_image",
183                    "image_url": format!("data:{};base64,{}", source.media_type, data)
184                }));
185            }
186            MessagePart::CompactSummary { summary, .. } => {
187                parts_out.push(serde_json::json!({"type": "input_text", "text": summary}));
188            }
189            _ => {}
190        }
191    }
192    if parts_out.len() == 1
193        && parts_out[0].get("type").and_then(|v| v.as_str()) == Some("input_text")
194    {
195        parts_out[0]["text"].as_str().unwrap_or("").to_string()
196    } else {
197        serde_json::to_string(&parts_out).unwrap_or_default()
198    }
199}
200
201struct AssistantSplit {
202    id: String,
203    name: String,
204    arguments: String,
205}
206
207fn split_assistant_parts(parts: &[MessagePart]) -> (Option<String>, Vec<AssistantSplit>) {
208    let mut text = String::new();
209    let mut tools: Vec<AssistantSplit> = Vec::new();
210    for p in parts {
211        match p {
212            MessagePart::Text { text: t } => text.push_str(t),
213            MessagePart::ToolUse { id, name, input } => tools.push(AssistantSplit {
214                id: id.clone(),
215                // Codex Responses API requires "_" in function names.
216                name: crate::tool_naming::to_wire(name),
217                arguments: serde_json::to_string(input).unwrap_or_default(),
218            }),
219            _ => {}
220        }
221    }
222    let text_out = if text.is_empty() { None } else { Some(text) };
223    (text_out, tools)
224}
225
226fn build_tools(tools: &[crate::tool::ToolSpec]) -> Vec<ResponsesTool> {
227    tools
228        .iter()
229        .map(|t| ResponsesTool {
230            r#type: "function".into(),
231            // Codex Responses API rejects "." in function names (e.g. "fs.read").
232            // Replace with "_" for outbound, revert on inbound.
233            name: crate::tool_naming::to_wire(&t.name),
234            description: t.description.clone(),
235            parameters: t.input_schema.clone(),
236        })
237        .collect()
238}
239
240impl Provider for CodexProvider {
241    fn name(&self) -> &str {
242        &self.name
243    }
244
245    fn call<'a>(&'a self, req: LlmRequest) -> BoxFut<'a, Result<AssistantMessage, RuntimeError>> {
246        // The Codex backend always uses streaming (Responses API with store=false).
247        // We call streaming internally and collect the result.
248        let observable = self.call_streaming(req);
249        Box::pin(async move {
250            // Drop the broadcast events — callers of `call()` don't consume them.
251            let _events = observable.events;
252            observable.output.await
253        })
254    }
255
256    fn call_streaming(&self, req: LlmRequest) -> Observable<AssistantMessage> {
257        let request = self.build_request(&req);
258        let turn_id = turn_id_from_req(&req);
259        let streaming_tools = req.tools.clone();
260        let (tx, events) = broadcast::channel(DEFAULT_STREAM_BUFFER);
261        let cancel = CancellationToken::new();
262        let cancel_for_task = cancel.clone();
263
264        let output: BoxFut<'static, Result<AssistantMessage, RuntimeError>> =
265            Box::pin(async move {
266                use eventsource_stream::Eventsource;
267                use futures::StreamExt;
268
269                let resp = tokio::select! {
270                    biased;
271                    _ = cancel_for_task.cancelled() => {
272                        return Err(RuntimeError::Cancelled("codex cancelled before send".into()));
273                    }
274                    r = request.send() => r.map_err(net_err)?,
275                };
276                let status = resp.status();
277                if !status.is_success() {
278                    let body_text = resp.text().await.unwrap_or_default();
279                    return Err(RuntimeError::ToolFailed(format!(
280                        "codex http {status}: {body_text}"
281                    )));
282                }
283
284                let mut stream = resp.bytes_stream().eventsource();
285
286                let mut acc_text = String::new();
287                let mut acc_thinking = String::new();
288                let mut cumulative = 0u64;
289                let mut final_usage: Option<ResponsesUsage> = None;
290                let mut resp_model: Option<String> = None;
291                let mut resp_id: Option<String> = None;
292                let mut stop_reason = StopReason::End;
293
294                let mut partial_tool_calls: Vec<PartialToolCall> = Vec::new();
295
296                while let Some(event) = tokio::select! {
297                    biased;
298                    _ = cancel_for_task.cancelled() => None,
299                    next = stream.next() => next,
300                } {
301                    let event =
302                        event.map_err(|e| RuntimeError::ToolFailed(format!("codex sse: {e}")))?;
303                    if event.data.is_empty() || event.data == "[DONE]" {
304                        continue;
305                    }
306                    let parsed: serde_json::Value = match serde_json::from_str(&event.data) {
307                        Ok(v) => v,
308                        Err(_) => continue,
309                    };
310
311                    let ev_type = parsed["type"].as_str().unwrap_or("");
312
313                    match ev_type {
314                        "response.output_text.delta" => {
315                            if let Some(delta) = parsed["delta"].as_str() {
316                                acc_text.push_str(delta);
317                                cumulative += estimate_tokens(delta);
318                                let _ = tx.send(NodeEvent::LlmChunk {
319                                    text: delta.to_string(),
320                                    cumulative_tokens: cumulative,
321                                });
322                            }
323                        }
324
325                        "response.reasoning_text.delta" => {
326                            if let Some(delta) = parsed["delta"].as_str() {
327                                acc_thinking.push_str(delta);
328                                let _ = tx.send(NodeEvent::ThinkingChunk {
329                                    text: delta.to_string(),
330                                });
331                            }
332                        }
333
334                        "response.output_item.added" => {
335                            if let Some(item) = parsed.get("item")
336                                && item["type"].as_str() == Some("function_call")
337                            {
338                                let idx = parsed["output_index"].as_u64().unwrap_or(0) as usize;
339                                while partial_tool_calls.len() <= idx {
340                                    partial_tool_calls.push(PartialToolCall::default());
341                                }
342                                let slot = &mut partial_tool_calls[idx];
343                                slot.id = item["call_id"].as_str().unwrap_or("").to_string();
344                                // Convert "_" back to "." for atman tool names.
345                                slot.name = item["name"].as_str().unwrap_or("").to_string();
346                            }
347                        }
348
349                        "response.function_call_arguments.delta" => {
350                            let idx = parsed["output_index"].as_u64().unwrap_or(0) as usize;
351                            while partial_tool_calls.len() <= idx {
352                                partial_tool_calls.push(PartialToolCall::default());
353                            }
354                            if let Some(delta) = parsed["delta"].as_str() {
355                                partial_tool_calls[idx].arguments.push_str(delta);
356                            }
357                        }
358
359                        "response.completed" => {
360                            if let Some(r) = parsed.get("response") {
361                                resp_model = r["model"].as_str().map(|s| s.to_string());
362                                resp_id = r["id"].as_str().map(|s| s.to_string());
363                                if let Some(u) = r.get("usage") {
364                                    final_usage =
365                                        serde_json::from_value::<ResponsesUsage>(u.clone()).ok();
366                                }
367                                if r["status"].as_str() == Some("cancelled") {
368                                    stop_reason = StopReason::Cancelled;
369                                }
370                            }
371                        }
372
373                        "error" => {
374                            let msg = parsed["message"].as_str().unwrap_or("unknown codex error");
375                            return Err(RuntimeError::ToolFailed(msg.to_string()));
376                        }
377
378                        _ => {}
379                    }
380                }
381
382                if cancel_for_task.is_cancelled() {
383                    let _ = tx.send(NodeEvent::LlmDone {
384                        total_tokens: cumulative,
385                    });
386                    return Err(RuntimeError::Cancelled("codex cancelled mid-stream".into()));
387                }
388
389                let total_output = final_usage
390                    .as_ref()
391                    .and_then(|u| u.output_tokens)
392                    .unwrap_or(cumulative);
393                let _ = tx.send(NodeEvent::LlmDone {
394                    total_tokens: total_output,
395                });
396
397                let mut parts: Vec<MessagePart> = Vec::new();
398                if !acc_thinking.is_empty() {
399                    parts.push(MessagePart::Thinking {
400                        thinking: acc_thinking,
401                        signature: None,
402                    });
403                }
404                if !acc_text.is_empty() {
405                    parts.push(MessagePart::Text { text: acc_text });
406                }
407                for tc in partial_tool_calls {
408                    if tc.name.is_empty() {
409                        continue;
410                    }
411                    let input: serde_json::Value = if tc.arguments.is_empty() {
412                        serde_json::Value::Object(Default::default())
413                    } else {
414                        serde_json::from_str(&tc.arguments).unwrap_or(serde_json::Value::Null)
415                    };
416                    parts.push(MessagePart::ToolUse {
417                        id: tc.id,
418                        name: crate::tool_naming::from_wire(&tc.name, &streaming_tools),
419                        input,
420                    });
421                }
422
423                let token_usage = final_usage.map(|u| TokenUsage {
424                    input: u.input_tokens.unwrap_or(0),
425                    cached_input: u
426                        .input_tokens_details
427                        .as_ref()
428                        .and_then(|d| d.cached_tokens)
429                        .unwrap_or(0),
430                    output: u.output_tokens.unwrap_or(0),
431                    cache_write: u
432                        .input_tokens_details
433                        .as_ref()
434                        .and_then(|d| d.cache_write_tokens)
435                        .unwrap_or(0),
436                    reasoning_tokens: u
437                        .output_tokens_details
438                        .as_ref()
439                        .and_then(|d| d.reasoning_tokens)
440                        .unwrap_or(0),
441                });
442
443                Ok(AssistantMessage {
444                    message: Message {
445                        role: MessageRole::Assistant,
446                        parts,
447                        turn_id,
448                        origin: MessageOrigin::User,
449                    },
450                    stop_reason,
451                    token_usage: token_usage.unwrap_or_default(),
452                    timing: CallTiming::default(),
453                    model: resp_model.unwrap_or_default(),
454                    response_id: resp_id,
455                })
456            });
457        Observable {
458            output,
459            events,
460            cancel,
461        }
462    }
463
464    fn discover_models(
465        &self,
466    ) -> crate::tool::BoxFut<'static, Vec<crate::provider::DiscoveredModel>> {
467        let access_token = self.access_token.clone();
468        let account_id = self.account_id.clone();
469        Box::pin(async move {
470            let client = reqwest::Client::new();
471            let resp = match client
472                .get("https://chatgpt.com/backend-api/wham/models")
473                .query(&[("client_version", "0.0.0")])
474                .bearer_auth(&access_token)
475                .header("ChatGPT-Account-Id", &account_id)
476                .send()
477                .await
478            {
479                Ok(r) => r,
480                Err(e) => {
481                    crate::notify!(
482                        warn,
483                        location = Inline,
484                        stack = dedupe("codex.models.fetch_failed", 60_000),
485                        "fetch codex models failed: {e:#}"
486                    );
487                    return vec![];
488                }
489            };
490            let Ok(body) = resp.json::<serde_json::Value>().await else {
491                return vec![];
492            };
493            let Some(list) = body["models"].as_array() else {
494                return vec![];
495            };
496            list.iter()
497                .filter_map(|m| {
498                    let slug = format!("codex/{}", m["slug"].as_str()?);
499                    if slug.is_empty() {
500                        return None;
501                    }
502                    let context_budget = m["context_window"].as_u64();
503                    let thinking = m["supported_reasoning_levels"]
504                        .as_array()
505                        .map(|a: &Vec<serde_json::Value>| !a.is_empty())
506                        .unwrap_or(false);
507                    Some(crate::provider::DiscoveredModel {
508                        slug,
509                        context_budget,
510                        thinking,
511                    })
512                })
513                .collect()
514        })
515    }
516
517    fn test_connection(&self) -> BoxFut<'_, Result<String, String>> {
518        let access_token = self.access_token.clone();
519        let account_id = self.account_id.clone();
520        let name = self.name.clone();
521        Box::pin(async move {
522            let client = reqwest::Client::builder()
523                .timeout(std::time::Duration::from_secs(15))
524                .build()
525                .map_err(|e| e.to_string())?;
526            let resp = client
527                .get("https://chatgpt.com/backend-api/wham/models")
528                .query(&[("client_version", "0.0.0")])
529                .bearer_auth(&access_token)
530                .header("ChatGPT-Account-Id", &account_id)
531                .send()
532                .await
533                .map_err(|e| format!("connection failed — {e}"))?;
534            let status = resp.status();
535            if status.is_success() {
536                Ok(format!("\"{name}\" responded OK"))
537            } else {
538                let body = resp.text().await.unwrap_or_default();
539                Err(format!(
540                    "returned {status} — {}",
541                    &body[..body.len().min(200)]
542                ))
543            }
544        })
545    }
546}
547
548const CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
549const CODEX_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
550const CODEX_AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize";
551const CODEX_REDIRECT_URI: &str = "http://localhost:1455/auth/callback";
552
553impl crate::oauth::OAuthProvider for CodexProvider {
554    fn authorize_url() -> (String, crate::oauth::Pkce, String) {
555        let pkce = crate::oauth::Pkce::generate();
556        let state = crate::oauth::generate_state();
557        let url = format!(
558            "{}?response_type=code&client_id={}&redirect_uri={}&code_challenge={}&code_challenge_method=S256&state={}&scope=openid+profile+email+offline_access",
559            CODEX_AUTHORIZE_URL, CODEX_CLIENT_ID, CODEX_REDIRECT_URI, pkce.challenge, state
560        );
561        (url, pkce, state)
562    }
563
564    fn exchange_code(
565        code: &str,
566        verifier: &str,
567    ) -> std::pin::Pin<
568        Box<dyn std::future::Future<Output = anyhow::Result<crate::oauth::TokenResult>> + Send>,
569    > {
570        let code = code.to_string();
571        let verifier = verifier.to_string();
572        Box::pin(async move {
573            let client = reqwest::Client::new();
574            let resp = client
575                .post(CODEX_TOKEN_URL)
576                .form(&[
577                    ("grant_type", "authorization_code"),
578                    ("code", &code),
579                    ("redirect_uri", CODEX_REDIRECT_URI),
580                    ("client_id", CODEX_CLIENT_ID),
581                    ("code_verifier", &verifier),
582                ])
583                .send()
584                .await
585                .context("token exchange request")?;
586
587            let status = resp.status();
588            let body_text = resp.text().await.unwrap_or_default();
589            if !status.is_success() {
590                anyhow::bail!("token exchange failed (HTTP {status}): {body_text}");
591            }
592
593            #[derive(serde::Deserialize)]
594            struct R {
595                access_token: String,
596                refresh_token: Option<String>,
597                id_token: Option<String>,
598            }
599            let data: R = serde_json::from_str(&body_text).context("parse token response")?;
600
601            let expires_at = crate::oauth::parse_jwt_exp(&data.access_token)
602                .unwrap_or_else(|| chrono::Utc::now().timestamp() + 3600);
603            let account = data
604                .id_token
605                .as_deref()
606                .and_then(crate::oauth::extract_account_from_id_token);
607
608            Ok(crate::oauth::TokenResult {
609                access_token: data.access_token,
610                refresh_token: data.refresh_token,
611                expires_at,
612                account,
613            })
614        })
615    }
616
617    fn refresh_token(
618        token: &str,
619    ) -> std::pin::Pin<
620        Box<dyn std::future::Future<Output = anyhow::Result<crate::oauth::TokenResult>> + Send>,
621    > {
622        let token = token.to_string();
623        Box::pin(async move {
624            let client = reqwest::Client::new();
625            let resp = client
626                .post(CODEX_TOKEN_URL)
627                .form(&[
628                    ("grant_type", "refresh_token"),
629                    ("refresh_token", &token),
630                    ("client_id", CODEX_CLIENT_ID),
631                ])
632                .send()
633                .await
634                .context("token refresh request")?;
635
636            let status = resp.status();
637            let body_text = resp.text().await.unwrap_or_default();
638            if !status.is_success() {
639                anyhow::bail!("token refresh failed (HTTP {status}): {body_text}");
640            }
641
642            #[derive(serde::Deserialize)]
643            struct R {
644                access_token: String,
645                refresh_token: Option<String>,
646                id_token: Option<String>,
647            }
648            let data: R = serde_json::from_str(&body_text).context("parse refresh response")?;
649
650            let expires_at = crate::oauth::parse_jwt_exp(&data.access_token)
651                .unwrap_or_else(|| chrono::Utc::now().timestamp() + 3600);
652            let account = data
653                .id_token
654                .as_deref()
655                .and_then(crate::oauth::extract_account_from_id_token);
656
657            Ok(crate::oauth::TokenResult {
658                access_token: data.access_token,
659                refresh_token: data.refresh_token,
660                expires_at,
661                account,
662            })
663        })
664    }
665
666    fn from_stored(stored: &crate::auth_store::StoredProvider) -> Self {
667        let account_id = stored.account.as_deref().unwrap_or("");
668        CodexProvider::new(&stored.name, &stored.access_token, account_id)
669    }
670}
671
672#[derive(Default)]
673struct PartialToolCall {
674    id: String,
675    name: String,
676    arguments: String,
677}
678
679fn turn_id_from_req(req: &LlmRequest) -> TurnId {
680    req.messages
681        .first()
682        .map(|m| m.turn_id.clone())
683        .unwrap_or_else(TurnId::now)
684}
685
686fn net_err(e: reqwest::Error) -> RuntimeError {
687    RuntimeError::ToolFailed(format!("codex net: {e}"))
688}
689
690#[derive(Serialize)]
691struct ResponsesRequest {
692    model: String,
693    input: Vec<InputItem>,
694    #[serde(skip_serializing_if = "Option::is_none")]
695    instructions: Option<String>,
696    #[serde(skip_serializing_if = "Vec::is_empty")]
697    tools: Vec<ResponsesTool>,
698    stream: bool,
699    store: bool,
700    #[serde(skip_serializing_if = "Option::is_none")]
701    reasoning: Option<ReasoningConfig>,
702    #[serde(skip_serializing_if = "Option::is_none")]
703    text: Option<TextConfig>,
704    #[serde(skip_serializing_if = "Option::is_none")]
705    include: Option<Vec<String>>,
706}
707
708#[derive(Serialize)]
709struct InputItem {
710    #[serde(skip_serializing_if = "Option::is_none")]
711    role: Option<String>,
712    #[serde(skip_serializing_if = "Option::is_none")]
713    content: Option<String>,
714    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
715    item_type: Option<String>,
716    #[serde(skip_serializing_if = "Option::is_none")]
717    call_id: Option<String>,
718    #[serde(skip_serializing_if = "Option::is_none")]
719    name: Option<String>,
720    #[serde(skip_serializing_if = "Option::is_none")]
721    arguments: Option<String>,
722    #[serde(skip_serializing_if = "Option::is_none")]
723    output: Option<String>,
724}
725
726#[derive(Serialize)]
727struct ResponsesTool {
728    #[serde(rename = "type")]
729    r#type: String,
730    name: String,
731    #[serde(skip_serializing_if = "Option::is_none")]
732    description: Option<String>,
733    parameters: serde_json::Value,
734}
735
736#[derive(Serialize)]
737struct ReasoningConfig {
738    #[serde(skip_serializing_if = "Option::is_none")]
739    effort: Option<String>,
740    summary: String,
741}
742
743#[derive(Serialize)]
744struct TextConfig {
745    verbosity: String,
746}
747
748#[derive(Deserialize, Default)]
749struct ResponsesUsage {
750    #[serde(default)]
751    input_tokens: Option<u64>,
752    #[serde(default)]
753    output_tokens: Option<u64>,
754    #[serde(default)]
755    input_tokens_details: Option<InputTokensDetails>,
756    #[serde(default)]
757    output_tokens_details: Option<OutputTokensDetails>,
758}
759
760#[derive(Deserialize, Default)]
761struct InputTokensDetails {
762    #[serde(default)]
763    cached_tokens: Option<u64>,
764    #[serde(default)]
765    cache_write_tokens: Option<u64>,
766}
767
768#[derive(Deserialize, Default)]
769struct OutputTokensDetails {
770    #[serde(default)]
771    reasoning_tokens: Option<u64>,
772}