1use 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;
33
34use crate::auth::codex::{self as auth, ORIGINATOR};
35
36pub const BASE_URL: &str = "https://chatgpt.com/backend-api/codex";
38
39pub const CLIENT_VERSION: &str = "0.160.0";
42
43static HOOKS: DialectHooks = DialectHooks {
44 default_endpoint: None,
45 model_route: None,
46 completion_envelope: Some(envelope),
47 modality_envelope: None,
48};
49
50fn envelope(_: &openai::OpenAIConfig, request: &CompletionRequest, builder: Builder) -> Builder {
51 let builder = builder
52 .header("OpenAI-Beta", "responses=experimental")
53 .header("accept", "text/event-stream");
54 match session(request) {
57 Some(session) => builder.header("session-id", session),
58 None => builder,
59 }
60}
61
62fn session(request: &CompletionRequest) -> Option<&str> {
64 request
65 .additional_params
66 .as_ref()?
67 .get("prompt_cache_key")?
68 .as_str()
69}
70
71pub fn user_agent() -> String {
73 format!(
74 "artifactize/{} ({}; {ORIGINATOR})",
75 env!("CARGO_PKG_VERSION"),
76 crate::platform::label(),
77 )
78}
79
80pub struct Codex {
81 model: crate::config::ModelId,
82 base: reqwest::Url,
84 state: PathBuf,
85 repo: PathBuf,
86 http: reqwest::Client,
87 sent: std::sync::Mutex<Vec<String>>,
89}
90
91impl Codex {
92 pub(super) fn new(
93 model: &crate::config::ModelId,
94 base: &str,
95 state: &Path,
96 repo: &Path,
97 ) -> Result<Self, String> {
98 Ok(Self {
99 model: model.clone(),
100 base: reqwest::Url::parse(base)
101 .map_err(|_| format!("Invalid Codex endpoint: {base}"))?,
102 state: state.into(),
103 repo: repo.into(),
104 http: super::http_client()?,
105 sent: std::sync::Mutex::new(Vec::new()),
106 })
107 }
108
109 pub(super) fn sent(&self) -> Vec<String> {
111 self.sent.lock().expect("sent tokens lock").clone()
112 }
113
114 pub(super) async fn model(
117 &self,
118 ) -> Result<Model<openai::responses_api::wire::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 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 Ok(config
142 .connect(super::delivery::Tap::new(self.http.clone()))
143 .responses(self.model.as_str()))
144 }
145}
146
147#[derive(Default)]
150struct ModelCatalog {
151 models: crate::json::Optional<Vec<crate::json::Optional<crate::json::Object<PickerModel>>>>,
152}
153#[derive(Default)]
154struct PickerModel {
155 visibility: crate::json::Optional<String>,
156 slug: crate::json::Optional<String>,
157 display_name: crate::json::Optional<String>,
158}
159impl<'de> serde::Deserialize<'de> for ModelCatalog {
160 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
161 struct CatalogVisitor;
162 impl<'de> serde::de::Visitor<'de> for CatalogVisitor {
163 type Value = ModelCatalog;
164 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165 formatter.write_str("a model catalog object")
166 }
167 fn visit_map<M: serde::de::MapAccess<'de>>(
168 self,
169 mut map: M,
170 ) -> Result<Self::Value, M::Error> {
171 let mut catalog = ModelCatalog::default();
172 while let Some(field) = map.next_key::<String>()? {
173 if field == "models" {
174 catalog.models = map.next_value()?;
175 } else {
176 map.next_value::<crate::json::Ignored>()?;
177 }
178 }
179 Ok(catalog)
180 }
181 }
182 deserializer.deserialize_map(CatalogVisitor)
183 }
184}
185impl<'de> serde::Deserialize<'de> for PickerModel {
186 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
187 struct ModelVisitor;
188 impl<'de> serde::de::Visitor<'de> for ModelVisitor {
189 type Value = PickerModel;
190 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191 formatter.write_str("a picker model object")
192 }
193 fn visit_map<M: serde::de::MapAccess<'de>>(
194 self,
195 mut map: M,
196 ) -> Result<Self::Value, M::Error> {
197 let mut model = PickerModel::default();
198 while let Some(field) = map.next_key::<String>()? {
199 match field.as_str() {
200 "visibility" => model.visibility = map.next_value()?,
201 "slug" => model.slug = map.next_value()?,
202 "display_name" => model.display_name = map.next_value()?,
203 _ => {
204 map.next_value::<crate::json::Ignored>()?;
205 }
206 }
207 }
208 Ok(model)
209 }
210 }
211 deserializer.deserialize_map(ModelVisitor)
212 }
213}
214fn picker_models(data: &[u8]) -> Result<Vec<super::models::ListedModel>, String> {
215 let invalid = || "Invalid Codex models response.".to_owned();
216 let crate::json::Object(catalog): crate::json::Object<ModelCatalog> =
217 serde_json::from_slice(data).map_err(|_| invalid())?;
218 catalog
219 .models
220 .0
221 .ok_or_else(invalid)?
222 .into_iter()
223 .filter_map(|model| model.0.map(|crate::json::Object(model)| model))
224 .filter(|model| model.visibility.0.as_deref() == Some("list"))
225 .map(|model| {
226 let slug: crate::config::ModelId = model
227 .slug
228 .0
229 .and_then(|slug| slug.parse().ok())
230 .ok_or_else(invalid)?;
231 Ok(super::models::ListedModel {
232 display_name: model.display_name.0.unwrap_or_else(|| slug.to_string()),
233 slug,
234 })
235 })
236 .collect()
237}
238
239pub(super) async fn models(
242 base: &str,
243 state: Option<&Path>,
244 repo: Option<&Path>,
245) -> Result<Vec<super::models::ListedModel>, String> {
246 let token = auth::access_token(state, repo)
247 .await
248 .map_err(|error| error.message)?;
249 let response = super::http_client()?
250 .get(format!("{base}/models?client_version={CLIENT_VERSION}"))
251 .bearer_auth(&token.access_token)
252 .header("ChatGPT-Account-Id", token.account_id.as_str())
253 .header("originator", ORIGINATOR)
254 .header(reqwest::header::USER_AGENT, user_agent())
255 .timeout(super::models::LIST_TIMEOUT)
256 .send()
257 .await
258 .map_err(|_| "Codex model listing failed; check your connection.".to_owned())?;
259 let status = response.status();
260 let data = response.bytes().await.unwrap_or_default();
261 if !status.is_success() {
262 let body = errors::Error::parse(&data);
264 let text = crate::secret::server_text(
267 &describe_text(
268 Some(status.as_u16()),
269 body.as_ref(),
270 "Codex model listing failed.",
271 auth::now().ok(),
272 ),
273 &[&token.access_token],
274 super::MAX_DIAGNOSTIC_CHARS,
275 );
276 return Err(if text == crate::secret::WITHHELD {
277 format!(
278 "Codex model listing failed (HTTP {}): {text}",
279 status.as_u16()
280 )
281 } else {
282 text
283 });
284 }
285 picker_models(&data)
286}
287
288pub(super) fn usage_limited(error: &ProviderError) -> bool {
290 body(error).is_some_and(|body| body.usage_limit(error.report().http_status))
291}
292pub(super) fn overloaded(error: &ProviderError) -> bool {
295 body(error).is_some_and(|body| body.code() == Some("server_is_overloaded"))
296}
297
298fn body(error: &ProviderError) -> Option<errors::Error> {
299 error
300 .provider_response()
301 .and_then(|response| errors::Error::parse(response.body.as_bytes()))
302}
303
304pub(super) fn diagnostic_text(error: &ProviderError) -> String {
307 let body = body(error);
308 if body.is_none() {
309 return super::diagnostic_text(error);
310 }
311 describe_text(
312 error.report().http_status,
313 body.as_ref(),
314 &super::diagnostic_text(error),
315 auth::now().ok(),
316 )
317}
318
319const SECONDS_PER_MINUTE: u64 = 60;
321
322#[cfg(test)]
326fn describe(
327 status: Option<u16>,
328 body: Option<&errors::Error>,
329 fallback: &str,
330 now: Option<auth::Timestamp>,
331) -> String {
332 describe_text(status, body, fallback, now)
333 .chars()
334 .take(super::MAX_DIAGNOSTIC_CHARS)
335 .collect()
336}
337
338fn describe_text(
340 status: Option<u16>,
341 body: Option<&errors::Error>,
342 fallback: &str,
343 now: Option<auth::Timestamp>,
344) -> String {
345 let code = body.and_then(errors::Error::code);
346 let mut message = if body.is_some_and(|body| body.usage_limit(status)) {
347 let plan = body
348 .and_then(|body| body.fields.plan_type.0.as_deref())
349 .map(|plan| format!(" ({} plan)", plan.to_lowercase()))
350 .unwrap_or_default();
351 let reset = body
352 .and_then(|body| body.fields.resets_at.0)
353 .zip(now)
354 .map(|(at, now)| {
355 format!(
356 " Try again in ~{} min.",
357 at.saturating_since(now)
358 .as_secs()
359 .div_ceil(SECONDS_PER_MINUTE)
360 )
361 })
362 .unwrap_or_default();
363 format!("You have hit your ChatGPT usage limit{plan}.{reset}")
364 } else {
365 body.and_then(|body| body.fields.message.0.as_deref().or(body.detail.as_deref()))
366 .unwrap_or(fallback)
367 .to_owned()
368 };
369 if let Some(code) = code {
370 message = format!("{code}: {message}");
371 }
372 if let Some(status) = status {
373 message.push_str(&format!(" (HTTP {status})"));
374 }
375 if matches!(status, Some(401 | 403)) {
376 message.push_str(
377 " Sign in again with `artifactize login codex`, or with Codex when ARTIFACTIZE_CODEX_AUTH_FILE is set.",
378 );
379 }
380 super::clean_text(&message)
381}
382
383#[cfg(test)]
384mod tests;