Skip to main content

artifactize_core/llm/
codex.rs

1//! The `codex` backend: the Codex Responses endpoint with ChatGPT/Codex sign-in
2//! credentials, the way Pi's `openai-codex` provider calls it
3//! (pi-mono `packages/ai/src/api/openai-codex-responses.ts`).
4//!
5//! rig's ChatGPT dialect speaks the Codex request contract: an always-streamed
6//! Responses turn with `store:false`, encrypted reasoning replay, function tools, all
7//! system messages as top-level `instructions`, and an event stream that may name no
8//! content type. artifactize adds the headers Pi sends (`OpenAI-Beta`, an event-stream
9//! `accept`, its own `originator` and user agent, the ChatGPT account), and drops
10//! rig's default instructions so the review's own system prompt is the only one.
11//!
12//! Every request of one review carries the review's session id, as `prompt_cache_key`
13//! and as the `session-id` header the backend keys its prompt cache on. rig 0.43 sends a
14//! fresh `session_id` per request instead (0xPlaygrounds/rig#2719), which artifactize
15//! turns off.
16
17use std::path::{Path, PathBuf};
18
19use rig_core::{
20    Model,
21    completion::CompletionRequest,
22    error::ProviderError,
23    http_client::Builder,
24    providers::{
25        chatgpt,
26        openai::{
27            self,
28            wire::{CallerIdentity, DialectHooks},
29        },
30    },
31};
32mod errors;
33mod output;
34pub(crate) use output::Responses;
35
36use crate::auth::codex::{self as auth, ORIGINATOR};
37
38/// The Codex API root; requests go to `<root>/responses` and `<root>/models`.
39pub const BASE_URL: &str = "https://chatgpt.com/backend-api/codex";
40
41/// The Codex backend filters its model catalog by client version. artifactize asks
42/// for the catalog of the Codex release whose Responses protocol it follows.
43pub const CLIENT_VERSION: &str = "0.160.0";
44
45static HOOKS: DialectHooks = DialectHooks {
46    default_endpoint: None,
47    model_route: None,
48    completion_envelope: Some(envelope),
49    modality_envelope: None,
50};
51
52fn envelope(_: &openai::OpenAIConfig, request: &CompletionRequest, builder: Builder) -> Builder {
53    let builder = builder
54        .header("OpenAI-Beta", "responses=experimental")
55        .header("accept", "text/event-stream");
56    // TODO: once rig ships `OpenAIConfig::with_session_id` (0xPlaygrounds/rig#2722), pin
57    // the session there and drop this header and the `session_ids` override in `model`.
58    match session(request) {
59        Some(session) => builder.header("session-id", session),
60        None => builder,
61    }
62}
63
64/// The review's session id: the request's `prompt_cache_key` ([`super::Client::parameters`]).
65fn session(request: &CompletionRequest) -> Option<&str> {
66    request
67        .additional_params
68        .as_ref()?
69        .get("prompt_cache_key")?
70        .as_str()
71}
72
73/// `artifactize/<version> (<os> <arch>; artifactize)`, the shape rig gives gateways.
74pub fn user_agent() -> String {
75    format!(
76        "artifactize/{} ({}; {ORIGINATOR})",
77        env!("CARGO_PKG_VERSION"),
78        crate::platform::label(),
79    )
80}
81
82pub struct Codex {
83    model: crate::config::ModelId,
84    /// The validated API root; text only where the HTTP adapter wants it.
85    base: reqwest::Url,
86    state: PathBuf,
87    repo: PathBuf,
88    http: reqwest::Client,
89    /// Every access token a turn of this client sent: a provider error may echo any of them.
90    sent: std::sync::Mutex<Vec<String>>,
91}
92
93impl Codex {
94    pub(super) fn new(
95        model: &crate::config::ModelId,
96        base: &str,
97        state: &Path,
98        repo: &Path,
99    ) -> Result<Self, String> {
100        Ok(Self {
101            model: model.clone(),
102            base: reqwest::Url::parse(base)
103                .map_err(|_| format!("Invalid Codex endpoint: {base}"))?,
104            state: state.into(),
105            repo: repo.into(),
106            http: super::http_client()?,
107            sent: std::sync::Mutex::new(Vec::new()),
108        })
109    }
110
111    /// The access tokens this client's turns sent.
112    pub(super) fn sent(&self) -> Vec<String> {
113        self.sent.lock().expect("sent tokens lock").clone()
114    }
115
116    /// The Responses model for one turn, with credentials read (and refreshed) now.
117    /// Callers must never print or log the token it carries.
118    pub(super) async fn model(&self) -> Result<Model<Responses>, auth::TokenError> {
119        let token = auth::access_token(Some(&self.state), Some(&self.repo)).await?;
120        {
121            let mut sent = self.sent.lock().expect("sent tokens lock");
122            if !sent.contains(&token.access_token) {
123                sent.push(token.access_token.clone());
124            }
125        }
126        let mut dialect = chatgpt::DIALECT;
127        dialect.quirks.hooks = Some(&HOOKS);
128        // No fresh `session_id` per request, which the backend's cache ignores; the
129        // envelope sends the review's `session-id` instead.
130        if let Some(identity) = &mut dialect.quirks.identity {
131            identity.session_ids = false;
132        }
133        let mut config = openai::OpenAIConfig::with_key(&dialect, token.access_token)
134            .with_base_url(self.base.as_str().trim_end_matches('/'))
135            .with_account_id(token.account_id.to_string());
136        config.instructions = None;
137        config.identity = Some(CallerIdentity {
138            originator: ORIGINATOR.into(),
139            user_agent: user_agent(),
140        });
141        let model = config
142            .connect(super::delivery::Tap::new(self.http.clone()))
143            .responses(self.model.as_str());
144        Ok(Model::new(Responses(model.wire), model.transport))
145    }
146}
147
148/// The known model catalog fields. Maps retain last duplicate values, while absent/null
149/// or wrong-typed optional fields preserve the picker's existing skip/default behavior.
150#[derive(Default)]
151struct ModelCatalog {
152    models: crate::json::Optional<Vec<crate::json::Optional<crate::json::Object<PickerModel>>>>,
153}
154#[derive(Default)]
155struct PickerModel {
156    visibility: crate::json::Optional<String>,
157    slug: crate::json::Optional<String>,
158    display_name: crate::json::Optional<String>,
159}
160impl<'de> serde::Deserialize<'de> for ModelCatalog {
161    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
162        struct CatalogVisitor;
163        impl<'de> serde::de::Visitor<'de> for CatalogVisitor {
164            type Value = ModelCatalog;
165            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166                formatter.write_str("a model catalog object")
167            }
168            fn visit_map<M: serde::de::MapAccess<'de>>(
169                self,
170                mut map: M,
171            ) -> Result<Self::Value, M::Error> {
172                let mut catalog = ModelCatalog::default();
173                while let Some(field) = map.next_key::<String>()? {
174                    if field == "models" {
175                        catalog.models = map.next_value()?;
176                    } else {
177                        map.next_value::<crate::json::Ignored>()?;
178                    }
179                }
180                Ok(catalog)
181            }
182        }
183        deserializer.deserialize_map(CatalogVisitor)
184    }
185}
186impl<'de> serde::Deserialize<'de> for PickerModel {
187    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
188        struct ModelVisitor;
189        impl<'de> serde::de::Visitor<'de> for ModelVisitor {
190            type Value = PickerModel;
191            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192                formatter.write_str("a picker model object")
193            }
194            fn visit_map<M: serde::de::MapAccess<'de>>(
195                self,
196                mut map: M,
197            ) -> Result<Self::Value, M::Error> {
198                let mut model = PickerModel::default();
199                while let Some(field) = map.next_key::<String>()? {
200                    match field.as_str() {
201                        "visibility" => model.visibility = map.next_value()?,
202                        "slug" => model.slug = map.next_value()?,
203                        "display_name" => model.display_name = map.next_value()?,
204                        _ => {
205                            map.next_value::<crate::json::Ignored>()?;
206                        }
207                    }
208                }
209                Ok(model)
210            }
211        }
212        deserializer.deserialize_map(ModelVisitor)
213    }
214}
215fn picker_models(data: &[u8]) -> Result<Vec<super::models::ListedModel>, String> {
216    let invalid = || "Invalid Codex models response.".to_owned();
217    let crate::json::Object(catalog): crate::json::Object<ModelCatalog> =
218        serde_json::from_slice(data).map_err(|_| invalid())?;
219    catalog
220        .models
221        .0
222        .ok_or_else(invalid)?
223        .into_iter()
224        .filter_map(|model| model.0.map(|crate::json::Object(model)| model))
225        .filter(|model| model.visibility.0.as_deref() == Some("list"))
226        .map(|model| {
227            let slug: crate::config::ModelId = model
228                .slug
229                .0
230                .and_then(|slug| slug.parse().ok())
231                .ok_or_else(invalid)?;
232            Ok(super::models::ListedModel {
233                display_name: model.display_name.0.unwrap_or_else(|| slug.to_string()),
234                slug,
235            })
236        })
237        .collect()
238}
239
240/// The account's Codex models that the Codex picker lists (`visibility: "list"`), in
241/// server order, from `GET <root>/models?client_version=…` as the Codex CLI asks.
242pub(super) async fn models(
243    base: &str,
244    state: Option<&Path>,
245    repo: Option<&Path>,
246) -> Result<Vec<super::models::ListedModel>, String> {
247    let token = auth::access_token(state, repo)
248        .await
249        .map_err(|error| error.message)?;
250    let response = super::http_client()?
251        .get(format!("{base}/models?client_version={CLIENT_VERSION}"))
252        .bearer_auth(&token.access_token)
253        .header("ChatGPT-Account-Id", token.account_id.as_str())
254        .header("originator", ORIGINATOR)
255        .header(reqwest::header::USER_AGENT, user_agent())
256        .timeout(super::models::LIST_TIMEOUT)
257        .send()
258        .await
259        .map_err(|_| "Codex model listing failed; check your connection.".to_owned())?;
260    let status = response.status();
261    let data = response.bytes().await.unwrap_or_default();
262    if !status.is_success() {
263        // Provider error payloads vary by endpoint; the success catalog has a known shape.
264        let body = errors::Error::parse(&data);
265        // The provider's text answers a request that carried the access token: the whole,
266        // unclipped text is checked before anything is cut.
267        let text = crate::secret::server_text(
268            &describe_text(
269                Some(status.as_u16()),
270                body.as_ref(),
271                "Codex model listing failed.",
272                auth::now().ok(),
273            ),
274            &[&token.access_token],
275            super::MAX_DIAGNOSTIC_CHARS,
276        );
277        return Err(if text == crate::secret::WITHHELD {
278            format!(
279                "Codex model listing failed (HTTP {}): {text}",
280                status.as_u16()
281            )
282        } else {
283            text
284        });
285    }
286    picker_models(&data)
287}
288
289/// A plan usage limit, which no retry within a review outlasts.
290pub(super) fn usage_limited(error: &ProviderError) -> bool {
291    body(error).is_some_and(|body| body.usage_limit(error.report().http_status))
292}
293/// Responses stream errors put their code at the root or inside response.error;
294/// rig's generic report only extracts error.code, so use the typed wire envelope.
295pub(super) fn overloaded(error: &ProviderError) -> bool {
296    body(error).is_some_and(|body| body.code() == Some("server_is_overloaded"))
297}
298
299fn body(error: &ProviderError) -> Option<errors::Error> {
300    error
301        .provider_response()
302        .and_then(|response| errors::Error::parse(response.body.as_bytes()))
303}
304
305/// A Codex provider error as text, cleaned for a terminal but not clipped: callers decide
306/// what of it may be shown ([`crate::secret::server_text`]) before cutting it.
307pub(super) fn diagnostic_text(error: &ProviderError) -> String {
308    let body = body(error);
309    if body.is_none() {
310        return super::diagnostic_text(error);
311    }
312    describe_text(
313        error.report().http_status,
314        body.as_ref(),
315        &super::diagnostic_text(error),
316        auth::now().ok(),
317    )
318}
319
320/// The wait before a usage limit resets is shown in whole minutes, rounded up.
321const SECONDS_PER_MINUTE: u64 = 60;
322
323/// A bounded message with the provider's code, an HTTP status, and what to do next:
324/// usage limits name the plan and when they reset, as Pi words them, and rejected
325/// credentials say how to sign in again.
326#[cfg(test)]
327fn describe(
328    status: Option<u16>,
329    body: Option<&errors::Error>,
330    fallback: &str,
331    now: Option<auth::Timestamp>,
332) -> String {
333    describe_text(status, body, fallback, now)
334        .chars()
335        .take(super::MAX_DIAGNOSTIC_CHARS)
336        .collect()
337}
338
339/// [`describe`] before clipping.
340fn describe_text(
341    status: Option<u16>,
342    body: Option<&errors::Error>,
343    fallback: &str,
344    now: Option<auth::Timestamp>,
345) -> String {
346    let code = body.and_then(errors::Error::code);
347    let mut message = if body.is_some_and(|body| body.usage_limit(status)) {
348        let plan = body
349            .and_then(|body| body.fields.plan_type.0.as_deref())
350            .map(|plan| format!(" ({} plan)", plan.to_lowercase()))
351            .unwrap_or_default();
352        let reset = body
353            .and_then(|body| body.fields.resets_at.0)
354            .zip(now)
355            .map(|(at, now)| {
356                format!(
357                    " Try again in ~{} min.",
358                    at.saturating_since(now)
359                        .as_secs()
360                        .div_ceil(SECONDS_PER_MINUTE)
361                )
362            })
363            .unwrap_or_default();
364        format!("You have hit your ChatGPT usage limit{plan}.{reset}")
365    } else {
366        body.and_then(|body| body.fields.message.0.as_deref().or(body.detail.as_deref()))
367            .unwrap_or(fallback)
368            .to_owned()
369    };
370    if let Some(code) = code {
371        message = format!("{code}: {message}");
372    }
373    if let Some(status) = status {
374        message.push_str(&format!(" (HTTP {status})"));
375    }
376    if matches!(status, Some(401 | 403)) {
377        message.push_str(
378            " Sign in again with `artifactize login codex`, or with Codex when ARTIFACTIZE_CODEX_AUTH_FILE is set.",
379        );
380    }
381    super::clean_text(&message)
382}
383
384#[cfg(test)]
385mod tests;