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;
33mod output;
34pub(crate) use output::Responses;
35
36use crate::auth::codex::{self as auth, ORIGINATOR};
37
38pub const BASE_URL: &str = "https://chatgpt.com/backend-api/codex";
40
41pub 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 match session(request) {
59 Some(session) => builder.header("session-id", session),
60 None => builder,
61 }
62}
63
64fn session(request: &CompletionRequest) -> Option<&str> {
66 request
67 .additional_params
68 .as_ref()?
69 .get("prompt_cache_key")?
70 .as_str()
71}
72
73pub 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 base: reqwest::Url,
86 state: PathBuf,
87 repo: PathBuf,
88 http: reqwest::Client,
89 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 pub(super) fn sent(&self) -> Vec<String> {
113 self.sent.lock().expect("sent tokens lock").clone()
114 }
115
116 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 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#[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
240pub(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 let body = errors::Error::parse(&data);
265 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
289pub(super) fn usage_limited(error: &ProviderError) -> bool {
291 body(error).is_some_and(|body| body.usage_limit(error.report().http_status))
292}
293pub(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
305pub(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
320const SECONDS_PER_MINUTE: u64 = 60;
322
323#[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
339fn 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;