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
+52 -22
View File
@@ -1,6 +1,7 @@
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use futures::future::BoxFuture;
use futures::StreamExt;
use galaxy_agent_core::{
AgentError, AgentErrorKind, AgentEvent, AgentEventStream, AgentRuntime, ConversationMessage,
@@ -8,35 +9,50 @@ use galaxy_agent_core::{
TurnRequest, turn_control,
};
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::stream::start_model_turn;
/// Refreshes Galaxy's cached ChatGPT subscription credentials without allowing
/// Rig to fall back to its interactive device-code flow.
pub async fn refresh_chatgpt_subscription_credentials() -> Result<(), String> {
let client = chatgpt::Client::builder()
.oauth()
.allow_device_flow(false)
.originator("galaxy")
.build()
.map_err(|error| error.to_string())?;
client.authorize().await.map_err(|error| error.to_string())
#[derive(Clone, PartialEq, Eq)]
pub struct ChatGPTSubscriptionCredentials {
pub access_token: String,
pub account_id: Option<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)]
pub struct ChatGPTSubscriptionRuntimeConfig {
pub model: String,
pub reasoning_effort: Option<String>,
pub max_output_tokens: Option<u64>,
pub auth_file: Option<PathBuf>,
}
#[derive(Clone, Debug)]
#[derive(Clone)]
pub struct ChatGPTSubscriptionRuntime {
config: ChatGPTSubscriptionRuntimeConfig,
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> {
@@ -56,7 +72,24 @@ impl ChatGPTSubscriptionRuntime {
kind: RuntimeKind::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.
@@ -117,14 +150,11 @@ impl AgentRuntime for ChatGPTSubscriptionRuntime {
request: TurnRequest,
control: TurnControl,
) -> Result<AgentEventStream, AgentError> {
let mut builder = chatgpt::Client::builder()
.oauth()
let auth = self.resolve_auth().await?;
let client = chatgpt::Client::builder()
.api_key(auth)
.allow_device_flow(false)
.originator("galaxy");
if let Some(auth_file) = &self.config.auth_file {
builder = builder.auth_file(auth_file);
}
let client = builder
.originator("galaxy")
.build()
.map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?;
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(),
reasoning_effort: Some("high".to_string()),
max_output_tokens: None,
auth_file: None,
});
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]
fn reasoning_effort_is_encoded_for_the_responses_request() {
let request = TurnRequest::new(