Implement browser OAuth for ChatGPT subscriptions
Update app branding and OAuth callback handling, remove legacy Samsung theme aliases, prune unavailable ChatGPT models, and delete cost data.
This commit is contained in:
+340
-49
@@ -1,41 +1,55 @@
|
|||||||
//! ChatGPT subscription OAuth state used by the AI settings page.
|
//! ChatGPT subscription browser OAuth state used by the AI settings page.
|
||||||
|
//!
|
||||||
|
//! Instead of using Rig's device-code flow (which requires users to copy a code),
|
||||||
|
//! this module implements a standard OAuth 2.0 Authorization Code + PKCE flow:
|
||||||
|
//! 1. Open the browser to OpenAI's authorize endpoint
|
||||||
|
//! 2. User approves in browser
|
||||||
|
//! 3. Browser redirects back to `galaxy://chatgpt/oauth2callback?code=...&state=...`
|
||||||
|
//! 4. We exchange the code for tokens and write them to Rig's auth file
|
||||||
|
|
||||||
use async_channel::unbounded;
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
use galaxy_agent_rig::{ChatGPTDeviceCode, ChatGPTSubscriptionClient};
|
use base64::Engine;
|
||||||
|
use galaxy_core::channel::ChannelState;
|
||||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||||
|
use rand::Rng;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
const CHATGPT_AUTHORIZE_URL: &str = "https://auth.openai.com/api/accounts/authorize";
|
||||||
|
const CHATGPT_TOKEN_URL: &str = "https://auth.openai.com/api/accounts/oauth/token";
|
||||||
|
const CHATGPT_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||||
|
const CHATGPT_SCOPES: &str = "openid profile email offline_access";
|
||||||
|
|
||||||
/// 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)]
|
||||||
pub(crate) enum ChatGPTAuthState {
|
pub(crate) enum ChatGPTAuthState {
|
||||||
NotConnected,
|
NotConnected,
|
||||||
Connecting,
|
AwaitingBrowser,
|
||||||
AwaitingDeviceCode {
|
ExchangingToken,
|
||||||
verification_uri: String,
|
|
||||||
user_code: String,
|
|
||||||
},
|
|
||||||
Connected,
|
Connected,
|
||||||
Failed(String),
|
Failed(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ChatGPTAuthEvent {
|
|
||||||
DeviceCode(ChatGPTDeviceCode),
|
|
||||||
Completed(Result<(), String>),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) enum ChatGPTAuthModelEvent {
|
pub(crate) enum ChatGPTAuthModelEvent {
|
||||||
StateChanged,
|
StateChanged,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Coordinates Rig's device authorization flow with Galaxy UI.
|
/// Coordinates browser-based OAuth authorization for ChatGPT subscriptions.
|
||||||
pub(crate) struct ChatGPTAuthModel {
|
pub(crate) struct ChatGPTAuthModel {
|
||||||
state: ChatGPTAuthState,
|
state: ChatGPTAuthState,
|
||||||
|
/// PKCE code verifier stored between authorize and callback.
|
||||||
|
pending_code_verifier: Option<String>,
|
||||||
|
/// CSRF state token stored between authorize and callback.
|
||||||
|
pending_state: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatGPTAuthModel {
|
impl ChatGPTAuthModel {
|
||||||
pub(crate) fn new() -> Self {
|
pub(crate) fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
state: ChatGPTAuthState::NotConnected,
|
state: ChatGPTAuthState::NotConnected,
|
||||||
|
pending_code_verifier: None,
|
||||||
|
pending_state: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,55 +57,116 @@ impl ChatGPTAuthModel {
|
|||||||
&self.state
|
&self.state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attempts to connect using existing Codex credentials, falling back to browser OAuth.
|
||||||
pub(crate) fn connect(&mut self, ctx: &mut ModelContext<Self>) {
|
pub(crate) fn connect(&mut self, ctx: &mut ModelContext<Self>) {
|
||||||
if matches!(
|
if matches!(
|
||||||
self.state,
|
self.state,
|
||||||
ChatGPTAuthState::Connecting | ChatGPTAuthState::AwaitingDeviceCode { .. }
|
ChatGPTAuthState::AwaitingBrowser | ChatGPTAuthState::ExchangingToken
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.state = ChatGPTAuthState::Connecting;
|
// Try to import credentials from ~/.codex/auth.json first.
|
||||||
|
if let Ok(()) = import_codex_credentials() {
|
||||||
|
self.state = ChatGPTAuthState::Connected;
|
||||||
|
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No existing credentials — start the browser OAuth flow.
|
||||||
|
let code_verifier = generate_random_string(64);
|
||||||
|
let code_challenge = compute_code_challenge(&code_verifier);
|
||||||
|
let state = generate_random_string(32);
|
||||||
|
let redirect_uri = chatgpt_redirect_uri();
|
||||||
|
|
||||||
|
let authorize_url = format!(
|
||||||
|
"{CHATGPT_AUTHORIZE_URL}?\
|
||||||
|
client_id={CHATGPT_CLIENT_ID}\
|
||||||
|
&response_type=code\
|
||||||
|
&redirect_uri={redirect_uri}\
|
||||||
|
&code_challenge={code_challenge}\
|
||||||
|
&code_challenge_method=S256\
|
||||||
|
&state={state}\
|
||||||
|
&scope={}",
|
||||||
|
urlencoding::encode(CHATGPT_SCOPES),
|
||||||
|
);
|
||||||
|
|
||||||
|
self.pending_code_verifier = Some(code_verifier);
|
||||||
|
self.pending_state = Some(state);
|
||||||
|
self.state = ChatGPTAuthState::AwaitingBrowser;
|
||||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||||
|
|
||||||
let (event_tx, event_rx) = unbounded();
|
ctx.open_url(&authorize_url);
|
||||||
let device_code_tx = event_tx.clone();
|
}
|
||||||
let _ = ctx.spawn_stream_local(
|
|
||||||
event_rx,
|
/// Called when the OS routes back `galaxy://chatgpt/oauth2callback?code=...&state=...`
|
||||||
|model, event, ctx| {
|
pub(crate) fn handle_oauth_callback(&mut self, url: &Url, ctx: &mut ModelContext<Self>) {
|
||||||
match event {
|
let Some(expected_state) = self.pending_state.take() else {
|
||||||
ChatGPTAuthEvent::DeviceCode(code) => {
|
self.fail(
|
||||||
model.state = ChatGPTAuthState::AwaitingDeviceCode {
|
"Received OAuth callback but no authorization was in progress.",
|
||||||
verification_uri: code.verification_uri,
|
ctx,
|
||||||
user_code: code.user_code,
|
);
|
||||||
};
|
return;
|
||||||
}
|
};
|
||||||
ChatGPTAuthEvent::Completed(result) => {
|
let Some(code_verifier) = self.pending_code_verifier.take() else {
|
||||||
model.state = match result {
|
self.fail("Received OAuth callback but code verifier is missing.", ctx);
|
||||||
Ok(()) => ChatGPTAuthState::Connected,
|
return;
|
||||||
Err(error) => ChatGPTAuthState::Failed(error),
|
};
|
||||||
};
|
|
||||||
}
|
// Extract query parameters
|
||||||
}
|
let params: std::collections::HashMap<_, _> = url.query_pairs().collect();
|
||||||
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
|
||||||
},
|
// Check for error response from the authorization server
|
||||||
|_, _| {},
|
if let Some(error) = params.get("error") {
|
||||||
);
|
let description = params
|
||||||
|
.get("error_description")
|
||||||
|
.map(|d| d.to_string())
|
||||||
|
.unwrap_or_else(|| error.to_string());
|
||||||
|
self.fail(&format!("ChatGPT authorization denied: {description}"), ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(code) = params.get("code") else {
|
||||||
|
self.fail("OAuth callback missing authorization code.", ctx);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let code = code.to_string();
|
||||||
|
|
||||||
|
let Some(state) = params.get("state") else {
|
||||||
|
self.fail("OAuth callback missing state parameter.", ctx);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if *state != expected_state {
|
||||||
|
self.fail("OAuth callback state mismatch (possible CSRF).", ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.state = ChatGPTAuthState::ExchangingToken;
|
||||||
|
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||||
|
|
||||||
|
let redirect_uri = chatgpt_redirect_uri();
|
||||||
|
|
||||||
let _ = ctx.spawn(
|
let _ = ctx.spawn(
|
||||||
async move {
|
async move { exchange_code_for_tokens(&code, &code_verifier, &redirect_uri).await },
|
||||||
let result =
|
|model, result, ctx| match result {
|
||||||
match ChatGPTSubscriptionClient::with_device_code_handler(move |code| {
|
Ok(()) => {
|
||||||
let _ = device_code_tx.try_send(ChatGPTAuthEvent::DeviceCode(code));
|
model.state = ChatGPTAuthState::Connected;
|
||||||
}) {
|
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||||
Ok(client) => client.authorize().await,
|
}
|
||||||
Err(error) => Err(error),
|
Err(error) => {
|
||||||
};
|
model.fail(&error, ctx);
|
||||||
let _ = event_tx.send(ChatGPTAuthEvent::Completed(result)).await;
|
}
|
||||||
},
|
},
|
||||||
|_, _, _| {},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fail(&mut self, message: &str, ctx: &mut ModelContext<Self>) {
|
||||||
|
self.state = ChatGPTAuthState::Failed(message.to_string());
|
||||||
|
self.pending_code_verifier = None;
|
||||||
|
self.pending_state = None;
|
||||||
|
ctx.emit(ChatGPTAuthModelEvent::StateChanged);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Entity for ChatGPTAuthModel {
|
impl Entity for ChatGPTAuthModel {
|
||||||
@@ -99,3 +174,219 @@ impl Entity for ChatGPTAuthModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SingletonEntity for ChatGPTAuthModel {}
|
impl SingletonEntity for ChatGPTAuthModel {}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn chatgpt_redirect_uri() -> String {
|
||||||
|
format!("{}://chatgpt/oauth2callback", ChannelState::url_scheme())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to read tokens from `~/.codex/auth.json` and write them to Rig's auth file.
|
||||||
|
/// Returns `Ok(())` if credentials were found and successfully imported.
|
||||||
|
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(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.ok_or("No access_token")?;
|
||||||
|
|
||||||
|
let expires_at = extract_expiration_timestamp(access_token);
|
||||||
|
|
||||||
|
// If the token is expired, don't import stale credentials.
|
||||||
|
if let Some(exp) = expires_at {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
if now >= exp - 60 {
|
||||||
|
return Err("Codex access token is expired".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let refresh_token = tokens
|
||||||
|
.get("refresh_token")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
|
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(Some(access_token)));
|
||||||
|
|
||||||
|
let record = AuthRecord {
|
||||||
|
access_token: Some(access_token.to_owned()),
|
||||||
|
refresh_token,
|
||||||
|
id_token,
|
||||||
|
expires_at,
|
||||||
|
account_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
write_auth_file(&record)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codex_auth_file_path() -> Option<std::path::PathBuf> {
|
||||||
|
if let Some(codex_home) = std::env::var_os("CODEX_HOME") {
|
||||||
|
return Some(std::path::PathBuf::from(codex_home).join("auth.json"));
|
||||||
|
}
|
||||||
|
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".codex").join("auth.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_random_string(len: usize) -> String {
|
||||||
|
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
(0..len)
|
||||||
|
.map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_code_challenge(verifier: &str) -> String {
|
||||||
|
let hash = Sha256::digest(verifier.as_bytes());
|
||||||
|
URL_SAFE_NO_PAD.encode(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exchange the authorization code for tokens and write them to Rig's auth file.
|
||||||
|
async fn exchange_code_for_tokens(
|
||||||
|
code: &str,
|
||||||
|
code_verifier: &str,
|
||||||
|
redirect_uri: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let form = [
|
||||||
|
("grant_type", "authorization_code"),
|
||||||
|
("client_id", CHATGPT_CLIENT_ID),
|
||||||
|
("code", code),
|
||||||
|
("redirect_uri", redirect_uri),
|
||||||
|
("code_verifier", code_verifier),
|
||||||
|
];
|
||||||
|
|
||||||
|
let body = url::form_urlencoded::Serializer::new(String::new())
|
||||||
|
.extend_pairs(form)
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(CHATGPT_TOKEN_URL)
|
||||||
|
.header(
|
||||||
|
reqwest::header::CONTENT_TYPE,
|
||||||
|
"application/x-www-form-urlencoded",
|
||||||
|
)
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Token exchange request failed: {e}"))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("Token exchange failed: {status} {body}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token_response: TokenResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse token response: {e}"))?;
|
||||||
|
|
||||||
|
let access_token = token_response.access_token;
|
||||||
|
let refresh_token = token_response.refresh_token;
|
||||||
|
let id_token = token_response.id_token;
|
||||||
|
|
||||||
|
let expires_at = extract_expiration_timestamp(&access_token);
|
||||||
|
let account_id =
|
||||||
|
extract_account_id(id_token.as_deref()).or_else(|| extract_account_id(Some(&access_token)));
|
||||||
|
|
||||||
|
let auth_record = AuthRecord {
|
||||||
|
access_token: Some(access_token),
|
||||||
|
refresh_token,
|
||||||
|
id_token,
|
||||||
|
expires_at,
|
||||||
|
account_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
write_auth_file(&auth_record)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_auth_file(record: &AuthRecord) -> Result<(), String> {
|
||||||
|
let path = auth_file_path().ok_or("Cannot determine auth file path")?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("Failed to create auth directory: {e}"))?;
|
||||||
|
}
|
||||||
|
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> {
|
||||||
|
decode_jwt_claims(token)
|
||||||
|
.get("exp")
|
||||||
|
.and_then(|v| v.as_i64().or_else(|| v.as_u64().map(|u| u as i64)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_account_id(token: Option<&str>) -> Option<String> {
|
||||||
|
let claims = decode_jwt_claims(token?);
|
||||||
|
claims
|
||||||
|
.get("https://api.openai.com/auth")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.and_then(|map| map.get("chatgpt_account_id"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_jwt_claims(token: &str) -> serde_json::Value {
|
||||||
|
let payload = token.split('.').nth(1).unwrap_or_default();
|
||||||
|
let decoded = URL_SAFE_NO_PAD.decode(payload.as_bytes());
|
||||||
|
decoded
|
||||||
|
.ok()
|
||||||
|
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
|
||||||
|
.unwrap_or(serde_json::Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct TokenResponse {
|
||||||
|
access_token: String,
|
||||||
|
refresh_token: Option<String>,
|
||||||
|
id_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct AuthRecord {
|
||||||
|
access_token: Option<String>,
|
||||||
|
refresh_token: Option<String>,
|
||||||
|
id_token: Option<String>,
|
||||||
|
expires_at: Option<i64>,
|
||||||
|
account_id: Option<String>,
|
||||||
|
}
|
||||||
|
|||||||
+4
-4
@@ -12,7 +12,7 @@ fn main() -> Result<()> {
|
|||||||
let mut state = ChannelState::new(
|
let mut state = ChannelState::new(
|
||||||
Channel::Oss,
|
Channel::Oss,
|
||||||
ChannelConfig {
|
ChannelConfig {
|
||||||
app_id: AppId::new("com", "samsung", "Galaxy"),
|
app_id: AppId::new("com", "galaxy", "Galaxy"),
|
||||||
logfile_name: "galaxy.log".into(),
|
logfile_name: "galaxy.log".into(),
|
||||||
server_config: WarpServerConfig::disabled(),
|
server_config: WarpServerConfig::disabled(),
|
||||||
oz_config: OzConfig::production(),
|
oz_config: OzConfig::production(),
|
||||||
@@ -51,7 +51,7 @@ embed_plist::embed_info_plist_bytes!(r#"
|
|||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>galaxy-oss</string>
|
<string>galaxy-oss</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>samsung.galaxy.GalaxyOss</string>
|
<string>com.galaxy.GalaxyOss</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
@@ -67,9 +67,9 @@ embed_plist::embed_info_plist_bytes!(r#"
|
|||||||
<key>UIDesignRequiresCompatibility</key>
|
<key>UIDesignRequiresCompatibility</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array><dict><key>CFBundleURLName</key><string>Galaxy</string><key>CFBundleURLSchemes</key><array><string>galaxyai</string></array></dict></array>
|
<array><dict><key>CFBundleURLName</key><string>Galaxy</string><key>CFBundleURLSchemes</key><array><string>galaxy</string></array></dict></array>
|
||||||
<key>NSHumanReadableCopyright</key>
|
<key>NSHumanReadableCopyright</key>
|
||||||
<string>© 2026, Samsung Electronics Co., Ltd.</string>
|
<string>© 2026, Galaxy Project</string>
|
||||||
<key>NSDockTilePlugIn</key>
|
<key>NSDockTilePlugIn</key>
|
||||||
<string>GalaxyDockTilePlugin.docktileplugin</string>
|
<string>GalaxyDockTilePlugin.docktileplugin</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -1071,34 +1071,6 @@ fn default_chatgpt_models() -> Vec<OpenAIModelConfig> {
|
|||||||
default_context_size(),
|
default_context_size(),
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
(
|
|
||||||
"gpt-5.3-codex",
|
|
||||||
"GPT-5.3 Codex",
|
|
||||||
vec!["low", "medium", "high", "xhigh"],
|
|
||||||
default_context_size(),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"gpt-5.3-codex-spark",
|
|
||||||
"GPT-5.3 Codex Spark",
|
|
||||||
vec![],
|
|
||||||
128_000,
|
|
||||||
Some(121_600),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"gpt-5.3-instant",
|
|
||||||
"GPT-5.3 Instant",
|
|
||||||
vec![],
|
|
||||||
default_context_size(),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"gpt-5.3-chat-latest",
|
|
||||||
"GPT-5.3 Chat Latest",
|
|
||||||
vec![],
|
|
||||||
default_context_size(),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ use crate::ai::blocklist::agent_view::agent_input_footer::editor::{
|
|||||||
};
|
};
|
||||||
use crate::ai::blocklist::BlocklistAIPermissions;
|
use crate::ai::blocklist::BlocklistAIPermissions;
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
use crate::ai::chatgpt_auth::{ChatGPTAuthModel, ChatGPTAuthModelEvent, ChatGPTAuthState};
|
use crate::ai::chatgpt_auth::{ChatGPTAuthModel, ChatGPTAuthModelEvent};
|
||||||
use crate::ai::execution_profiles::model_menu_items::available_model_menu_items;
|
use crate::ai::execution_profiles::model_menu_items::available_model_menu_items;
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
use crate::ai::execution_profiles::profiles::{
|
use crate::ai::execution_profiles::profiles::{
|
||||||
@@ -3735,43 +3735,11 @@ impl TypedActionView for AISettingsPageView {
|
|||||||
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx));
|
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx));
|
||||||
ctx.notify();
|
ctx.notify();
|
||||||
}
|
}
|
||||||
AISettingsPageAction::OpenChatGPTDevicePage =>
|
AISettingsPageAction::OpenChatGPTDevicePage => {
|
||||||
{
|
// No-op: device-code flow removed in favor of browser OAuth.
|
||||||
#[cfg(not(target_family = "wasm"))]
|
|
||||||
if let ChatGPTAuthState::AwaitingDeviceCode {
|
|
||||||
verification_uri, ..
|
|
||||||
} = ChatGPTAuthModel::as_ref(ctx).state()
|
|
||||||
{
|
|
||||||
ctx.open_url(verification_uri);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
AISettingsPageAction::CopyChatGPTDeviceCode => {
|
AISettingsPageAction::CopyChatGPTDeviceCode => {
|
||||||
#[cfg(not(target_family = "wasm"))]
|
// No-op: device-code flow removed in favor of browser OAuth.
|
||||||
let user_code = match ChatGPTAuthModel::as_ref(ctx).state() {
|
|
||||||
ChatGPTAuthState::AwaitingDeviceCode { user_code, .. } => {
|
|
||||||
Some(user_code.clone())
|
|
||||||
}
|
|
||||||
ChatGPTAuthState::NotConnected
|
|
||||||
| ChatGPTAuthState::Connecting
|
|
||||||
| ChatGPTAuthState::Connected
|
|
||||||
| ChatGPTAuthState::Failed(_) => None,
|
|
||||||
};
|
|
||||||
#[cfg(target_family = "wasm")]
|
|
||||||
let user_code: Option<String> = None;
|
|
||||||
if let Some(user_code) = user_code {
|
|
||||||
ctx.clipboard()
|
|
||||||
.write(ClipboardContent::plain_text(user_code));
|
|
||||||
let window_id = ctx.window_id();
|
|
||||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
|
||||||
toast_stack.add_ephemeral_toast(
|
|
||||||
crate::view_components::DismissibleToast::success(
|
|
||||||
"ChatGPT device code copied.".to_string(),
|
|
||||||
),
|
|
||||||
window_id,
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
AISettingsPageAction::ToggleAcpEnabled => {
|
AISettingsPageAction::ToggleAcpEnabled => {
|
||||||
if cfg!(unix) {
|
if cfg!(unix) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use galaxy_cli::agent::Harness;
|
use galaxy_cli::agent::Harness;
|
||||||
use galaxy_core::ui::theme::Fill;
|
use galaxy_core::ui::theme::Fill;
|
||||||
use galaxyui::clipboard::ClipboardContent;
|
|
||||||
use galaxyui::elements::{
|
use galaxyui::elements::{
|
||||||
Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
|
Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
|
||||||
CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement, MainAxisAlignment, MainAxisSize,
|
CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement, MainAxisAlignment, MainAxisSize,
|
||||||
@@ -225,8 +224,6 @@ pub struct ProviderSetupModalBody {
|
|||||||
acp_command_editor: ViewHandle<EditorView>,
|
acp_command_editor: ViewHandle<EditorView>,
|
||||||
acp_args_editor: ViewHandle<EditorView>,
|
acp_args_editor: ViewHandle<EditorView>,
|
||||||
chatgpt_connect_mouse_state: MouseStateHandle,
|
chatgpt_connect_mouse_state: MouseStateHandle,
|
||||||
chatgpt_open_mouse_state: MouseStateHandle,
|
|
||||||
chatgpt_copy_mouse_state: MouseStateHandle,
|
|
||||||
bedrock_auth_buttons: Vec<ViewHandle<ActionButton>>,
|
bedrock_auth_buttons: Vec<ViewHandle<ActionButton>>,
|
||||||
bedrock_cross_region_toggle: SwitchStateHandle,
|
bedrock_cross_region_toggle: SwitchStateHandle,
|
||||||
bedrock_auto_login_toggle: SwitchStateHandle,
|
bedrock_auto_login_toggle: SwitchStateHandle,
|
||||||
@@ -460,8 +457,6 @@ impl ProviderSetupModalBody {
|
|||||||
acp_command_editor,
|
acp_command_editor,
|
||||||
acp_args_editor,
|
acp_args_editor,
|
||||||
chatgpt_connect_mouse_state: MouseStateHandle::default(),
|
chatgpt_connect_mouse_state: MouseStateHandle::default(),
|
||||||
chatgpt_open_mouse_state: MouseStateHandle::default(),
|
|
||||||
chatgpt_copy_mouse_state: MouseStateHandle::default(),
|
|
||||||
bedrock_auth_buttons,
|
bedrock_auth_buttons,
|
||||||
bedrock_cross_region_toggle: SwitchStateHandle::default(),
|
bedrock_cross_region_toggle: SwitchStateHandle::default(),
|
||||||
bedrock_auto_login_toggle: SwitchStateHandle::default(),
|
bedrock_auto_login_toggle: SwitchStateHandle::default(),
|
||||||
@@ -1129,10 +1124,8 @@ impl ProviderSetupModalBody {
|
|||||||
let mut children = vec![Self::render_label(appearance, "ChatGPT authorization")];
|
let mut children = vec![Self::render_label(appearance, "ChatGPT authorization")];
|
||||||
let description = match &state {
|
let description = match &state {
|
||||||
ChatGPTAuthState::NotConnected => "Connect your ChatGPT subscription to continue.",
|
ChatGPTAuthState::NotConnected => "Connect your ChatGPT subscription to continue.",
|
||||||
ChatGPTAuthState::Connecting => "Waiting for ChatGPT authorization to start...",
|
ChatGPTAuthState::AwaitingBrowser => "Waiting for ChatGPT sign-in in your browser...",
|
||||||
ChatGPTAuthState::AwaitingDeviceCode { .. } => {
|
ChatGPTAuthState::ExchangingToken => "Completing sign-in...",
|
||||||
"Enter the device code in the ChatGPT sign-in page."
|
|
||||||
}
|
|
||||||
ChatGPTAuthState::Connected => "ChatGPT subscription connected.",
|
ChatGPTAuthState::Connected => "ChatGPT subscription connected.",
|
||||||
ChatGPTAuthState::Failed(_) => "ChatGPT connection failed.",
|
ChatGPTAuthState::Failed(_) => "ChatGPT connection failed.",
|
||||||
};
|
};
|
||||||
@@ -1151,79 +1144,9 @@ impl ProviderSetupModalBody {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let ChatGPTAuthState::AwaitingDeviceCode {
|
if matches!(
|
||||||
verification_uri,
|
|
||||||
user_code,
|
|
||||||
} = &state
|
|
||||||
{
|
|
||||||
children.push(
|
|
||||||
Container::new(
|
|
||||||
FormattedTextElement::from_str(
|
|
||||||
user_code.clone(),
|
|
||||||
appearance.monospace_font_family(),
|
|
||||||
24.,
|
|
||||||
)
|
|
||||||
.with_weight(Weight::Bold)
|
|
||||||
.with_color(appearance.theme().active_ui_text_color().into())
|
|
||||||
.finish(),
|
|
||||||
)
|
|
||||||
.with_padding(Padding::uniform(12.))
|
|
||||||
.with_background(appearance.theme().surface_1())
|
|
||||||
.with_border(Border::all(1.).with_border_fill(appearance.theme().accent()))
|
|
||||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
|
||||||
.finish(),
|
|
||||||
);
|
|
||||||
let buttons = Flex::row()
|
|
||||||
.with_spacing(8.)
|
|
||||||
.with_child(
|
|
||||||
appearance
|
|
||||||
.ui_builder()
|
|
||||||
.button(
|
|
||||||
ButtonVariant::Secondary,
|
|
||||||
self.chatgpt_open_mouse_state.clone(),
|
|
||||||
)
|
|
||||||
.with_text_label("Open sign-in page".to_owned())
|
|
||||||
.build()
|
|
||||||
.on_click(|ctx, _, _| {
|
|
||||||
ctx.dispatch_typed_action(
|
|
||||||
ProviderSetupModalBodyAction::OpenChatGPTDevicePage,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.finish(),
|
|
||||||
)
|
|
||||||
.with_child(
|
|
||||||
appearance
|
|
||||||
.ui_builder()
|
|
||||||
.button(
|
|
||||||
ButtonVariant::Secondary,
|
|
||||||
self.chatgpt_copy_mouse_state.clone(),
|
|
||||||
)
|
|
||||||
.with_text_label("Copy code".to_owned())
|
|
||||||
.build()
|
|
||||||
.on_click(|ctx, _, _| {
|
|
||||||
ctx.dispatch_typed_action(
|
|
||||||
ProviderSetupModalBodyAction::CopyChatGPTDeviceCode,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.finish(),
|
|
||||||
)
|
|
||||||
.finish();
|
|
||||||
children.push(buttons);
|
|
||||||
children.push(
|
|
||||||
Text::new(
|
|
||||||
verification_uri.clone(),
|
|
||||||
appearance.ui_font_family(),
|
|
||||||
INPUT_FONT_SIZE,
|
|
||||||
)
|
|
||||||
.with_color(appearance.theme().nonactive_ui_text_color().into())
|
|
||||||
.soft_wrap(true)
|
|
||||||
.finish(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !matches!(
|
|
||||||
state,
|
state,
|
||||||
ChatGPTAuthState::Connected | ChatGPTAuthState::Connecting
|
ChatGPTAuthState::NotConnected | ChatGPTAuthState::Failed(_)
|
||||||
) {
|
) {
|
||||||
children.push(
|
children.push(
|
||||||
appearance
|
appearance
|
||||||
@@ -2096,25 +2019,10 @@ impl TypedActionView for ProviderSetupModalBody {
|
|||||||
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx));
|
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx));
|
||||||
}
|
}
|
||||||
ProviderSetupModalBodyAction::OpenChatGPTDevicePage => {
|
ProviderSetupModalBodyAction::OpenChatGPTDevicePage => {
|
||||||
#[cfg(not(target_family = "wasm"))]
|
// No-op: device-code flow removed in favor of browser OAuth.
|
||||||
let auth_state = ChatGPTAuthModel::as_ref(ctx).state().clone();
|
|
||||||
let verification_uri = match auth_state {
|
|
||||||
ChatGPTAuthState::AwaitingDeviceCode {
|
|
||||||
verification_uri, ..
|
|
||||||
} => Some(verification_uri),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
if let Some(verification_uri) = verification_uri {
|
|
||||||
ctx.open_url(&verification_uri);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ProviderSetupModalBodyAction::CopyChatGPTDeviceCode => {
|
ProviderSetupModalBodyAction::CopyChatGPTDeviceCode => {
|
||||||
#[cfg(not(target_family = "wasm"))]
|
// No-op: device-code flow removed in favor of browser OAuth.
|
||||||
let auth_state = ChatGPTAuthModel::as_ref(ctx).state().clone();
|
|
||||||
if let ChatGPTAuthState::AwaitingDeviceCode { user_code, .. } = auth_state {
|
|
||||||
ctx.clipboard()
|
|
||||||
.write(ClipboardContent::plain_text(user_code));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ProviderSetupModalBodyAction::SelectBedrockAuth(method) => {
|
ProviderSetupModalBodyAction::SelectBedrockAuth(method) => {
|
||||||
self.draft_bedrock.auth_method = *method;
|
self.draft_bedrock.auth_method = *method;
|
||||||
|
|||||||
@@ -47,10 +47,8 @@ pub enum ThemeKind {
|
|||||||
ReceivedReferralReward,
|
ReceivedReferralReward,
|
||||||
#[schemars(description = "Adeberry")]
|
#[schemars(description = "Adeberry")]
|
||||||
Adeberry,
|
Adeberry,
|
||||||
#[serde(alias = "SamsungDark")]
|
|
||||||
#[schemars(description = "Galaxy Dark")]
|
#[schemars(description = "Galaxy Dark")]
|
||||||
GalaxyDark,
|
GalaxyDark,
|
||||||
#[serde(alias = "SamsungLight")]
|
|
||||||
#[schemars(description = "Galaxy Day")]
|
#[schemars(description = "Galaxy Day")]
|
||||||
GalaxyDay,
|
GalaxyDay,
|
||||||
#[schemars(description = "Phenomenon")]
|
#[schemars(description = "Phenomenon")]
|
||||||
|
|||||||
@@ -27,18 +27,6 @@ fn assert_custom_theme_is_not_syncable(custom_theme: CustomTheme) {
|
|||||||
assert!(!ThemeKind::Custom(custom_theme).is_custom_theme_reference_syncable());
|
assert!(!ThemeKind::Custom(custom_theme).is_custom_theme_reference_syncable());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn legacy_samsung_theme_names_deserialize_as_galaxy_themes() {
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_str::<ThemeKind>(r#""SamsungDark""#).unwrap(),
|
|
||||||
ThemeKind::GalaxyDark
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_str::<ThemeKind>(r#""SamsungLight""#).unwrap(),
|
|
||||||
ThemeKind::GalaxyDay
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn galaxy_theme_names_serialize_without_legacy_branding() {
|
fn galaxy_theme_names_serialize_without_legacy_branding() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -51,18 +39,6 @@ fn galaxy_theme_names_serialize_without_legacy_branding() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn legacy_samsung_theme_settings_values_deserialize_as_galaxy_themes() {
|
|
||||||
assert_eq!(
|
|
||||||
ThemeKind::from_file_value(&serde_json::json!("samsung_dark")),
|
|
||||||
Some(ThemeKind::GalaxyDark)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
ThemeKind::from_file_value(&serde_json::json!("samsung_light")),
|
|
||||||
Some(ThemeKind::GalaxyDay)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn galaxy_theme_settings_values_serialize_without_legacy_branding() {
|
fn galaxy_theme_settings_values_serialize_without_legacy_branding() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
+13
-1
@@ -112,6 +112,8 @@ pub enum UriHost {
|
|||||||
TabConfig,
|
TabConfig,
|
||||||
/// Focuses a specific terminal pane by its persistent session UUID.
|
/// Focuses a specific terminal pane by its persistent session UUID.
|
||||||
Session,
|
Session,
|
||||||
|
/// Handles OAuth callbacks for ChatGPT subscription authorization.
|
||||||
|
ChatGPT,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromStr for UriHost {
|
impl FromStr for UriHost {
|
||||||
@@ -135,6 +137,7 @@ impl FromStr for UriHost {
|
|||||||
"linear" => Ok(Self::Linear),
|
"linear" => Ok(Self::Linear),
|
||||||
"tab_config" if FeatureFlag::TabConfigs.is_enabled() => Ok(Self::TabConfig),
|
"tab_config" if FeatureFlag::TabConfigs.is_enabled() => Ok(Self::TabConfig),
|
||||||
"session" => Ok(Self::Session),
|
"session" => Ok(Self::Session),
|
||||||
|
"chatgpt" => Ok(Self::ChatGPT),
|
||||||
_ => Err(anyhow!("Received url with unexpected host: {}", s)),
|
_ => Err(anyhow!("Received url with unexpected host: {}", s)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -570,6 +573,13 @@ impl UriHost {
|
|||||||
log::warn!("session deep link could not find pane with given UUID");
|
log::warn!("session deep link could not find pane with given UUID");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
UriHost::ChatGPT => {
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
{
|
||||||
|
crate::ai::chatgpt_auth::ChatGPTAuthModel::handle(ctx)
|
||||||
|
.update(ctx, |model, ctx| model.handle_oauth_callback(url, ctx));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,6 +605,7 @@ impl UriHost {
|
|||||||
// Handler picks the window itself based on `?new_window=true`.
|
// Handler picks the window itself based on `?new_window=true`.
|
||||||
Self::TabConfig => W::Nothing,
|
Self::TabConfig => W::Nothing,
|
||||||
Self::Session => W::Nothing,
|
Self::Session => W::Nothing,
|
||||||
|
Self::ChatGPT => W::Nothing,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1669,7 +1680,8 @@ fn validate_custom_uri(url: &Url) -> Result<UriHost> {
|
|||||||
| UriHost::Codex
|
| UriHost::Codex
|
||||||
| UriHost::Linear
|
| UriHost::Linear
|
||||||
| UriHost::TabConfig
|
| UriHost::TabConfig
|
||||||
| UriHost::Session => true,
|
| UriHost::Session
|
||||||
|
| UriHost::ChatGPT => true,
|
||||||
// Auth and Home only allow the desktop redirect path
|
// Auth and Home only allow the desktop redirect path
|
||||||
UriHost::Auth | UriHost::Home => false,
|
UriHost::Auth | UriHost::Home => false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ impl View for WasmNUXDialog {
|
|||||||
.with_child(
|
.with_child(
|
||||||
appearance
|
appearance
|
||||||
.ui_builder()
|
.ui_builder()
|
||||||
.span("Galaxy is a clone of Warp built for Samsung to use with Bedrock. Get the best features of Warp with the security provided by Bedrock!")
|
.span("Galaxy is an AI-powered terminal. Get the best features with the security provided by your own cloud infrastructure!")
|
||||||
.with_style(UiComponentStyles {
|
.with_style(UiComponentStyles {
|
||||||
font_weight: Some(Weight::Thin),
|
font_weight: Some(Weight::Thin),
|
||||||
font_color: Some(
|
font_color: Some(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -144,7 +144,7 @@ impl AgentRuntime for ChatGPTSubscriptionRuntime {
|
|||||||
request: TurnRequest,
|
request: TurnRequest,
|
||||||
control: TurnControl,
|
control: TurnControl,
|
||||||
) -> Result<AgentEventStream, AgentError> {
|
) -> Result<AgentEventStream, AgentError> {
|
||||||
let mut builder = chatgpt::Client::builder().oauth();
|
let mut builder = chatgpt::Client::builder().oauth().allow_device_flow(false);
|
||||||
if let Some(auth_file) = &self.config.auth_file {
|
if let Some(auth_file) = &self.config.auth_file {
|
||||||
builder = builder.auth_file(auth_file);
|
builder = builder.auth_file(auth_file);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user