use std::path::{Path, PathBuf};
use rig_core::{
Model,
completion::CompletionRequest,
error::ProviderError,
http_client::Builder,
providers::{
chatgpt,
openai::{
self,
wire::{CallerIdentity, DialectHooks},
},
},
};
mod errors;
mod output;
pub(crate) use output::Responses;
use crate::auth::codex::{self as auth, ORIGINATOR};
pub const BASE_URL: &str = "https://chatgpt.com/backend-api/codex";
pub const CLIENT_VERSION: &str = "0.160.0";
static HOOKS: DialectHooks = DialectHooks {
default_endpoint: None,
model_route: None,
completion_envelope: Some(envelope),
modality_envelope: None,
};
fn envelope(_: &openai::OpenAIConfig, request: &CompletionRequest, builder: Builder) -> Builder {
let builder = builder
.header("OpenAI-Beta", "responses=experimental")
.header("accept", "text/event-stream");
match session(request) {
Some(session) => builder.header("session-id", session),
None => builder,
}
}
fn session(request: &CompletionRequest) -> Option<&str> {
request
.additional_params
.as_ref()?
.get("prompt_cache_key")?
.as_str()
}
pub fn user_agent() -> String {
format!(
"artifactize/{} ({}; {ORIGINATOR})",
env!("CARGO_PKG_VERSION"),
crate::platform::label(),
)
}
pub struct Codex {
model: crate::config::ModelId,
base: reqwest::Url,
state: PathBuf,
repo: PathBuf,
http: reqwest::Client,
sent: std::sync::Mutex<Vec<String>>,
}
impl Codex {
pub(super) fn new(
model: &crate::config::ModelId,
base: &str,
state: &Path,
repo: &Path,
) -> Result<Self, String> {
Ok(Self {
model: model.clone(),
base: reqwest::Url::parse(base)
.map_err(|_| format!("Invalid Codex endpoint: {base}"))?,
state: state.into(),
repo: repo.into(),
http: super::http_client()?,
sent: std::sync::Mutex::new(Vec::new()),
})
}
pub(super) fn sent(&self) -> Vec<String> {
self.sent.lock().expect("sent tokens lock").clone()
}
pub(super) async fn model(&self) -> Result<Model<Responses>, auth::TokenError> {
let token = auth::access_token(Some(&self.state), Some(&self.repo)).await?;
{
let mut sent = self.sent.lock().expect("sent tokens lock");
if !sent.contains(&token.access_token) {
sent.push(token.access_token.clone());
}
}
let mut dialect = chatgpt::DIALECT;
dialect.quirks.hooks = Some(&HOOKS);
if let Some(identity) = &mut dialect.quirks.identity {
identity.session_ids = false;
}
let mut config = openai::OpenAIConfig::with_key(&dialect, token.access_token)
.with_base_url(self.base.as_str().trim_end_matches('/'))
.with_account_id(token.account_id.to_string());
config.instructions = None;
config.identity = Some(CallerIdentity {
originator: ORIGINATOR.into(),
user_agent: user_agent(),
});
let model = config
.connect(super::delivery::Tap::new(self.http.clone()))
.responses(self.model.as_str());
Ok(Model::new(Responses(model.wire), model.transport))
}
}
#[derive(Default)]
struct ModelCatalog {
models: crate::json::Optional<Vec<crate::json::Optional<crate::json::Object<PickerModel>>>>,
}
#[derive(Default)]
struct PickerModel {
visibility: crate::json::Optional<String>,
slug: crate::json::Optional<String>,
display_name: crate::json::Optional<String>,
}
impl<'de> serde::Deserialize<'de> for ModelCatalog {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct CatalogVisitor;
impl<'de> serde::de::Visitor<'de> for CatalogVisitor {
type Value = ModelCatalog;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("a model catalog object")
}
fn visit_map<M: serde::de::MapAccess<'de>>(
self,
mut map: M,
) -> Result<Self::Value, M::Error> {
let mut catalog = ModelCatalog::default();
while let Some(field) = map.next_key::<String>()? {
if field == "models" {
catalog.models = map.next_value()?;
} else {
map.next_value::<crate::json::Ignored>()?;
}
}
Ok(catalog)
}
}
deserializer.deserialize_map(CatalogVisitor)
}
}
impl<'de> serde::Deserialize<'de> for PickerModel {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct ModelVisitor;
impl<'de> serde::de::Visitor<'de> for ModelVisitor {
type Value = PickerModel;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("a picker model object")
}
fn visit_map<M: serde::de::MapAccess<'de>>(
self,
mut map: M,
) -> Result<Self::Value, M::Error> {
let mut model = PickerModel::default();
while let Some(field) = map.next_key::<String>()? {
match field.as_str() {
"visibility" => model.visibility = map.next_value()?,
"slug" => model.slug = map.next_value()?,
"display_name" => model.display_name = map.next_value()?,
_ => {
map.next_value::<crate::json::Ignored>()?;
}
}
}
Ok(model)
}
}
deserializer.deserialize_map(ModelVisitor)
}
}
fn picker_models(data: &[u8]) -> Result<Vec<super::models::ListedModel>, String> {
let invalid = || "Invalid Codex models response.".to_owned();
let crate::json::Object(catalog): crate::json::Object<ModelCatalog> =
serde_json::from_slice(data).map_err(|_| invalid())?;
catalog
.models
.0
.ok_or_else(invalid)?
.into_iter()
.filter_map(|model| model.0.map(|crate::json::Object(model)| model))
.filter(|model| model.visibility.0.as_deref() == Some("list"))
.map(|model| {
let slug: crate::config::ModelId = model
.slug
.0
.and_then(|slug| slug.parse().ok())
.ok_or_else(invalid)?;
Ok(super::models::ListedModel {
display_name: model.display_name.0.unwrap_or_else(|| slug.to_string()),
slug,
})
})
.collect()
}
pub(super) async fn models(
base: &str,
state: Option<&Path>,
repo: Option<&Path>,
) -> Result<Vec<super::models::ListedModel>, String> {
let token = auth::access_token(state, repo)
.await
.map_err(|error| error.message)?;
let response = super::http_client()?
.get(format!("{base}/models?client_version={CLIENT_VERSION}"))
.bearer_auth(&token.access_token)
.header("ChatGPT-Account-Id", token.account_id.as_str())
.header("originator", ORIGINATOR)
.header(reqwest::header::USER_AGENT, user_agent())
.timeout(super::models::LIST_TIMEOUT)
.send()
.await
.map_err(|_| "Codex model listing failed; check your connection.".to_owned())?;
let status = response.status();
let data = response.bytes().await.unwrap_or_default();
if !status.is_success() {
let body = errors::Error::parse(&data);
let text = crate::secret::server_text(
&describe_text(
Some(status.as_u16()),
body.as_ref(),
"Codex model listing failed.",
auth::now().ok(),
),
&[&token.access_token],
super::MAX_DIAGNOSTIC_CHARS,
);
return Err(if text == crate::secret::WITHHELD {
format!(
"Codex model listing failed (HTTP {}): {text}",
status.as_u16()
)
} else {
text
});
}
picker_models(&data)
}
pub(super) fn usage_limited(error: &ProviderError) -> bool {
body(error).is_some_and(|body| body.usage_limit(error.report().http_status))
}
pub(super) fn overloaded(error: &ProviderError) -> bool {
body(error).is_some_and(|body| body.code() == Some("server_is_overloaded"))
}
fn body(error: &ProviderError) -> Option<errors::Error> {
error
.provider_response()
.and_then(|response| errors::Error::parse(response.body.as_bytes()))
}
pub(super) fn diagnostic_text(error: &ProviderError) -> String {
let body = body(error);
if body.is_none() {
return super::diagnostic_text(error);
}
describe_text(
error.report().http_status,
body.as_ref(),
&super::diagnostic_text(error),
auth::now().ok(),
)
}
const SECONDS_PER_MINUTE: u64 = 60;
#[cfg(test)]
fn describe(
status: Option<u16>,
body: Option<&errors::Error>,
fallback: &str,
now: Option<auth::Timestamp>,
) -> String {
describe_text(status, body, fallback, now)
.chars()
.take(super::MAX_DIAGNOSTIC_CHARS)
.collect()
}
fn describe_text(
status: Option<u16>,
body: Option<&errors::Error>,
fallback: &str,
now: Option<auth::Timestamp>,
) -> String {
let code = body.and_then(errors::Error::code);
let mut message = if body.is_some_and(|body| body.usage_limit(status)) {
let plan = body
.and_then(|body| body.fields.plan_type.0.as_deref())
.map(|plan| format!(" ({} plan)", plan.to_lowercase()))
.unwrap_or_default();
let reset = body
.and_then(|body| body.fields.resets_at.0)
.zip(now)
.map(|(at, now)| {
format!(
" Try again in ~{} min.",
at.saturating_since(now)
.as_secs()
.div_ceil(SECONDS_PER_MINUTE)
)
})
.unwrap_or_default();
format!("You have hit your ChatGPT usage limit{plan}.{reset}")
} else {
body.and_then(|body| body.fields.message.0.as_deref().or(body.detail.as_deref()))
.unwrap_or(fallback)
.to_owned()
};
if let Some(code) = code {
message = format!("{code}: {message}");
}
if let Some(status) = status {
message.push_str(&format!(" (HTTP {status})"));
}
if matches!(status, Some(401 | 403)) {
message.push_str(
" Sign in again with `artifactize login codex`, or with Codex when ARTIFACTIZE_CODEX_AUTH_FILE is set.",
);
}
super::clean_text(&message)
}
#[cfg(test)]
mod tests;