Skip to main content

codex_backend_client/
types.rs

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/// Authenticated Codex user settings used by CLI runtime policy.
64#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
65pub struct CodexUserSettingsResponse {
66    /// Server-computed effective commit-attribution policy.
67    ///
68    /// Older backend responses omit this field, which safely defaults to disabled.
69    #[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/// Hand-rolled models for the Cloud Tasks task-details response.
197/// The generated OpenAPI models are pretty bad. This is a half-step
198/// towards hand-rolling them.
199#[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    /// Attempt to extract a unified diff string from the assistant or diff turn.
442    fn unified_diff(&self) -> Option<String>;
443    /// Extract assistant text output messages (no diff) from current turns.
444    fn assistant_text_messages(&self) -> Vec<String>;
445    /// Extract the user's prompt text from the current user turn, when present.
446    fn user_text_prompt(&self) -> Option<String>;
447    /// Extract an assistant error message (if the turn failed and provided one).
448    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}