Update ChatGPT authentication and sign-in recovery

Co-Authored-By: Warp <agent@warp.dev>
This commit is contained in:
Josh Woodcock
2026-10-19 12:53:11 -05:00
co-authored by Warp
parent 3ddd8f25ac
commit c894a11589
8 changed files with 660 additions and 264 deletions
+356 -224
View File
@@ -2,12 +2,17 @@
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream}; use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration; use std::time::Duration;
use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine; use base64::Engine;
use futures::channel::oneshot; use futures::channel::oneshot;
use futures::StreamExt;
use galaxy_agent_rig::ChatGPTCredentialResolver;
use galaxyui::{Entity, ModelContext, SingletonEntity}; use galaxyui::{Entity, ModelContext, SingletonEntity};
use galaxyui_extras::secure_storage::AppContextExt as _;
use instant::Instant; use instant::Instant;
use rand::RngCore; use rand::RngCore;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -21,6 +26,10 @@ const CALLBACK_HOST: &str = "localhost";
const CALLBACK_PORTS: [u16; 2] = [1455, 1457]; const CALLBACK_PORTS: [u16; 2] = [1455, 1457];
const CALLBACK_PATH: &str = "/auth/callback"; const CALLBACK_PATH: &str = "/auth/callback";
const CALLBACK_TIMEOUT: Duration = Duration::from_secs(2 * 60); const CALLBACK_TIMEOUT: Duration = Duration::from_secs(2 * 60);
const AUTH_STORAGE_KEY: &str = "galaxy.chatgpt.oauth";
const SIGN_IN_REQUIRED: &str = "ChatGPT sign-in required. Connect or reconnect ChatGPT in Providers.";
type Reply = oneshot::Sender<Result<ChatGPTAuthCredentials, String>>;
static RESOLVER: OnceLock<Mutex<Option<async_channel::Sender<Reply>>>> = OnceLock::new();
/// Current state of the local ChatGPT subscription connection. /// Current state of the local ChatGPT subscription connection.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@@ -45,26 +54,42 @@ struct OAuthCallback {
/// Coordinates browser-based OAuth authorization for ChatGPT subscriptions. /// Coordinates browser-based OAuth authorization for ChatGPT subscriptions.
pub(crate) struct ChatGPTAuthModel { pub(crate) struct ChatGPTAuthModel {
state: ChatGPTAuthState, state: ChatGPTAuthState,
record: Option<AuthRecord>,
refresh: RefreshCoordinator,
sign_in_generation: u64,
sign_in_cancel: Option<Arc<AtomicBool>>,
} }
impl ChatGPTAuthModel { impl ChatGPTAuthModel {
pub(crate) fn new() -> Self { pub(crate) fn new(ctx: &mut ModelContext<Self>) -> Self {
let state = match has_or_import_auth_credentials() { let (tx, rx) = async_channel::bounded(64);
Ok(()) => ChatGPTAuthState::Connected, *RESOLVER.get_or_init(|| Mutex::new(None)).lock().unwrap() = Some(tx);
Err(error) => { ctx.spawn_stream_local(rx, |model, reply, ctx| model.resolve(reply, ctx), |_, _| {});
log::debug!( let record = ctx.secure_storage().read_value(AUTH_STORAGE_KEY).ok()
"[chatgpt/auth] No usable persisted ChatGPT credentials at startup: {error}" .and_then(|json| serde_json::from_str::<AuthRecord>(&json).ok())
); .filter(|record| record.credentials(now()).is_ok() || record.can_refresh());
ChatGPTAuthState::NotConnected let state = if record.is_some() {
} ChatGPTAuthState::Connected
} else {
ChatGPTAuthState::NotConnected
}; };
Self { state } Self {
state,
record,
refresh: RefreshCoordinator::default(),
sign_in_generation: 0,
sign_in_cancel: None,
}
} }
#[cfg(test)] #[cfg(test)]
pub(crate) fn new_for_test() -> Self { pub(crate) fn new_for_test() -> Self {
Self { Self {
state: ChatGPTAuthState::NotConnected, state: ChatGPTAuthState::NotConnected,
record: None,
refresh: RefreshCoordinator::default(),
sign_in_generation: 0,
sign_in_cancel: None,
} }
} }
@@ -72,22 +97,26 @@ impl ChatGPTAuthModel {
&self.state &self.state
} }
/// Reuses persisted credentials when possible, otherwise starts browser OAuth with PKCE. fn begin_sign_in(&mut self) -> Option<u64> {
pub(crate) fn connect(&mut self, ctx: &mut ModelContext<Self>) {
if matches!( if matches!(
self.state, self.state,
ChatGPTAuthState::AwaitingBrowser | ChatGPTAuthState::ExchangingToken ChatGPTAuthState::AwaitingBrowser | ChatGPTAuthState::ExchangingToken
) { ) {
return; return None;
} }
if has_or_import_auth_credentials().is_ok() { self.refresh.invalidate();
self.state = ChatGPTAuthState::Connected; self.sign_in_generation += 1;
ctx.emit(ChatGPTAuthModelEvent::StateChanged); Some(self.sign_in_generation)
return; }
}
let (redirect_uri, callback_rx) = match start_oauth_callback_server() { /// Explicit connection always starts a fresh browser OAuth flow with PKCE.
pub(crate) fn connect(&mut self, ctx: &mut ModelContext<Self>) {
let Some(generation) = self.begin_sign_in() else {
return;
};
let cancelled = Arc::new(AtomicBool::new(false));
let (redirect_uri, callback_rx) = match start_oauth_callback_server(cancelled.clone()) {
Ok(server) => server, Ok(server) => server,
Err(error) => { Err(error) => {
self.fail(error, ctx); self.fail(error, ctx);
@@ -106,8 +135,8 @@ impl ChatGPTAuthModel {
} }
}; };
self.state = ChatGPTAuthState::AwaitingBrowser; self.sign_in_cancel = Some(cancelled);
ctx.emit(ChatGPTAuthModelEvent::StateChanged); self.set_state(ChatGPTAuthState::AwaitingBrowser, ctx);
ctx.open_url(authorize_url.as_str()); ctx.open_url(authorize_url.as_str());
let _ = ctx.spawn( let _ = ctx.spawn(
@@ -117,6 +146,9 @@ impl ChatGPTAuthModel {
})? })?
}, },
move |model, callback, ctx| { move |model, callback, ctx| {
if model.sign_in_generation != generation {
return;
}
let callback = match callback { let callback = match callback {
Ok(callback) => callback, Ok(callback) => callback,
Err(error) => { Err(error) => {
@@ -129,19 +161,27 @@ impl ChatGPTAuthModel {
return; return;
} }
model.state = ChatGPTAuthState::ExchangingToken; model.set_state(ChatGPTAuthState::ExchangingToken, ctx);
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
let _ = ctx.spawn( let _ = ctx.spawn(
async move { async move {
exchange_code_for_tokens(&callback.code, &code_verifier, &redirect_uri) exchange_code_for_tokens(&callback.code, &code_verifier, &redirect_uri)
.await .await
}, },
|model, result, ctx| match result { move |model, result, ctx| {
Ok(()) => { if model.sign_in_generation != generation {
model.state = ChatGPTAuthState::Connected; return;
ctx.emit(ChatGPTAuthModelEvent::StateChanged); }
match result.and_then(|record| {
save_auth_record(&record, ctx)?;
Ok(record)
}) {
Ok(record) => {
model.record = Some(record);
model.sign_in_cancel = None;
model.set_state(ChatGPTAuthState::Connected, ctx);
}
Err(error) => model.fail(error, ctx),
} }
Err(error) => model.fail(error, ctx),
}, },
); );
}, },
@@ -149,8 +189,77 @@ impl ChatGPTAuthModel {
} }
fn fail(&mut self, error: String, ctx: &mut ModelContext<Self>) { fn fail(&mut self, error: String, ctx: &mut ModelContext<Self>) {
self.state = ChatGPTAuthState::Failed(error); if let Some(cancelled) = self.sign_in_cancel.take() {
ctx.emit(ChatGPTAuthModelEvent::StateChanged); cancelled.store(true, Ordering::SeqCst);
}
self.set_state(ChatGPTAuthState::Failed(error), ctx);
}
fn set_state(&mut self, state: ChatGPTAuthState, ctx: &mut ModelContext<Self>) {
if self.state != state {
self.state = state;
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
}
}
pub(crate) fn cancel_sign_in(&mut self, ctx: &mut ModelContext<Self>) {
if let Some(cancelled) = self.sign_in_cancel.take() {
cancelled.store(true, Ordering::SeqCst);
self.sign_in_generation += 1;
self.refresh.invalidate();
self.set_state(ChatGPTAuthState::NotConnected, ctx);
}
}
fn resolve(&mut self, reply: Reply, ctx: &mut ModelContext<Self>) {
if reply.is_canceled() {
return;
}
if matches!(self.state, ChatGPTAuthState::AwaitingBrowser | ChatGPTAuthState::ExchangingToken) {
let _ = reply.send(Err("ChatGPT sign-in is in progress.".to_owned()));
return;
}
let Some(record) = self.record.as_ref() else {
let _ = reply.send(Err(SIGN_IN_REQUIRED.to_owned()));
return;
};
if let Ok(credentials) = record.credentials(now()) {
let _ = reply.send(Ok(credentials));
return;
}
if !record.can_refresh() {
let _ = reply.send(Err(SIGN_IN_REQUIRED.to_owned()));
self.fail(SIGN_IN_REQUIRED.to_owned(), ctx);
return;
}
let record = record.clone();
let Some(generation) = self.refresh.begin(reply) else {
return;
};
let _ = ctx.spawn(
async move { refresh_tokens(&record).await },
move |model, result, ctx| {
let completed = model.refresh.complete(generation, result, |record| {
save_auth_record(record, ctx)
});
match completed {
None => {}
Some(Ok(record)) => {
model.record = Some(record);
model.set_state(ChatGPTAuthState::Connected, ctx);
}
Some(Err(error)) => {
if error.reauthenticate {
model.record = None;
if save_auth_record(&AuthRecord::default(), ctx).is_err() {
log::warn!("[chatgpt/auth] Could not persist reconnect-required state");
}
}
model.fail(error.message, ctx);
}
}
},
);
} }
} }
@@ -194,6 +303,7 @@ fn random_hex_string(byte_count: usize) -> String {
} }
fn start_oauth_callback_server( fn start_oauth_callback_server(
cancelled: Arc<AtomicBool>,
) -> Result<(String, oneshot::Receiver<Result<OAuthCallback, String>>), String> { ) -> Result<(String, oneshot::Receiver<Result<OAuthCallback, String>>), String> {
let mut bind_errors = Vec::new(); let mut bind_errors = Vec::new();
let mut bound = None; let mut bound = None;
@@ -222,6 +332,10 @@ fn start_oauth_callback_server(
.spawn(move || { .spawn(move || {
let deadline = Instant::now() + CALLBACK_TIMEOUT; let deadline = Instant::now() + CALLBACK_TIMEOUT;
loop { loop {
if cancelled.load(Ordering::SeqCst) {
let _ = callback_tx.send(Err("ChatGPT sign-in was cancelled.".to_owned()));
return;
}
match listener.accept() { match listener.accept() {
Ok((stream, _)) => { Ok((stream, _)) => {
let _ = callback_tx.send(handle_callback_connection(stream)); let _ = callback_tx.send(handle_callback_connection(stream));
@@ -323,26 +437,19 @@ fn parse_callback_request_line(request_line: &str) -> Result<OAuthCallback, Stri
let mut code = None; let mut code = None;
let mut state = None; let mut state = None;
let mut oauth_error = None; let mut oauth_error = false;
let mut error_description = None;
for (key, value) in url.query_pairs() { for (key, value) in url.query_pairs() {
match key.as_ref() { match key.as_ref() {
"code" if !value.is_empty() => code = Some(value.into_owned()), "code" if !value.is_empty() => code = Some(value.into_owned()),
"state" if !value.is_empty() => state = Some(value.into_owned()), "state" if !value.is_empty() => state = Some(value.into_owned()),
"error" if !value.is_empty() => oauth_error = Some(value.into_owned()), "error" if !value.is_empty() => oauth_error = true,
"error_description" if !value.is_empty() => {
error_description = Some(value.into_owned())
}
"code" | "state" | "error" | "error_description" => {} "code" | "state" | "error" | "error_description" => {}
_ => {} _ => {}
} }
} }
if let Some(error) = oauth_error { if oauth_error {
return Err(format!( return Err("ChatGPT browser authorization failed. Please try signing in again.".to_owned());
"ChatGPT authorization failed: {error} ({})",
error_description.as_deref().unwrap_or("no description")
));
} }
Ok(OAuthCallback { Ok(OAuthCallback {
code: code.ok_or("ChatGPT OAuth callback did not include an authorization code.")?, code: code.ok_or("ChatGPT OAuth callback did not include an authorization code.")?,
@@ -358,157 +465,101 @@ fn callback_response_page(title: &str, message: &str, is_error: bool) -> String
.replace("__ACCENT__", accent) .replace("__ACCENT__", accent)
} }
#[derive(Clone, Debug, PartialEq, Eq)] pub(crate) use galaxy_agent_rig::ChatGPTSubscriptionCredentials as ChatGPTAuthCredentials;
pub(crate) struct ChatGPTAuthCredentials {
pub(crate) access_token: String,
pub(crate) account_id: Option<String>,
}
/// Attempts to read tokens from `~/.codex/auth.json` and write them to Rig's auth file. pub(crate) fn credential_resolver() -> ChatGPTCredentialResolver {
/// Returns `Ok(())` if credentials were found and successfully imported. Arc::new(|| Box::pin(load_or_refresh_auth_credentials()))
pub(crate) fn import_codex_credentials() -> Result<(), String> {
let codex_path = codex_auth_file_path().ok_or("Cannot determine codex auth path")?;
let bytes = std::fs::read(&codex_path).map_err(|e| format!("{e}"))?;
let doc: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| format!("{e}"))?;
let tokens = doc.get("tokens").ok_or("No tokens object")?;
let access_token = tokens
.get("access_token")
.and_then(|value| value.as_str())
.filter(|token| !token.is_empty());
let refresh_token = tokens
.get("refresh_token")
.and_then(|value| value.as_str())
.filter(|token| !token.is_empty())
.map(ToOwned::to_owned);
if access_token.is_none() && refresh_token.is_none() {
return Err("Codex auth file does not contain an access or refresh token".to_string());
}
let expires_at = access_token.and_then(extract_expiration_timestamp);
let id_token = tokens
.get("id_token")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned);
let account_id = tokens
.get("account_id")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned)
.or_else(|| extract_account_id(id_token.as_deref()))
.or_else(|| extract_account_id(access_token));
let record = AuthRecord {
access_token: access_token.map(ToOwned::to_owned),
refresh_token,
id_token,
expires_at,
account_id,
};
write_auth_file(&record)
} }
pub(crate) async fn load_or_refresh_auth_credentials() -> Result<ChatGPTAuthCredentials, String> { pub(crate) async fn load_or_refresh_auth_credentials() -> Result<ChatGPTAuthCredentials, String> {
if let Ok(credentials) = load_auth_credentials() { let (tx, rx) = oneshot::channel();
return Ok(credentials); RESOLVER.get_or_init(|| Mutex::new(None)).lock().unwrap()
} .as_ref().ok_or("ChatGPT authorization service is unavailable.")?
.try_send(tx).map_err(|_| "ChatGPT authorization service is busy or unavailable.")?;
let _ = import_codex_credentials(); match futures::future::select(
if let Ok(credentials) = load_auth_credentials() { Box::pin(rx),
return Ok(credentials); Box::pin(async_io::Timer::after(Duration::from_secs(35))),
} ).await {
futures::future::Either::Left((result, _)) => {
galaxy_agent_rig::refresh_chatgpt_subscription_credentials().await?; result.map_err(|_| "ChatGPT authorization service stopped.".to_owned())?
load_auth_credentials() }
} futures::future::Either::Right(_) => {
Err("ChatGPT credential refresh timed out. Retry or sign in again.".to_owned())
fn has_or_import_auth_credentials() -> Result<(), String> { }
if has_auth_credentials() {
return Ok(());
}
import_codex_credentials()?;
if has_auth_credentials() {
Ok(())
} else {
Err("ChatGPT credentials are missing an access or refresh token.".to_string())
} }
} }
fn has_auth_credentials() -> bool { #[derive(Clone, Debug)]
let Some(path) = auth_file_path() else { struct AuthFailure {
return false; message: String,
}; reauthenticate: bool,
let Ok(bytes) = std::fs::read(path) else {
return false;
};
let Ok(record) = serde_json::from_slice::<AuthRecord>(&bytes) else {
return false;
};
record
.refresh_token
.as_deref()
.is_some_and(|token| !token.trim().is_empty())
|| record.access_token.as_deref().is_some_and(|token| {
!token.trim().is_empty()
&& !token_is_expired(
record
.expires_at
.or_else(|| extract_expiration_timestamp(token)),
)
})
} }
fn load_auth_credentials() -> Result<ChatGPTAuthCredentials, String> { #[derive(Default)]
let path = auth_file_path().ok_or("Cannot determine ChatGPT auth file path")?; struct RefreshCoordinator {
let bytes = std::fs::read(&path) generation: u64,
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?; active: Option<(u64, Vec<Reply>)>,
let record: AuthRecord = serde_json::from_slice(&bytes) }
.map_err(|error| format!("Failed to parse {}: {error}", path.display()))?;
let access_token = record
.access_token
.as_deref()
.filter(|token| !token.trim().is_empty())
.ok_or("ChatGPT auth file does not contain an access token")?;
let expires_at = record impl RefreshCoordinator {
.expires_at fn begin(&mut self, reply: Reply) -> Option<u64> {
.or_else(|| extract_expiration_timestamp(access_token)); if let Some((_, replies)) = &mut self.active {
if token_is_expired(expires_at) { replies.push(reply);
return Err("ChatGPT access token is expired".to_string()); return None;
}
self.generation += 1;
self.active = Some((self.generation, vec![reply]));
Some(self.generation)
} }
let account_id = record fn invalidate(&mut self) {
.account_id self.generation += 1;
.clone() if let Some((_, replies)) = self.active.take() {
.filter(|account_id| !account_id.trim().is_empty()) for reply in replies {
.or_else(|| extract_account_id(record.id_token.as_deref())) let _ = reply.send(Err("ChatGPT session was replaced. Please sign in again.".to_owned()));
.or_else(|| extract_account_id(Some(access_token))); }
}
}
Ok(ChatGPTAuthCredentials { fn complete(
access_token: access_token.to_string(), &mut self,
account_id, generation: u64,
}) result: Result<AuthRecord, AuthFailure>,
} persist: impl FnOnce(&AuthRecord) -> Result<(), String>,
) -> Option<Result<AuthRecord, AuthFailure>> {
fn codex_auth_file_path() -> Option<std::path::PathBuf> { if self.active.as_ref().is_none_or(|(active, _)| *active != generation) {
if let Some(codex_home) = std::env::var_os("CODEX_HOME") { return None;
return Some(std::path::PathBuf::from(codex_home).join("auth.json")); }
let (_, replies) = self.active.take()?;
let result = result.and_then(|record| {
let credentials = record.access_context().map_err(|message| AuthFailure {
message, reauthenticate: true,
})?;
persist(&record).map_err(|_| AuthFailure {
message: "Could not securely save refreshed ChatGPT credentials. Please sign in again.".to_owned(),
reauthenticate: true,
})?;
Ok((record, credentials))
});
for reply in replies {
let response = result.as_ref().map(|(_, credentials)| credentials.clone())
.map_err(|error| error.message.clone());
let _ = reply.send(response);
}
Some(result.map(|(record, _)| record))
} }
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".codex").join("auth.json"))
} }
fn token_is_expired(expires_at: Option<i64>) -> bool { fn now() -> i64 {
let now = std::time::SystemTime::now() std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
.duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_secs() as i64).unwrap_or_default()
.map(|duration| duration.as_secs() as i64)
.unwrap_or_default();
expires_at.is_none_or(|expires_at| now >= expires_at - 60)
} }
async fn exchange_code_for_tokens( async fn exchange_code_for_tokens(
code: &str, code: &str,
code_verifier: &str, code_verifier: &str,
redirect_uri: &str, redirect_uri: &str,
) -> Result<(), String> { ) -> Result<AuthRecord, String> {
let body = url::form_urlencoded::Serializer::new(String::new()) let body = url::form_urlencoded::Serializer::new(String::new())
.append_pair("grant_type", "authorization_code") .append_pair("grant_type", "authorization_code")
.append_pair("client_id", CHATGPT_CLIENT_ID) .append_pair("client_id", CHATGPT_CLIENT_ID)
@@ -516,7 +567,34 @@ async fn exchange_code_for_tokens(
.append_pair("redirect_uri", redirect_uri) .append_pair("redirect_uri", redirect_uri)
.append_pair("code_verifier", code_verifier) .append_pair("code_verifier", code_verifier)
.finish(); .finish();
let response = reqwest::Client::new() let tokens = token_request(body).await.map_err(|error| error.message)?;
record_from_tokens(tokens, None, now()).map_err(|error| error.message)
}
async fn refresh_tokens(previous: &AuthRecord) -> Result<AuthRecord, AuthFailure> {
let refresh_token = previous.refresh_token.as_deref().filter(|token| !token.trim().is_empty())
.ok_or_else(|| AuthFailure {
message: SIGN_IN_REQUIRED.to_owned(),
reauthenticate: true,
})?;
let body = url::form_urlencoded::Serializer::new(String::new())
.append_pair("grant_type", "refresh_token")
.append_pair("client_id", CHATGPT_CLIENT_ID)
.append_pair("refresh_token", refresh_token)
.finish();
let tokens = token_request(body).await?;
record_from_tokens(tokens, Some(previous), now())
}
async fn token_request(body: String) -> Result<TokenResponse, AuthFailure> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build().map_err(|_| AuthFailure {
message: "Could not create the ChatGPT authorization client.".to_owned(),
reauthenticate: false,
})?;
let response = client
.post(CHATGPT_TOKEN_URL) .post(CHATGPT_TOKEN_URL)
.header( .header(
reqwest::header::CONTENT_TYPE, reqwest::header::CONTENT_TYPE,
@@ -525,67 +603,83 @@ async fn exchange_code_for_tokens(
.body(body) .body(body)
.send() .send()
.await .await
.map_err(|error| format!("ChatGPT token exchange request failed: {error}"))?; .map_err(|_| AuthFailure {
message: "ChatGPT authorization request failed. Retry the connection.".to_owned(),
let status = response.status(); reauthenticate: false,
if !status.is_success() { })?;
let body = response.text().await.unwrap_or_default(); let status = response.status().as_u16();
return Err(format!("ChatGPT token exchange failed: {status} {body}")); let mut bytes = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|_| AuthFailure {
message: "Could not read the ChatGPT authorization response.".to_owned(),
reauthenticate: (200..300).contains(&status),
})?;
if bytes.len().saturating_add(chunk.len()) > 64 * 1024 {
return Err(AuthFailure {
message: "ChatGPT authorization returned an oversized response.".to_owned(),
reauthenticate: (200..300).contains(&status),
});
}
bytes.extend_from_slice(&chunk);
} }
let tokens: TokenResponse = response parse_token_response(status, &bytes)
.json() }
.await
.map_err(|error| format!("Could not parse the ChatGPT token response: {error}"))?; fn parse_token_response(status: u16, bytes: &[u8]) -> Result<TokenResponse, AuthFailure> {
if (200..300).contains(&status) {
return serde_json::from_slice(bytes).map_err(|_| AuthFailure {
message: "ChatGPT returned invalid credentials. Please sign in again.".to_owned(),
reauthenticate: true,
});
}
let body = serde_json::from_slice::<serde_json::Value>(bytes).unwrap_or_default();
let code = body.get("error").and_then(|error| {
error.as_str().or_else(|| error.get("code").and_then(|code| code.as_str()))
});
let reauthenticate = matches!(status, 401 | 403)
|| (status == 400 && matches!(code,
Some("invalid_grant" | "refresh_token_reused" | "refresh_token_expired" | "refresh_token_invalid")
));
let message = if reauthenticate {
format!("ChatGPT authorization expired or was rejected (HTTP {status}). Please sign in again.")
} else {
format!("ChatGPT authorization failed (HTTP {status}). Retry the connection.")
};
Err(AuthFailure { message, reauthenticate })
}
fn record_from_tokens(
tokens: TokenResponse,
previous: Option<&AuthRecord>,
now: i64,
) -> Result<AuthRecord, AuthFailure> {
let expires_at = extract_expiration_timestamp(&tokens.access_token).or_else(|| { let expires_at = extract_expiration_timestamp(&tokens.access_token).or_else(|| {
tokens.expires_in.map(|expires_in| { tokens.expires_in.and_then(|expires| i64::try_from(expires).ok())
std::time::SystemTime::now() .map(|expires| now.saturating_add(expires))
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or_default()
+ expires_in as i64
})
}); });
let account_id = extract_account_id(tokens.id_token.as_deref()) let account_id = extract_account_id(tokens.id_token.as_deref())
.or_else(|| extract_account_id(Some(&tokens.access_token))); .or_else(|| extract_account_id(Some(&tokens.access_token)))
write_auth_file(&AuthRecord { .or_else(|| previous.and_then(|record| record.account_id.clone()));
let record = AuthRecord {
access_token: Some(tokens.access_token), access_token: Some(tokens.access_token),
refresh_token: tokens.refresh_token, refresh_token: tokens.refresh_token.or_else(|| previous.and_then(|record| record.refresh_token.clone())),
id_token: tokens.id_token, id_token: tokens.id_token.or_else(|| previous.and_then(|record| record.id_token.clone())),
expires_at, expires_at,
account_id, account_id,
}) };
record.credentials(now).map_err(|_| AuthFailure {
message: "ChatGPT returned invalid or expired credentials. Please sign in again.".to_owned(),
reauthenticate: true,
})?;
Ok(record)
} }
fn write_auth_file(record: &AuthRecord) -> Result<(), String> { fn save_auth_record(record: &AuthRecord, ctx: &galaxyui::AppContext) -> Result<(), String> {
let path = auth_file_path().ok_or("Cannot determine auth file path")?; let json = serde_json::to_string(record)
if let Some(parent) = path.parent() { .map_err(|_| "Could not encode ChatGPT credentials.".to_owned())?;
std::fs::create_dir_all(parent) ctx.secure_storage().write_value_with_owner_only_fallback(AUTH_STORAGE_KEY, &json)
.map_err(|e| format!("Failed to create auth directory: {e}"))?; .map_err(|_| "Could not securely save ChatGPT credentials. Please sign in again.".to_owned())
}
let json =
serde_json::to_vec_pretty(record).map_err(|e| format!("Failed to serialize auth: {e}"))?;
std::fs::write(&path, json).map_err(|e| format!("Failed to write auth file: {e}"))?;
Ok(())
}
fn auth_file_path() -> Option<std::path::PathBuf> {
#[cfg(target_os = "windows")]
{
std::env::var_os("APPDATA").map(|d| {
std::path::PathBuf::from(d)
.join("chatgpt")
.join("auth.json")
})
}
#[cfg(not(target_os = "windows"))]
{
std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))
})
.map(|d| d.join("chatgpt").join("auth.json"))
}
} }
fn extract_expiration_timestamp(token: &str) -> Option<i64> { fn extract_expiration_timestamp(token: &str) -> Option<i64> {
@@ -633,7 +727,7 @@ struct TokenResponse {
expires_in: Option<u64>, expires_in: Option<u64>,
} }
#[derive(serde::Deserialize, serde::Serialize)] #[derive(Clone, Default, serde::Deserialize, serde::Serialize)]
struct AuthRecord { struct AuthRecord {
access_token: Option<String>, access_token: Option<String>,
refresh_token: Option<String>, refresh_token: Option<String>,
@@ -641,3 +735,41 @@ struct AuthRecord {
expires_at: Option<i64>, expires_at: Option<i64>,
account_id: Option<String>, account_id: Option<String>,
} }
impl std::fmt::Debug for AuthRecord {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ChatGPTAuthRecord")
.field("credentials", &"<redacted>")
.field("expires_at", &self.expires_at)
.finish()
}
}
impl AuthRecord {
fn can_refresh(&self) -> bool {
self.refresh_token.as_deref().is_some_and(|token| !token.trim().is_empty())
}
fn access_context(&self) -> Result<ChatGPTAuthCredentials, String> {
let access_token = self.access_token.as_deref().filter(|token| !token.trim().is_empty())
.ok_or_else(|| SIGN_IN_REQUIRED.to_owned())?;
let account_id = self.account_id.clone().filter(|id| !id.trim().is_empty())
.or_else(|| extract_account_id(self.id_token.as_deref()))
.or_else(|| extract_account_id(Some(access_token)));
Ok(ChatGPTAuthCredentials { access_token: access_token.to_owned(), account_id })
}
fn credentials(&self, now: i64) -> Result<ChatGPTAuthCredentials, String> {
let expiry = self.expires_at.or_else(|| {
self.access_token.as_deref().and_then(extract_expiration_timestamp)
});
if expiry.is_none_or(|expiry| now >= expiry.saturating_sub(60)) {
return Err(SIGN_IN_REQUIRED.to_owned());
}
self.access_context()
}
}
#[cfg(test)]
#[path = "chatgpt_auth_tests.rs"]
mod tests;
+171
View File
@@ -0,0 +1,171 @@
use std::cell::Cell;
use futures::FutureExt;
use super::*;
fn record(access: &str, refresh: &str) -> AuthRecord {
AuthRecord {
access_token: Some(access.to_owned()),
refresh_token: Some(refresh.to_owned()),
id_token: None,
expires_at: Some(10_000),
account_id: Some("test-account".to_owned()),
}
}
#[test]
fn concurrent_requests_share_one_refresh_and_wait_for_persistence() {
let mut coordinator = RefreshCoordinator::default();
let (first_tx, mut first_rx) = oneshot::channel();
let (second_tx, mut second_rx) = oneshot::channel();
let generation = coordinator.begin(first_tx).unwrap();
assert_eq!(coordinator.begin(second_tx), None);
let saved = Cell::new(false);
let result = coordinator.complete(generation, Ok(record("rotated-access", "rotated-refresh")), |record| {
assert_eq!(first_rx.try_recv().unwrap(), None);
assert_eq!(second_rx.try_recv().unwrap(), None);
assert_eq!(record.refresh_token.as_deref(), Some("rotated-refresh"));
saved.set(true);
Ok(())
});
assert!(result.unwrap().is_ok());
assert!(saved.get());
let first = first_rx.now_or_never().unwrap().unwrap().unwrap();
let second = second_rx.now_or_never().unwrap().unwrap().unwrap();
assert_eq!(first, second);
assert_eq!(first.access_token, "rotated-access");
}
#[test]
fn failed_persistence_never_publishes_rotated_credentials() {
let mut coordinator = RefreshCoordinator::default();
let (tx, rx) = oneshot::channel();
let generation = coordinator.begin(tx).unwrap();
let result = coordinator.complete(generation, Ok(record("rotated", "new-refresh")), |_| {
Err("storage unavailable".to_owned())
}).unwrap().unwrap_err();
assert!(result.reauthenticate);
assert!(rx.now_or_never().unwrap().unwrap().is_err());
}
#[test]
fn replaced_sign_in_rejects_late_refresh_without_overwriting_new_session() {
let mut coordinator = RefreshCoordinator::default();
let (old_tx, old_rx) = oneshot::channel();
let old = coordinator.begin(old_tx).unwrap();
coordinator.invalidate();
assert!(old_rx.now_or_never().unwrap().unwrap().is_err());
let (new_tx, new_rx) = oneshot::channel();
let current = coordinator.begin(new_tx).unwrap();
assert_ne!(old, current);
assert!(coordinator.complete(old, Ok(record("stale", "stale-refresh")), |_| {
panic!("A stale refresh must not write credentials")
}).is_none());
assert!(coordinator.complete(current, Ok(record("current", "current-refresh")), |_| Ok(()))
.unwrap().is_ok());
assert_eq!(new_rx.now_or_never().unwrap().unwrap().unwrap().access_token, "current");
}
#[test]
fn reconnect_starts_a_new_generation_even_with_a_cached_refresh_token() {
let mut model = ChatGPTAuthModel::new_for_test();
model.record = Some(record("old-access", "already-used-refresh"));
model.state = ChatGPTAuthState::Connected;
let first = model.begin_sign_in().unwrap();
model.state = ChatGPTAuthState::AwaitingBrowser;
assert_eq!(model.begin_sign_in(), None);
model.state = ChatGPTAuthState::Failed("refresh_token_reused".to_owned());
let replacement = model.begin_sign_in().unwrap();
assert!(replacement > first);
}
#[test]
fn rotated_tokens_replace_previous_tokens_and_omitted_refresh_tokens_are_preserved() {
let previous = record("old-access", "old-refresh");
for refresh in [None, Some("new-refresh".to_owned())] {
let result = record_from_tokens(TokenResponse {
access_token: "new-access".to_owned(),
refresh_token: refresh.clone(),
id_token: None,
expires_in: Some(3600),
}, Some(&previous), 100).unwrap();
assert_eq!(result.access_token.as_deref(), Some("new-access"));
assert_eq!(result.refresh_token, refresh.or_else(|| Some("old-refresh".to_owned())));
assert_eq!(result.expires_at, Some(3700));
assert_eq!(result.account_id.as_deref(), Some("test-account"));
}
}
#[test]
fn rejected_refresh_tokens_require_reauthentication_without_echoing_tokens() {
for code in ["refresh_token_reused", "invalid_grant", "refresh_token_expired"] {
let body = serde_json::to_vec(&serde_json::json!({
"error": {"code": code, "message": "sensitive-refresh-token"}
})).unwrap();
let error = parse_token_response(401, &body).err().unwrap();
assert!(error.reauthenticate);
assert!(error.message.contains("sign in"));
assert!(!error.message.contains("sensitive-refresh-token"));
}
let error = parse_token_response(400, br#"{"error":"invalid_grant"}"#).err().unwrap();
assert!(error.reauthenticate);
}
#[test]
fn transient_refresh_failure_preserves_the_session_and_does_not_save_rejected_data() {
let error = parse_token_response(503, br#"{"message":"sensitive-data"}"#).err().unwrap();
assert!(!error.reauthenticate);
assert!(!error.message.contains("sensitive-data"));
let mut coordinator = RefreshCoordinator::default();
let (tx, rx) = oneshot::channel();
let generation = coordinator.begin(tx).unwrap();
let result = coordinator.complete(generation, Err(error), |_| {
panic!("Transient failures must not overwrite stored credentials")
}).unwrap().unwrap_err();
assert!(!result.reauthenticate);
assert!(rx.now_or_never().unwrap().unwrap().is_err());
}
#[test]
fn credential_debug_output_is_redacted_and_expiry_has_a_refresh_margin() {
let credentials = ChatGPTAuthCredentials {
access_token: "sensitive-access-token".to_owned(),
account_id: Some("account".to_owned()),
};
assert!(!format!("{credentials:?}").contains("sensitive-access-token"));
let credentials = record("sensitive-access-token", "sensitive-refresh-token");
assert!(!format!("{credentials:?}").contains("sensitive-access-token"));
assert!(!format!("{credentials:?}").contains("sensitive-refresh-token"));
assert!(credentials.credentials(100).is_ok());
assert!(credentials.credentials(9940).is_err());
assert!(AuthRecord::default().credentials(100).is_err());
}
#[test]
fn invalid_token_payloads_are_not_saved_or_exposed() {
assert!(parse_token_response(200, b"invalid-sensitive-data").is_err());
assert!(record_from_tokens(TokenResponse {
access_token: String::new(),
refresh_token: Some("refresh".to_owned()),
id_token: None,
expires_in: Some(3600),
}, None, 100).is_err());
assert!(record_from_tokens(TokenResponse {
access_token: "opaque-token-without-expiry".to_owned(),
refresh_token: None,
id_token: None,
expires_in: None,
}, None, 100).is_err());
}
#[test]
fn browser_authorization_keeps_pkce_and_state_validation() {
let url = build_authorize_url("http://localhost:1455/auth/callback", "challenge", "state").unwrap();
let params = url.query_pairs().collect::<std::collections::HashMap<_, _>>();
assert_eq!(params.get("code_challenge_method").map(|v| v.as_ref()), Some("S256"));
assert_eq!(params.get("state").map(|v| v.as_ref()), Some("state"));
let callback = parse_callback_request_line("GET /auth/callback?code=code&state=state HTTP/1.1").unwrap();
assert_eq!(callback.state, "state");
assert!(parse_callback_request_line("GET /auth/callback?code=code HTTP/1.1").is_err());
}
+1 -2
View File
@@ -215,9 +215,8 @@ impl CrosscheckReviewer {
model: config.model.unwrap_or(model_id), model: config.model.unwrap_or(model_id),
reasoning_effort: config.reasoning_effort, reasoning_effort: config.reasoning_effort,
max_output_tokens: Some(u64::from(REVIEWER_MAX_OUTPUT_TOKENS)), max_output_tokens: Some(u64::from(REVIEWER_MAX_OUTPUT_TOKENS)),
auth_file: None,
}, },
); ).with_credential_resolver(crate::ai::chatgpt_auth::credential_resolver());
runtime runtime
.complete_text( .complete_text(
prompt::CROSSCHECK_REVIEWER_SYSTEM_PROMPT.to_string(), prompt::CROSSCHECK_REVIEWER_SYSTEM_PROMPT.to_string(),
+1 -1
View File
@@ -90,7 +90,7 @@ pub fn init(app: &mut AppContext) {
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
app.add_singleton_model(acp::AcpRuntimeModel::new); app.add_singleton_model(acp::AcpRuntimeModel::new);
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
app.add_singleton_model(|_| chatgpt_auth::ChatGPTAuthModel::new()); app.add_singleton_model(chatgpt_auth::ChatGPTAuthModel::new);
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
app.add_singleton_model(claude_oauth::ClaudeOAuthModel::new); app.add_singleton_model(claude_oauth::ClaudeOAuthModel::new);
blocklist::keyboard_navigable_buttons::init(app); blocklist::keyboard_navigable_buttons::init(app);
+1 -2
View File
@@ -314,9 +314,8 @@ pub(crate) async fn provider_runtime_for_request(
model, model,
reasoning_effort: config.reasoning_effort, reasoning_effort: config.reasoning_effort,
max_output_tokens: config.max_output_tokens.map(u64::from), max_output_tokens: config.max_output_tokens.map(u64::from),
auth_file: None,
}, },
)), ).with_credential_resolver(crate::ai::chatgpt_auth::credential_resolver())),
OpenAIProviderKind::Anthropic => { OpenAIProviderKind::Anthropic => {
let credential = config.api_key.unwrap_or_default(); let credential = config.api_key.unwrap_or_default();
let stored = crate::ai::claude_subscription::is_stored_credential(&credential); let stored = crate::ai::claude_subscription::is_stored_credential(&credential);
+41 -12
View File
@@ -220,6 +220,7 @@ pub enum ProviderSetupViewAction {
ToggleModel(usize), ToggleModel(usize),
CycleModelCapability(usize, CapabilityKey), CycleModelCapability(usize, CapabilityKey),
ConnectChatGPT, ConnectChatGPT,
CancelChatGPTSignIn,
ConnectClaude, ConnectClaude,
SignOutClaude, SignOutClaude,
CancelClaudeSignIn, CancelClaudeSignIn,
@@ -1590,10 +1591,10 @@ impl ProviderSetupView {
); );
} }
if matches!( let pending = matches!(state,
state, ChatGPTAuthState::AwaitingBrowser | ChatGPTAuthState::ExchangingToken
ChatGPTAuthState::NotConnected | ChatGPTAuthState::Failed(_) );
) { {
children.push( children.push(
appearance appearance
.ui_builder() .ui_builder()
@@ -1601,10 +1602,21 @@ impl ProviderSetupView {
ButtonVariant::Secondary, ButtonVariant::Secondary,
self.chatgpt_connect_mouse_state.clone(), self.chatgpt_connect_mouse_state.clone(),
) )
.with_text_label("Connect ChatGPT".to_owned()) .with_text_label(if pending {
"Cancel sign-in"
} else if matches!(state, ChatGPTAuthState::Connected) {
"Reconnect ChatGPT"
} else {
"Connect ChatGPT"
}.to_owned())
.build() .build()
.on_click(|ctx, _, _| { .on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ProviderSetupViewAction::ConnectChatGPT); let action = if pending {
ProviderSetupViewAction::CancelChatGPTSignIn
} else {
ProviderSetupViewAction::ConnectChatGPT
};
ctx.dispatch_typed_action(action);
}) })
.finish(), .finish(),
); );
@@ -1861,7 +1873,7 @@ impl ProviderSetupView {
.finish() .finish()
} }
fn render_discovery(&self, appearance: &Appearance) -> Box<dyn galaxyui::Element> { fn render_discovery(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn galaxyui::Element> {
let (message, color) = match &self.discovery_state { let (message, color) = match &self.discovery_state {
DiscoveryState::Idle | DiscoveryState::Loading => ( DiscoveryState::Idle | DiscoveryState::Loading => (
if self.provider_type == ProviderSetupProviderType::Acp if self.provider_type == ProviderSetupProviderType::Acp
@@ -1875,7 +1887,7 @@ impl ProviderSetupView {
), ),
DiscoveryState::Failed(error) => (error.clone(), appearance.theme().ui_error_color()), DiscoveryState::Failed(error) => (error.clone(), appearance.theme().ui_error_color()),
}; };
Flex::column() let mut column = Flex::column()
.with_spacing(12.) .with_spacing(12.)
.with_child( .with_child(
Text::new( Text::new(
@@ -1913,8 +1925,13 @@ impl ProviderSetupView {
.with_color(color) .with_color(color)
.soft_wrap(true) .soft_wrap(true)
.finish() .finish()
}) });
.finish() if self.provider_type == ProviderSetupProviderType::ChatGPTSubscription
&& matches!(self.discovery_state, DiscoveryState::Failed(_))
{
column = column.with_child(self.render_chatgpt_auth(appearance, app));
}
column.finish()
} }
fn render_model_table( fn render_model_table(
@@ -2468,7 +2485,7 @@ impl View for ProviderSetupView {
let content = match self.step { let content = match self.step {
ProviderSetupStep::ProviderType => self.render_provider_type(appearance), ProviderSetupStep::ProviderType => self.render_provider_type(appearance),
ProviderSetupStep::Configure => self.render_configure(appearance, app), ProviderSetupStep::Configure => self.render_configure(appearance, app),
ProviderSetupStep::Discover => self.render_discovery(appearance), ProviderSetupStep::Discover => self.render_discovery(appearance, app),
ProviderSetupStep::Models => self.render_models(appearance), ProviderSetupStep::Models => self.render_models(appearance),
}; };
Flex::column() Flex::column()
@@ -2488,6 +2505,10 @@ impl TypedActionView for ProviderSetupView {
match action { match action {
ProviderSetupViewAction::SelectProvider(kind) => { ProviderSetupViewAction::SelectProvider(kind) => {
if self.provider_type != *kind { if self.provider_type != *kind {
#[cfg(not(target_family = "wasm"))]
if self.provider_type == ProviderSetupProviderType::ChatGPTSubscription {
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.cancel_sign_in(ctx));
}
self.cancel_claude_signin(); self.cancel_claude_signin();
self.draft_api_key = None; self.draft_api_key = None;
self.claude_credentials_available = false; self.claude_credentials_available = false;
@@ -2588,6 +2609,10 @@ impl TypedActionView for ProviderSetupView {
ProviderSetupViewAction::Cancel => { ProviderSetupViewAction::Cancel => {
self.cancel_acp_setup(); self.cancel_acp_setup();
self.cancel_claude_signin(); self.cancel_claude_signin();
#[cfg(not(target_family = "wasm"))]
if self.provider_type == ProviderSetupProviderType::ChatGPTSubscription {
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.cancel_sign_in(ctx));
}
self.discovery_state = DiscoveryState::Idle; self.discovery_state = DiscoveryState::Idle;
ctx.emit(ProviderSetupViewEvent::Close); ctx.emit(ProviderSetupViewEvent::Close);
} }
@@ -2633,6 +2658,10 @@ impl TypedActionView for ProviderSetupView {
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx)); ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx));
} }
ProviderSetupViewAction::CancelChatGPTSignIn => {
#[cfg(not(target_family = "wasm"))]
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.cancel_sign_in(ctx));
}
ProviderSetupViewAction::ConnectClaude => { ProviderSetupViewAction::ConnectClaude => {
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
self.connect_claude(ctx); self.connect_claude(ctx);
+52 -22
View File
@@ -1,6 +1,7 @@
use std::path::PathBuf; use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use futures::future::BoxFuture;
use futures::StreamExt; use futures::StreamExt;
use galaxy_agent_core::{ use galaxy_agent_core::{
AgentError, AgentErrorKind, AgentEvent, AgentEventStream, AgentRuntime, ConversationMessage, AgentError, AgentErrorKind, AgentEvent, AgentEventStream, AgentRuntime, ConversationMessage,
@@ -8,35 +9,50 @@ use galaxy_agent_core::{
TurnRequest, turn_control, TurnRequest, turn_control,
}; };
use rig_core::client::CompletionClient; use rig_core::client::CompletionClient;
use rig_core::providers::chatgpt; use rig_core::providers::chatgpt::{self, ChatGPTAuth};
use crate::request::build_completion_request; use crate::request::build_completion_request;
use crate::stream::start_model_turn; use crate::stream::start_model_turn;
/// Refreshes Galaxy's cached ChatGPT subscription credentials without allowing #[derive(Clone, PartialEq, Eq)]
/// Rig to fall back to its interactive device-code flow. pub struct ChatGPTSubscriptionCredentials {
pub async fn refresh_chatgpt_subscription_credentials() -> Result<(), String> { pub access_token: String,
let client = chatgpt::Client::builder() pub account_id: Option<String>,
.oauth()
.allow_device_flow(false)
.originator("galaxy")
.build()
.map_err(|error| error.to_string())?;
client.authorize().await.map_err(|error| error.to_string())
} }
impl std::fmt::Debug for ChatGPTSubscriptionCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ChatGPTSubscriptionCredentials")
.field("access_token", &"<redacted>")
.field("account_id", &self.account_id.as_ref().map(|_| "<redacted>"))
.finish()
}
}
pub type ChatGPTCredentialResolver = Arc<
dyn Fn() -> BoxFuture<'static, Result<ChatGPTSubscriptionCredentials, String>> + Send + Sync,
>;
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChatGPTSubscriptionRuntimeConfig { pub struct ChatGPTSubscriptionRuntimeConfig {
pub model: String, pub model: String,
pub reasoning_effort: Option<String>, pub reasoning_effort: Option<String>,
pub max_output_tokens: Option<u64>, pub max_output_tokens: Option<u64>,
pub auth_file: Option<PathBuf>,
} }
#[derive(Clone, Debug)] #[derive(Clone)]
pub struct ChatGPTSubscriptionRuntime { pub struct ChatGPTSubscriptionRuntime {
config: ChatGPTSubscriptionRuntimeConfig, config: ChatGPTSubscriptionRuntimeConfig,
descriptor: RuntimeDescriptor, descriptor: RuntimeDescriptor,
credential_resolver: Option<ChatGPTCredentialResolver>,
}
impl std::fmt::Debug for ChatGPTSubscriptionRuntime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ChatGPTSubscriptionRuntime")
.field("config", &self.config)
.field("has_credential_resolver", &self.credential_resolver.is_some())
.finish()
}
} }
fn reasoning_additional_params(reasoning_effort: Option<&str>) -> Option<serde_json::Value> { fn reasoning_additional_params(reasoning_effort: Option<&str>) -> Option<serde_json::Value> {
@@ -56,7 +72,24 @@ impl ChatGPTSubscriptionRuntime {
kind: RuntimeKind::Provider, kind: RuntimeKind::Provider,
capabilities: RuntimeCapabilities::provider(), capabilities: RuntimeCapabilities::provider(),
}; };
Self { config, descriptor } Self { config, descriptor, credential_resolver: None }
}
pub fn with_credential_resolver(mut self, resolver: ChatGPTCredentialResolver) -> Self {
self.credential_resolver = Some(resolver);
self
}
async fn resolve_auth(&self) -> Result<ChatGPTAuth, AgentError> {
let resolver = self.credential_resolver.as_ref().ok_or_else(|| {
AgentError::new(AgentErrorKind::Authentication, "ChatGPT sign-in required. Reconnect ChatGPT in Providers.")
})?;
let credentials = resolver().await
.map_err(|message| AgentError::new(AgentErrorKind::Authentication, message))?;
Ok(ChatGPTAuth::AccessToken {
access_token: credentials.access_token,
account_id: credentials.account_id,
})
} }
/// Completes a text-only request for callers such as crosscheck reviewers. /// Completes a text-only request for callers such as crosscheck reviewers.
@@ -117,14 +150,11 @@ impl AgentRuntime for ChatGPTSubscriptionRuntime {
request: TurnRequest, request: TurnRequest,
control: TurnControl, control: TurnControl,
) -> Result<AgentEventStream, AgentError> { ) -> Result<AgentEventStream, AgentError> {
let mut builder = chatgpt::Client::builder() let auth = self.resolve_auth().await?;
.oauth() let client = chatgpt::Client::builder()
.api_key(auth)
.allow_device_flow(false) .allow_device_flow(false)
.originator("galaxy"); .originator("galaxy")
if let Some(auth_file) = &self.config.auth_file {
builder = builder.auth_file(auth_file);
}
let client = builder
.build() .build()
.map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?; .map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?;
let model = client.completion_model(&self.config.model); let model = client.completion_model(&self.config.model);
+37 -1
View File
@@ -16,7 +16,6 @@ fn runtime_descriptor_identifies_chatgpt_subscription() {
model: "gpt-5.3-codex".to_string(), model: "gpt-5.3-codex".to_string(),
reasoning_effort: Some("high".to_string()), reasoning_effort: Some("high".to_string()),
max_output_tokens: None, max_output_tokens: None,
auth_file: None,
}); });
assert_eq!( assert_eq!(
@@ -25,6 +24,43 @@ fn runtime_descriptor_identifies_chatgpt_subscription() {
); );
} }
#[tokio::test]
async fn runtime_resolves_current_credentials_without_rig_oauth() {
let runtime = ChatGPTSubscriptionRuntime::new(ChatGPTSubscriptionRuntimeConfig {
model: "test-model".to_owned(),
reasoning_effort: None,
max_output_tokens: None,
}).with_credential_resolver(std::sync::Arc::new(|| {
Box::pin(async {
Ok(ChatGPTSubscriptionCredentials {
access_token: "resolved-token".to_owned(),
account_id: Some("resolved-account".to_owned()),
})
})
}));
match runtime.resolve_auth().await.unwrap() {
ChatGPTAuth::AccessToken { access_token, account_id } => {
assert_eq!(access_token, "resolved-token");
assert_eq!(account_id.as_deref(), Some("resolved-account"));
}
ChatGPTAuth::OAuth => panic!("The runtime must not own OAuth refresh"),
}
}
#[tokio::test]
async fn missing_or_failed_resolver_requires_sign_in_without_device_flow() {
let runtime = ChatGPTSubscriptionRuntime::new(ChatGPTSubscriptionRuntimeConfig {
model: "test-model".to_owned(),
reasoning_effort: None,
max_output_tokens: None,
});
assert_eq!(runtime.resolve_auth().await.unwrap_err().kind, AgentErrorKind::Authentication);
let runtime = runtime.with_credential_resolver(std::sync::Arc::new(|| {
Box::pin(async { Err("Sign in again".to_owned()) })
}));
assert_eq!(runtime.resolve_auth().await.unwrap_err().kind, AgentErrorKind::Authentication);
}
#[test] #[test]
fn reasoning_effort_is_encoded_for_the_responses_request() { fn reasoning_effort_is_encoded_for_the_responses_request() {
let request = TurnRequest::new( let request = TurnRequest::new(