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| {
424                    let input_tokens = u.input_tokens.unwrap_or(0);
425                    let cached_input = u
426                        .input_tokens_details
427                        .as_ref()
428                        .and_then(|d| d.cached_tokens)
429                        .unwrap_or(0);
430                    TokenUsage {
431                        // Responses API reports input_tokens as the total input,
432                        // including cached tokens. TokenUsage.input is the
433                        // uncached portion so the shared window accounting can
434                        // add cached_input exactly once.
435                        input: normalize_input_tokens(input_tokens, cached_input),
436                        cached_input,
437                        output: u.output_tokens.unwrap_or(0),
438                        cache_write: u
439                            .input_tokens_details
440                            .as_ref()
441                            .and_then(|d| d.cache_write_tokens)
442                            .unwrap_or(0),
443                        reasoning_tokens: u
444                            .output_tokens_details
445                            .as_ref()
446                            .and_then(|d| d.reasoning_tokens)
447                            .unwrap_or(0),
448                    }
449                });
450
451                Ok(AssistantMessage {
452                    message: Message {
453                        role: MessageRole::Assistant,
454                        parts,
455                        turn_id,
456                        origin: MessageOrigin::User,
457                    },
458                    stop_reason,
459                    token_usage: token_usage.unwrap_or_default(),
460                    timing: CallTiming::default(),
461                    model: resp_model.unwrap_or_default(),
462                    response_id: resp_id,
463                })
464            });
465        Observable {
466            output,
467            events,
468            cancel,
469        }
470    }
471
472    fn discover_models(
473        &self,
474    ) -> crate::tool::BoxFut<'static, Vec<crate::provider::DiscoveredModel>> {
475        let access_token = self.access_token.clone();
476        let account_id = self.account_id.clone();
477        Box::pin(async move {
478            let client = reqwest::Client::new();
479            let resp = match client
480                .get("https://chatgpt.com/backend-api/wham/models")
481                .query(&[("client_version", "0.0.0")])
482                .bearer_auth(&access_token)
483                .header("ChatGPT-Account-Id", &account_id)
484                .send()
485                .await
486            {
487                Ok(r) => r,
488                Err(e) => {
489                    crate::notify!(
490                        warn,
491                        location = Inline,
492                        stack = dedupe("codex.models.fetch_failed", 60_000),
493                        "fetch codex models failed: {e:#}"
494                    );
495                    return vec![];
496                }
497            };
498            let Ok(body) = resp.json::<serde_json::Value>().await else {
499                return vec![];
500            };
501            let Some(list) = body["models"].as_array() else {
502                return vec![];
503            };
504            list.iter()
505                .filter_map(|m| {
506                    let slug = format!("codex/{}", m["slug"].as_str()?);
507                    if slug.is_empty() {
508                        return None;
509                    }
510                    let context_budget = m["context_window"].as_u64();
511                    let thinking = m["supported_reasoning_levels"]
512                        .as_array()
513                        .map(|a: &Vec<serde_json::Value>| !a.is_empty())
514                        .unwrap_or(false);
515                    Some(crate::provider::DiscoveredModel {
516                        slug,
517                        context_budget,
518                        thinking,
519                    })
520                })
521                .collect()
522        })
523    }
524
525    fn test_connection(&self) -> BoxFut<'_, Result<String, String>> {
526        let access_token = self.access_token.clone();
527        let account_id = self.account_id.clone();
528        let name = self.name.clone();
529        Box::pin(async move {
530            let client = reqwest::Client::builder()
531                .timeout(std::time::Duration::from_secs(15))
532                .build()
533                .map_err(|e| e.to_string())?;
534            let resp = client
535                .get("https://chatgpt.com/backend-api/wham/models")
536                .query(&[("client_version", "0.0.0")])
537                .bearer_auth(&access_token)
538                .header("ChatGPT-Account-Id", &account_id)
539                .send()
540                .await
541                .map_err(|e| format!("connection failed — {e}"))?;
542            let status = resp.status();
543            if status.is_success() {
544                Ok(format!("\"{name}\" responded OK"))
545            } else {
546                let body = resp.text().await.unwrap_or_default();
547                Err(format!(
548                    "returned {status} — {}",
549                    &body[..body.len().min(200)]
550                ))
551            }
552        })
553    }
554}
555
556const CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
557const CODEX_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
558const CODEX_AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize";
559const CODEX_REDIRECT_URI: &str = "http://localhost:1455/auth/callback";
560
561impl crate::oauth::OAuthProvider for CodexProvider {
562    fn authorize_url() -> (String, crate::oauth::Pkce, String) {
563        let pkce = crate::oauth::Pkce::generate();
564        let state = crate::oauth::generate_state();
565        let url = format!(
566            "{}?response_type=code&client_id={}&redirect_uri={}&code_challenge={}&code_challenge_method=S256&state={}&scope=openid+profile+email+offline_access",
567            CODEX_AUTHORIZE_URL, CODEX_CLIENT_ID, CODEX_REDIRECT_URI, pkce.challenge, state
568        );
569        (url, pkce, state)
570    }
571
572    fn exchange_code(
573        code: &str,
574        verifier: &str,
575    ) -> std::pin::Pin<
576        Box<dyn std::future::Future<Output = anyhow::Result<crate::oauth::TokenResult>> + Send>,
577    > {
578        let code = code.to_string();
579        let verifier = verifier.to_string();
580        Box::pin(async move {
581            let client = reqwest::Client::new();
582            let resp = client
583                .post(CODEX_TOKEN_URL)
584                .form(&[
585                    ("grant_type", "authorization_code"),
586                    ("code", &code),
587                    ("redirect_uri", CODEX_REDIRECT_URI),
588                    ("client_id", CODEX_CLIENT_ID),
589                    ("code_verifier", &verifier),
590                ])
591                .send()
592                .await
593                .context("token exchange request")?;
594
595            let status = resp.status();
596            let body_text = resp.text().await.unwrap_or_default();
597            if !status.is_success() {
598                anyhow::bail!("token exchange failed (HTTP {status}): {body_text}");
599            }
600
601            #[derive(serde::Deserialize)]
602            struct R {
603                access_token: String,
604                refresh_token: Option<String>,
605                id_token: Option<String>,
606            }
607            let data: R = serde_json::from_str(&body_text).context("parse token response")?;
608
609            let expires_at = crate::oauth::parse_jwt_exp(&data.access_token)
610                .unwrap_or_else(|| chrono::Utc::now().timestamp() + 3600);
611            let account = data
612                .id_token
613                .as_deref()
614                .and_then(crate::oauth::extract_account_from_id_token);
615
616            Ok(crate::oauth::TokenResult {
617                access_token: data.access_token,
618                refresh_token: data.refresh_token,
619                expires_at,
620                account,
621            })
622        })
623    }
624
625    fn refresh_token(
626        token: &str,
627    ) -> std::pin::Pin<
628        Box<dyn std::future::Future<Output = anyhow::Result<crate::oauth::TokenResult>> + Send>,
629    > {
630        let token = token.to_string();
631        Box::pin(async move {
632            let client = reqwest::Client::new();
633            let resp = client
634                .post(CODEX_TOKEN_URL)
635                .form(&[
636                    ("grant_type", "refresh_token"),
637                    ("refresh_token", &token),
638                    ("client_id", CODEX_CLIENT_ID),
639                ])
640                .send()
641                .await
642                .context("token refresh request")?;
643
644            let status = resp.status();
645            let body_text = resp.text().await.unwrap_or_default();
646            if !status.is_success() {
647                anyhow::bail!("token refresh failed (HTTP {status}): {body_text}");
648            }
649
650            #[derive(serde::Deserialize)]
651            struct R {
652                access_token: String,
653                refresh_token: Option<String>,
654                id_token: Option<String>,
655            }
656            let data: R = serde_json::from_str(&body_text).context("parse refresh response")?;
657
658            let expires_at = crate::oauth::parse_jwt_exp(&data.access_token)
659                .unwrap_or_else(|| chrono::Utc::now().timestamp() + 3600);
660            let account = data
661                .id_token
662                .as_deref()
663                .and_then(crate::oauth::extract_account_from_id_token);
664
665            Ok(crate::oauth::TokenResult {
666                access_token: data.access_token,
667                refresh_token: data.refresh_token,
668                expires_at,
669                account,
670            })
671        })
672    }
673
674    fn from_stored(stored: &crate::auth_store::StoredProvider) -> Self {
675        let account_id = stored.account.as_deref().unwrap_or("");
676        CodexProvider::new(&stored.id, &stored.access_token, account_id)
677    }
678}
679
680#[derive(Default)]
681struct PartialToolCall {
682    id: String,
683    name: String,
684    arguments: String,
685}
686
687fn turn_id_from_req(req: &LlmRequest) -> TurnId {
688    req.messages
689        .first()
690        .map(|m| m.turn_id.clone())
691        .unwrap_or_else(TurnId::now)
692}
693
694fn net_err(e: reqwest::Error) -> RuntimeError {
695    RuntimeError::ToolFailed(format!("codex net: {e}"))
696}
697
698fn normalize_input_tokens(total_input: u64, cached_input: u64) -> u64 {
699    total_input.saturating_sub(cached_input)
700}
701
702#[derive(Serialize)]
703struct ResponsesRequest {
704    model: String,
705    input: Vec<InputItem>,
706    #[serde(skip_serializing_if = "Option::is_none")]
707    instructions: Option<String>,
708    #[serde(skip_serializing_if = "Vec::is_empty")]
709    tools: Vec<ResponsesTool>,
710    stream: bool,
711    store: bool,
712    #[serde(skip_serializing_if = "Option::is_none")]
713    reasoning: Option<ReasoningConfig>,
714    #[serde(skip_serializing_if = "Option::is_none")]
715    text: Option<TextConfig>,
716    #[serde(skip_serializing_if = "Option::is_none")]
717    include: Option<Vec<String>>,
718}
719
720#[derive(Serialize)]
721struct InputItem {
722    #[serde(skip_serializing_if = "Option::is_none")]
723    role: Option<String>,
724    #[serde(skip_serializing_if = "Option::is_none")]
725    content: Option<String>,
726    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
727    item_type: Option<String>,
728    #[serde(skip_serializing_if = "Option::is_none")]
729    call_id: Option<String>,
730    #[serde(skip_serializing_if = "Option::is_none")]
731    name: Option<String>,
732    #[serde(skip_serializing_if = "Option::is_none")]
733    arguments: Option<String>,
734    #[serde(skip_serializing_if = "Option::is_none")]
735    output: Option<String>,
736}
737
738#[derive(Serialize)]
739struct ResponsesTool {
740    #[serde(rename = "type")]
741    r#type: String,
742    name: String,
743    #[serde(skip_serializing_if = "Option::is_none")]
744    description: Option<String>,
745    parameters: serde_json::Value,
746}
747
748#[derive(Serialize)]
749struct ReasoningConfig {
750    #[serde(skip_serializing_if = "Option::is_none")]
751    effort: Option<String>,
752    summary: String,
753}
754
755#[derive(Serialize)]
756struct TextConfig {
757    verbosity: String,
758}
759
760#[derive(Deserialize, Default)]
761struct ResponsesUsage {
762    #[serde(default)]
763    input_tokens: Option<u64>,
764    #[serde(default)]
765    output_tokens: Option<u64>,
766    #[serde(default)]
767    input_tokens_details: Option<InputTokensDetails>,
768    #[serde(default)]
769    output_tokens_details: Option<OutputTokensDetails>,
770}
771
772#[derive(Deserialize, Default)]
773struct InputTokensDetails {
774    #[serde(default)]
775    cached_tokens: Option<u64>,
776    #[serde(default)]
777    cache_write_tokens: Option<u64>,
778}
779
780#[derive(Deserialize, Default)]
781struct OutputTokensDetails {
782    #[serde(default)]
783    reasoning_tokens: Option<u64>,
784}
785
786#[cfg(test)]
787mod tests {
788    use super::normalize_input_tokens;
789
790    #[test]
791    fn input_tokens_exclude_cached_tokens_for_window_accounting() {
792        assert_eq!(normalize_input_tokens(100_000, 60_000), 40_000);
793    }
794
795    #[test]
796    fn cached_tokens_cannot_underflow_input_tokens() {
797        assert_eq!(normalize_input_tokens(10, 20), 0);
798    }
799}