1pub use codex_backend_openapi_models::models::ConfigBundleResponse;
2pub use codex_backend_openapi_models::models::CreditStatusDetails;
3pub use codex_backend_openapi_models::models::DeliveredConfigToml;
4pub use codex_backend_openapi_models::models::DeliveredManagedLayers;
5pub use codex_backend_openapi_models::models::DeliveredRequirementsToml;
6pub use codex_backend_openapi_models::models::DeliveredTomlFragment;
7pub use codex_backend_openapi_models::models::PaginatedListTaskListItem;
8pub use codex_backend_openapi_models::models::PlanType;
9pub use codex_backend_openapi_models::models::RateLimitReachedKind;
10pub use codex_backend_openapi_models::models::RateLimitStatusDetails;
11pub use codex_backend_openapi_models::models::RateLimitStatusPayload;
12pub use codex_backend_openapi_models::models::RateLimitWindowSnapshot;
13pub use codex_backend_openapi_models::models::SpendControlLimitDetails;
14pub use codex_backend_openapi_models::models::TaskListItem;
15
16use codex_protocol::protocol::RateLimitSnapshot;
17use serde::Deserialize;
18use serde::de::Deserializer;
19use serde_json::Value;
20use std::collections::HashMap;
21
22#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
23pub struct RateLimitResetCreditsSummary {
24 pub available_count: i64,
25}
26
27#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
28pub struct RateLimitResetCreditsDetails {
29 pub credits: Vec<RateLimitResetCreditDetails>,
30 pub available_count: i64,
31}
32
33#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
34pub struct RateLimitResetCreditDetails {
35 pub id: String,
36 pub reset_type: String,
37 pub status: String,
38 pub granted_at: String,
39 pub expires_at: Option<String>,
40 pub title: Option<String>,
41 pub description: Option<String>,
42}
43
44#[derive(Clone, Debug, PartialEq)]
45pub struct RateLimitsWithResetCredits {
46 pub rate_limits: Vec<RateLimitSnapshot>,
47 pub rate_limit_reset_credits: Option<RateLimitResetCreditsSummary>,
48}
49
50#[derive(Clone, Debug, Deserialize, PartialEq)]
51pub(crate) struct RateLimitStatusWithResetCredits {
52 #[serde(flatten)]
53 pub rate_limits: RateLimitStatusPayload,
54 pub rate_limit_reset_credits: Option<RateLimitResetCreditsSummary>,
55}
56
57#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
58pub struct CodexWorkspaceMessagesResponse {
59 #[serde(default)]
60 pub messages: Vec<CodexWorkspaceMessage>,
61}
62
63#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
65pub struct CodexUserSettingsResponse {
66 #[serde(default)]
70 pub commit_attribution_enabled: bool,
71}
72
73#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
74pub struct CodexWorkspaceMessage {
75 pub message_id: String,
76 pub message_type: CodexWorkspaceMessageType,
77 pub message_body: String,
78 #[serde(default)]
79 pub created_at: Option<String>,
80 #[serde(default)]
81 pub archived_at: Option<String>,
82}
83
84#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
85#[serde(rename_all = "snake_case")]
86pub enum ConsumeRateLimitResetCreditCode {
87 Reset,
88 NothingToReset,
89 NoCredit,
90 AlreadyRedeemed,
91}
92
93#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
94pub struct ConsumeRateLimitResetCreditResponse {
95 pub code: ConsumeRateLimitResetCreditCode,
96 #[serde(default)]
97 pub windows_reset: i64,
98}
99
100#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
101#[serde(rename_all = "snake_case")]
102pub enum CodexWorkspaceMessageType {
103 Headline,
104 Announcement,
105 #[serde(other)]
106 Unknown,
107}
108
109#[derive(Clone, Debug)]
110pub struct AccountsCheckResponse {
111 pub accounts: Vec<AccountEntry>,
112 pub account_ordering: Vec<String>,
113 pub default_account_id: Option<String>,
114}
115
116#[derive(Clone, Debug, Deserialize)]
117pub struct AccountEntry {
118 pub id: String,
119 #[serde(default)]
120 pub name: Option<String>,
121 #[serde(default)]
122 pub profile_picture_url: Option<String>,
123 #[serde(default)]
124 pub structure: String,
125}
126
127#[derive(Deserialize)]
128struct RawAccountsCheckResponse {
129 #[serde(default)]
130 accounts: RawAccounts,
131 #[serde(default)]
132 account_ordering: Vec<String>,
133 #[serde(default)]
134 default_account_id: Option<String>,
135}
136
137#[derive(Deserialize)]
138#[serde(untagged)]
139enum RawAccounts {
140 List(Vec<AccountEntry>),
141 Map(HashMap<String, ChatGptAccountEntry>),
142}
143
144impl Default for RawAccounts {
145 fn default() -> Self {
146 Self::List(Vec::new())
147 }
148}
149
150#[derive(Deserialize)]
151struct ChatGptAccountEntry {
152 account: ChatGptAccountInfo,
153}
154
155#[derive(Deserialize)]
156struct ChatGptAccountInfo {
157 account_id: Option<String>,
158 #[serde(default)]
159 name: Option<String>,
160 #[serde(default)]
161 profile_picture_url: Option<String>,
162 #[serde(default)]
163 structure: String,
164}
165
166impl<'de> Deserialize<'de> for AccountsCheckResponse {
167 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
168 where
169 D: Deserializer<'de>,
170 {
171 let raw = RawAccountsCheckResponse::deserialize(deserializer)?;
172 let accounts = match raw.accounts {
173 RawAccounts::List(accounts) => accounts,
174 RawAccounts::Map(mut accounts) => raw
175 .account_ordering
176 .iter()
177 .filter_map(|account_id| {
178 let account = accounts.remove(account_id)?.account;
179 Some(AccountEntry {
180 id: account.account_id?,
181 name: account.name,
182 profile_picture_url: account.profile_picture_url,
183 structure: account.structure,
184 })
185 })
186 .collect(),
187 };
188 Ok(Self {
189 accounts,
190 account_ordering: raw.account_ordering,
191 default_account_id: raw.default_account_id,
192 })
193 }
194}
195
196#[derive(Clone, Debug, Deserialize)]
200pub struct CodeTaskDetailsResponse {
201 #[serde(default)]
202 pub current_user_turn: Option<Turn>,
203 #[serde(default)]
204 pub current_assistant_turn: Option<Turn>,
205 #[serde(default)]
206 pub current_diff_task_turn: Option<Turn>,
207}
208
209#[derive(Clone, Debug, Default, Deserialize)]
210pub struct Turn {
211 #[serde(default)]
212 pub id: Option<String>,
213 #[serde(default)]
214 pub attempt_placement: Option<i64>,
215 #[serde(default, rename = "turn_status")]
216 pub turn_status: Option<String>,
217 #[serde(default, deserialize_with = "deserialize_vec")]
218 pub sibling_turn_ids: Vec<String>,
219 #[serde(default, deserialize_with = "deserialize_vec")]
220 pub input_items: Vec<TurnItem>,
221 #[serde(default, deserialize_with = "deserialize_vec")]
222 pub output_items: Vec<TurnItem>,
223 #[serde(default)]
224 pub worklog: Option<Worklog>,
225 #[serde(default)]
226 pub error: Option<TurnError>,
227}
228
229#[derive(Clone, Debug, Default, Deserialize)]
230pub struct TurnItem {
231 #[serde(rename = "type", default)]
232 pub kind: String,
233 #[serde(default)]
234 pub role: Option<String>,
235 #[serde(default, deserialize_with = "deserialize_vec")]
236 pub content: Vec<ContentFragment>,
237 #[serde(default)]
238 pub diff: Option<String>,
239 #[serde(default)]
240 pub output_diff: Option<DiffPayload>,
241}
242
243#[derive(Clone, Debug, Deserialize)]
244#[serde(untagged)]
245pub enum ContentFragment {
246 Structured(StructuredContent),
247 Text(String),
248}
249
250#[derive(Clone, Debug, Default, Deserialize)]
251pub struct StructuredContent {
252 #[serde(rename = "content_type", default)]
253 pub content_type: Option<String>,
254 #[serde(default)]
255 pub text: Option<String>,
256}
257
258#[derive(Clone, Debug, Default, Deserialize)]
259pub struct DiffPayload {
260 #[serde(default)]
261 pub diff: Option<String>,
262}
263
264#[derive(Clone, Debug, Default, Deserialize)]
265pub struct Worklog {
266 #[serde(default, deserialize_with = "deserialize_vec")]
267 pub messages: Vec<WorklogMessage>,
268}
269
270#[derive(Clone, Debug, Default, Deserialize)]
271pub struct WorklogMessage {
272 #[serde(default)]
273 pub author: Option<Author>,
274 #[serde(default)]
275 pub content: Option<WorklogContent>,
276}
277
278#[derive(Clone, Debug, Default, Deserialize)]
279pub struct Author {
280 #[serde(default)]
281 pub role: Option<String>,
282}
283
284#[derive(Clone, Debug, Default, Deserialize)]
285pub struct WorklogContent {
286 #[serde(default)]
287 pub parts: Vec<ContentFragment>,
288}
289
290#[derive(Clone, Debug, Default, Deserialize)]
291pub struct TurnError {
292 #[serde(default)]
293 pub code: Option<String>,
294 #[serde(default)]
295 pub message: Option<String>,
296}
297
298impl ContentFragment {
299 fn text(&self) -> Option<&str> {
300 match self {
301 ContentFragment::Structured(inner) => {
302 if inner
303 .content_type
304 .as_deref()
305 .map(|ct| ct.eq_ignore_ascii_case("text"))
306 .unwrap_or(false)
307 {
308 inner.text.as_deref().filter(|s| !s.is_empty())
309 } else {
310 None
311 }
312 }
313 ContentFragment::Text(raw) => {
314 if raw.trim().is_empty() {
315 None
316 } else {
317 Some(raw.as_str())
318 }
319 }
320 }
321 }
322}
323
324impl TurnItem {
325 fn text_values(&self) -> Vec<String> {
326 self.content
327 .iter()
328 .filter_map(|fragment| fragment.text().map(str::to_string))
329 .collect()
330 }
331
332 fn diff_text(&self) -> Option<String> {
333 if self.kind == "output_diff" {
334 if let Some(diff) = &self.diff
335 && !diff.is_empty()
336 {
337 return Some(diff.clone());
338 }
339 } else if self.kind == "pr"
340 && let Some(payload) = &self.output_diff
341 && let Some(diff) = &payload.diff
342 && !diff.is_empty()
343 {
344 return Some(diff.clone());
345 }
346 None
347 }
348}
349
350impl Turn {
351 fn unified_diff(&self) -> Option<String> {
352 self.output_items.iter().find_map(TurnItem::diff_text)
353 }
354
355 fn message_texts(&self) -> Vec<String> {
356 let mut out: Vec<String> = self
357 .output_items
358 .iter()
359 .filter(|item| item.kind == "message")
360 .flat_map(TurnItem::text_values)
361 .collect();
362
363 if let Some(log) = &self.worklog {
364 for message in &log.messages {
365 if message.is_assistant() {
366 out.extend(message.text_values());
367 }
368 }
369 }
370
371 out
372 }
373
374 fn user_prompt(&self) -> Option<String> {
375 let parts: Vec<String> = self
376 .input_items
377 .iter()
378 .filter(|item| item.kind == "message")
379 .filter(|item| {
380 item.role
381 .as_deref()
382 .map(|r| r.eq_ignore_ascii_case("user"))
383 .unwrap_or(true)
384 })
385 .flat_map(TurnItem::text_values)
386 .collect();
387
388 if parts.is_empty() {
389 None
390 } else {
391 Some(parts.join(
392 "
393
394",
395 ))
396 }
397 }
398
399 fn error_summary(&self) -> Option<String> {
400 self.error.as_ref().and_then(TurnError::summary)
401 }
402}
403
404impl WorklogMessage {
405 fn is_assistant(&self) -> bool {
406 self.author
407 .as_ref()
408 .and_then(|a| a.role.as_deref())
409 .map(|role| role.eq_ignore_ascii_case("assistant"))
410 .unwrap_or(false)
411 }
412
413 fn text_values(&self) -> Vec<String> {
414 self.content
415 .as_ref()
416 .map(|content| {
417 content
418 .parts
419 .iter()
420 .filter_map(|fragment| fragment.text().map(str::to_string))
421 .collect()
422 })
423 .unwrap_or_default()
424 }
425}
426
427impl TurnError {
428 fn summary(&self) -> Option<String> {
429 let code = self.code.as_deref().unwrap_or("");
430 let message = self.message.as_deref().unwrap_or("");
431 match (code.is_empty(), message.is_empty()) {
432 (true, true) => None,
433 (false, true) => Some(code.to_string()),
434 (true, false) => Some(message.to_string()),
435 (false, false) => Some(format!("{code}: {message}")),
436 }
437 }
438}
439
440pub trait CodeTaskDetailsResponseExt {
441 fn unified_diff(&self) -> Option<String>;
443 fn assistant_text_messages(&self) -> Vec<String>;
445 fn user_text_prompt(&self) -> Option<String>;
447 fn assistant_error_message(&self) -> Option<String>;
449}
450
451impl CodeTaskDetailsResponseExt for CodeTaskDetailsResponse {
452 fn unified_diff(&self) -> Option<String> {
453 [
454 self.current_diff_task_turn.as_ref(),
455 self.current_assistant_turn.as_ref(),
456 ]
457 .into_iter()
458 .flatten()
459 .find_map(Turn::unified_diff)
460 }
461
462 fn assistant_text_messages(&self) -> Vec<String> {
463 let mut out = Vec::new();
464 for turn in [
465 self.current_diff_task_turn.as_ref(),
466 self.current_assistant_turn.as_ref(),
467 ]
468 .into_iter()
469 .flatten()
470 {
471 out.extend(turn.message_texts());
472 }
473 out
474 }
475
476 fn user_text_prompt(&self) -> Option<String> {
477 self.current_user_turn.as_ref().and_then(Turn::user_prompt)
478 }
479
480 fn assistant_error_message(&self) -> Option<String> {
481 self.current_assistant_turn
482 .as_ref()
483 .and_then(Turn::error_summary)
484 }
485}
486
487fn deserialize_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
488where
489 D: Deserializer<'de>,
490 T: Deserialize<'de>,
491{
492 Option::<Vec<T>>::deserialize(deserializer).map(Option::unwrap_or_default)
493}
494
495#[derive(Clone, Debug, Deserialize)]
496pub struct TurnAttemptsSiblingTurnsResponse {
497 #[serde(default)]
498 pub sibling_turns: Vec<HashMap<String, Value>>,
499}
500
501#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
502pub struct TokenUsageProfile {
503 pub stats: TokenUsageProfileStats,
504}
505
506#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
507pub struct TokenUsageProfileStats {
508 pub lifetime_tokens: Option<i64>,
509 pub peak_daily_tokens: Option<i64>,
510 pub longest_running_turn_sec: Option<i64>,
511 pub current_streak_days: Option<i64>,
512 pub longest_streak_days: Option<i64>,
513 pub daily_usage_buckets: Option<Vec<TokenUsageProfileDailyBucket>>,
514}
515
516#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
517pub struct TokenUsageProfileDailyBucket {
518 pub start_date: String,
519 pub tokens: i64,
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525 use pretty_assertions::assert_eq;
526
527 fn fixture(name: &str) -> CodeTaskDetailsResponse {
528 let json = match name {
529 "diff" => include_str!("../tests/fixtures/task_details_with_diff.json"),
530 "error" => include_str!("../tests/fixtures/task_details_with_error.json"),
531 other => panic!("unknown fixture {other}"),
532 };
533 serde_json::from_str(json).expect("fixture should deserialize")
534 }
535
536 #[test]
537 fn unified_diff_prefers_current_diff_task_turn() {
538 let details = fixture("diff");
539 let diff = details.unified_diff().expect("diff present");
540 assert!(diff.contains("diff --git"));
541 }
542
543 #[test]
544 fn unified_diff_falls_back_to_pr_output_diff() {
545 let details = fixture("error");
546 let diff = details.unified_diff().expect("diff from pr output");
547 assert!(diff.contains("lib.rs"));
548 }
549
550 #[test]
551 fn assistant_text_messages_extracts_text_content() {
552 let details = fixture("diff");
553 let messages = details.assistant_text_messages();
554 assert_eq!(messages, vec!["Assistant response".to_string()]);
555 }
556
557 #[test]
558 fn user_text_prompt_joins_parts_with_spacing() {
559 let details = fixture("diff");
560 let prompt = details.user_text_prompt().expect("prompt present");
561 assert_eq!(
562 prompt,
563 "First line
564
565Second line"
566 );
567 }
568
569 #[test]
570 fn assistant_error_message_combines_code_and_message() {
571 let details = fixture("error");
572 let msg = details
573 .assistant_error_message()
574 .expect("error should be present");
575 assert_eq!(msg, "APPLY_FAILED: Patch could not be applied");
576 }
577
578 #[test]
579 fn workspace_messages_response_deserializes_messages() {
580 let response: CodexWorkspaceMessagesResponse = serde_json::from_value(serde_json::json!({
581 "messages": [
582 {
583 "message_id": "headline-id",
584 "message_type": "headline",
585 "message_body": "Headline body",
586 "created_at": "2026-06-14T00:00:00Z",
587 "archived_at": null
588 },
589 {
590 "message_id": "announcement-id",
591 "message_type": "announcement",
592 "message_body": "Announcement body",
593 "created_at": "2026-06-14T01:00:00Z",
594 "archived_at": null
595 },
596 {
597 "message_id": "unknown-id",
598 "message_type": "unknown",
599 "message_body": "Unknown body"
600 }
601 ]
602 }))
603 .expect("workspace messages response should deserialize");
604
605 assert_eq!(
606 response,
607 CodexWorkspaceMessagesResponse {
608 messages: vec![
609 CodexWorkspaceMessage {
610 message_id: "headline-id".to_string(),
611 message_type: CodexWorkspaceMessageType::Headline,
612 message_body: "Headline body".to_string(),
613 created_at: Some("2026-06-14T00:00:00Z".to_string()),
614 archived_at: None,
615 },
616 CodexWorkspaceMessage {
617 message_id: "announcement-id".to_string(),
618 message_type: CodexWorkspaceMessageType::Announcement,
619 message_body: "Announcement body".to_string(),
620 created_at: Some("2026-06-14T01:00:00Z".to_string()),
621 archived_at: None,
622 },
623 CodexWorkspaceMessage {
624 message_id: "unknown-id".to_string(),
625 message_type: CodexWorkspaceMessageType::Unknown,
626 message_body: "Unknown body".to_string(),
627 created_at: None,
628 archived_at: None,
629 },
630 ],
631 }
632 );
633 }
634}