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
17pub 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 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 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 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 let observable = self.call_streaming(req);
249 Box::pin(async move {
250 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 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 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}