mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
feat: openrouter out of the box experience for goose installations (#3507)
This commit is contained in:
@@ -64,106 +64,141 @@ pub async fn handle_configure() -> Result<(), Box<dyn Error>> {
|
||||
);
|
||||
println!();
|
||||
cliclack::intro(style(" goose-configure ").on_cyan().black())?;
|
||||
match configure_provider_dialog().await {
|
||||
Ok(true) => {
|
||||
println!(
|
||||
"\n {}: Run '{}' again to adjust your config or add extensions",
|
||||
style("Tip").green().italic(),
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
// Since we are setting up for the first time, we'll also enable the developer system
|
||||
// This operation is best-effort and errors are ignored
|
||||
ExtensionConfigManager::set(ExtensionEntry {
|
||||
enabled: true,
|
||||
config: ExtensionConfig::Builtin {
|
||||
name: "developer".to_string(),
|
||||
display_name: Some(goose::config::DEFAULT_DISPLAY_NAME.to_string()),
|
||||
timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT),
|
||||
bundled: Some(true),
|
||||
description: None,
|
||||
},
|
||||
})?;
|
||||
}
|
||||
Ok(false) => {
|
||||
let _ = config.clear();
|
||||
println!(
|
||||
"\n {}: We did not save your config, inspect your credentials\n and run '{}' again to ensure goose can connect",
|
||||
style("Warning").yellow().italic(),
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = config.clear();
|
||||
|
||||
match e.downcast_ref::<ConfigError>() {
|
||||
Some(ConfigError::NotFound(key)) => {
|
||||
println!(
|
||||
"\n {} Required configuration key '{}' not found \n Please provide this value and run '{}' again",
|
||||
style("Error").red().italic(),
|
||||
key,
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
}
|
||||
Some(ConfigError::KeyringError(msg)) => {
|
||||
#[cfg(target_os = "macos")]
|
||||
println!(
|
||||
"\n {} Failed to access secure storage (keyring): {} \n Please check your system keychain and run '{}' again. \n If your system is unable to use the keyring, please try setting secret key(s) via environment variables.",
|
||||
style("Error").red().italic(),
|
||||
msg,
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
// Check if user wants to use OpenRouter login or manual configuration
|
||||
let setup_method = cliclack::select("How would you like to set up your provider?")
|
||||
.item(
|
||||
"openrouter",
|
||||
"OpenRouter Login (Recommended)",
|
||||
"Sign in with OpenRouter to automatically configure models",
|
||||
)
|
||||
.item(
|
||||
"manual",
|
||||
"Manual Configuration",
|
||||
"Choose a provider and enter credentials manually",
|
||||
)
|
||||
.interact()?;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
println!(
|
||||
"\n {} Failed to access Windows Credential Manager: {} \n Please check Windows Credential Manager and run '{}' again. \n If your system is unable to use the Credential Manager, please try setting secret key(s) via environment variables.",
|
||||
style("Error").red().italic(),
|
||||
msg,
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
println!(
|
||||
"\n {} Failed to access secure storage: {} \n Please check your system's secure storage and run '{}' again. \n If your system is unable to use secure storage, please try setting secret key(s) via environment variables.",
|
||||
style("Error").red().italic(),
|
||||
msg,
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
match setup_method {
|
||||
"openrouter" => {
|
||||
match handle_openrouter_auth().await {
|
||||
Ok(_) => {
|
||||
// OpenRouter auth already handles everything including enabling developer extension
|
||||
}
|
||||
Some(ConfigError::DeserializeError(msg)) => {
|
||||
Err(e) => {
|
||||
let _ = config.clear();
|
||||
println!(
|
||||
"\n {} Invalid configuration value: {} \n Please check your input and run '{}' again",
|
||||
style("Error").red().italic(),
|
||||
msg,
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
}
|
||||
Some(ConfigError::FileError(e)) => {
|
||||
println!(
|
||||
"\n {} Failed to access config file: {} \n Please check file permissions and run '{}' again",
|
||||
"\n {} OpenRouter authentication failed: {} \n Please try again or use manual configuration",
|
||||
style("Error").red().italic(),
|
||||
e,
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
}
|
||||
Some(ConfigError::DirectoryError(msg)) => {
|
||||
println!(
|
||||
"\n {} Failed to access config directory: {} \n Please check directory permissions and run '{}' again",
|
||||
style("Error").red().italic(),
|
||||
msg,
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
}
|
||||
// handle all other nonspecific errors
|
||||
_ => {
|
||||
println!(
|
||||
"\n {} {} \n We did not save your config, inspect your credentials\n and run '{}' again to ensure goose can connect",
|
||||
style("Error").red().italic(),
|
||||
e,
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
"manual" => {
|
||||
match configure_provider_dialog().await {
|
||||
Ok(true) => {
|
||||
println!(
|
||||
"\n {}: Run '{}' again to adjust your config or add extensions",
|
||||
style("Tip").green().italic(),
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
// Since we are setting up for the first time, we'll also enable the developer system
|
||||
// This operation is best-effort and errors are ignored
|
||||
ExtensionConfigManager::set(ExtensionEntry {
|
||||
enabled: true,
|
||||
config: ExtensionConfig::Builtin {
|
||||
name: "developer".to_string(),
|
||||
display_name: Some(goose::config::DEFAULT_DISPLAY_NAME.to_string()),
|
||||
timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT),
|
||||
bundled: Some(true),
|
||||
description: None,
|
||||
},
|
||||
})?;
|
||||
}
|
||||
Ok(false) => {
|
||||
let _ = config.clear();
|
||||
println!(
|
||||
"\n {}: We did not save your config, inspect your credentials\n and run '{}' again to ensure goose can connect",
|
||||
style("Warning").yellow().italic(),
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = config.clear();
|
||||
|
||||
match e.downcast_ref::<ConfigError>() {
|
||||
Some(ConfigError::NotFound(key)) => {
|
||||
println!(
|
||||
"\n {} Required configuration key '{}' not found \n Please provide this value and run '{}' again",
|
||||
style("Error").red().italic(),
|
||||
key,
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
}
|
||||
Some(ConfigError::KeyringError(msg)) => {
|
||||
#[cfg(target_os = "macos")]
|
||||
println!(
|
||||
"\n {} Failed to access secure storage (keyring): {} \n Please check your system keychain and run '{}' again. \n If your system is unable to use the keyring, please try setting secret key(s) via environment variables.",
|
||||
style("Error").red().italic(),
|
||||
msg,
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
println!(
|
||||
"\n {} Failed to access Windows Credential Manager: {} \n Please check Windows Credential Manager and run '{}' again. \n If your system is unable to use the Credential Manager, please try setting secret key(s) via environment variables.",
|
||||
style("Error").red().italic(),
|
||||
msg,
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
println!(
|
||||
"\n {} Failed to access secure storage: {} \n Please check your system's secure storage and run '{}' again. \n If your system is unable to use secure storage, please try setting secret key(s) via environment variables.",
|
||||
style("Error").red().italic(),
|
||||
msg,
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
}
|
||||
Some(ConfigError::DeserializeError(msg)) => {
|
||||
println!(
|
||||
"\n {} Invalid configuration value: {} \n Please check your input and run '{}' again",
|
||||
style("Error").red().italic(),
|
||||
msg,
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
}
|
||||
Some(ConfigError::FileError(e)) => {
|
||||
println!(
|
||||
"\n {} Failed to access config file: {} \n Please check file permissions and run '{}' again",
|
||||
style("Error").red().italic(),
|
||||
e,
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
}
|
||||
Some(ConfigError::DirectoryError(msg)) => {
|
||||
println!(
|
||||
"\n {} Failed to access config directory: {} \n Please check directory permissions and run '{}' again",
|
||||
style("Error").red().italic(),
|
||||
msg,
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
}
|
||||
// handle all other nonspecific errors
|
||||
_ => {
|
||||
println!(
|
||||
"\n {} {} \n We did not save your config, inspect your credentials\n and run '{}' again to ensure goose can connect",
|
||||
style("Error").red().italic(),
|
||||
e,
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -1465,3 +1500,96 @@ pub fn configure_max_turns_dialog() -> Result<(), Box<dyn Error>> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle OpenRouter authentication
|
||||
pub async fn handle_openrouter_auth() -> Result<(), Box<dyn Error>> {
|
||||
use goose::config::{configure_openrouter, signup_openrouter::OpenRouterAuth};
|
||||
use goose::message::Message;
|
||||
use goose::providers::create;
|
||||
|
||||
// Use the OpenRouter authentication flow
|
||||
let mut auth_flow = OpenRouterAuth::new()?;
|
||||
match auth_flow.complete_flow().await {
|
||||
Ok(api_key) => {
|
||||
println!("\nAuthentication complete!");
|
||||
|
||||
// Get config instance
|
||||
let config = Config::global();
|
||||
|
||||
// Use the existing configure_openrouter function to set everything up
|
||||
println!("\nConfiguring OpenRouter...");
|
||||
if let Err(e) = configure_openrouter(config, api_key) {
|
||||
eprintln!("Failed to configure OpenRouter: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
|
||||
println!("✓ OpenRouter configuration complete");
|
||||
println!("✓ Models configured successfully");
|
||||
|
||||
// Test configuration - get the model that was configured
|
||||
println!("\nTesting configuration...");
|
||||
let configured_model: String = config.get_param("GOOSE_MODEL")?;
|
||||
let model_config = goose::model::ModelConfig::new(configured_model);
|
||||
match create("openrouter", model_config) {
|
||||
Ok(provider) => {
|
||||
// Simple test request
|
||||
let test_result = provider
|
||||
.complete(
|
||||
"You are Goose, an AI assistant.",
|
||||
&[Message::user().with_text("Say 'Configuration test successful!'")],
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
|
||||
match test_result {
|
||||
Ok(_) => {
|
||||
println!("✓ Configuration test passed!");
|
||||
|
||||
// Enable the developer extension by default if not already enabled
|
||||
let entries = ExtensionConfigManager::get_all()?;
|
||||
let has_developer = entries
|
||||
.iter()
|
||||
.any(|e| e.config.name() == "developer" && e.enabled);
|
||||
|
||||
if !has_developer {
|
||||
match ExtensionConfigManager::set(ExtensionEntry {
|
||||
enabled: true,
|
||||
config: ExtensionConfig::Builtin {
|
||||
name: "developer".to_string(),
|
||||
display_name: Some(
|
||||
goose::config::DEFAULT_DISPLAY_NAME.to_string(),
|
||||
),
|
||||
timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT),
|
||||
bundled: Some(true),
|
||||
description: None,
|
||||
},
|
||||
}) {
|
||||
Ok(_) => println!("✓ Developer extension enabled"),
|
||||
Err(e) => {
|
||||
eprintln!("⚠️ Failed to enable developer extension: {}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cliclack::outro("OpenRouter setup complete! You can now use Goose.")?;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("⚠️ Configuration test failed: {}", e);
|
||||
eprintln!("Your settings have been saved, but there may be an issue with the connection.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("⚠️ Failed to create provider for testing: {}", e);
|
||||
eprintln!("Your settings have been saved. Please check your configuration.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Authentication failed: {}", e);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod recipe;
|
||||
pub mod reply;
|
||||
pub mod schedule;
|
||||
pub mod session;
|
||||
pub mod setup;
|
||||
pub mod utils;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -29,4 +30,5 @@ pub fn configure(state: Arc<crate::state::AppState>) -> Router {
|
||||
.merge(session::routes(state.clone()))
|
||||
.merge(schedule::routes(state.clone()))
|
||||
.merge(project::routes(state.clone()))
|
||||
.merge(setup::routes(state.clone()))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
use crate::state::AppState;
|
||||
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
|
||||
use goose::config::signup_openrouter::OpenRouterAuth;
|
||||
use goose::config::{configure_openrouter, Config};
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SetupResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub fn routes(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/handle_openrouter", post(start_openrouter_setup))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn start_openrouter_setup(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
) -> Result<Json<SetupResponse>, StatusCode> {
|
||||
tracing::info!("Starting OpenRouter setup flow");
|
||||
|
||||
let mut auth_flow = OpenRouterAuth::new().map_err(|e| {
|
||||
tracing::error!("Failed to initialize auth flow: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
tracing::info!("Auth flow initialized, starting complete_flow");
|
||||
|
||||
match auth_flow.complete_flow().await {
|
||||
Ok(api_key) => {
|
||||
tracing::info!("Got API key, configuring OpenRouter...");
|
||||
|
||||
let config = Config::global();
|
||||
|
||||
if let Err(e) = configure_openrouter(config, api_key) {
|
||||
tracing::error!("Failed to configure OpenRouter: {}", e);
|
||||
return Ok(Json(SetupResponse {
|
||||
success: false,
|
||||
message: format!("Failed to configure OpenRouter: {}", e),
|
||||
}));
|
||||
}
|
||||
|
||||
tracing::info!("OpenRouter setup completed successfully");
|
||||
Ok(Json(SetupResponse {
|
||||
success: true,
|
||||
message: "OpenRouter setup completed successfully".to_string(),
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("OpenRouter setup failed: {}", e);
|
||||
Ok(Json(SetupResponse {
|
||||
success: false,
|
||||
message: format!("Setup failed: {}", e),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,6 @@ nanoid = "0.4"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.21"
|
||||
url = "2.5"
|
||||
urlencoding = "2.1"
|
||||
axum = "0.8.1"
|
||||
webbrowser = "0.8"
|
||||
lazy_static = "1.5.0"
|
||||
@@ -66,6 +65,7 @@ etcetera = "0.8.0"
|
||||
rand = "0.8.5"
|
||||
utoipa = { version = "4.1", features = ["chrono"] }
|
||||
tokio-cron-scheduler = "0.14.0"
|
||||
urlencoding = "2.1"
|
||||
|
||||
# For Bedrock provider
|
||||
aws-config = { version = "1.5.16", features = ["behavior-version-latest"] }
|
||||
|
||||
@@ -2,12 +2,14 @@ pub mod base;
|
||||
mod experiments;
|
||||
pub mod extensions;
|
||||
pub mod permission;
|
||||
pub mod signup_openrouter;
|
||||
|
||||
pub use crate::agents::ExtensionConfig;
|
||||
pub use base::{Config, ConfigError, APP_STRATEGY};
|
||||
pub use experiments::ExperimentManager;
|
||||
pub use extensions::{ExtensionConfigManager, ExtensionEntry};
|
||||
pub use permission::PermissionManager;
|
||||
pub use signup_openrouter::configure_openrouter;
|
||||
|
||||
pub use extensions::DEFAULT_DISPLAY_NAME;
|
||||
pub use extensions::DEFAULT_EXTENSION;
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
pub mod server;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// Default models for openrouter config configuration
|
||||
const OPENROUTER_DEFAULT_MODEL: &str = "qwen/qwen3-coder";
|
||||
|
||||
const OPENROUTER_AUTH_URL: &str = "https://openrouter.ai/auth";
|
||||
const OPENROUTER_TOKEN_URL: &str = "https://openrouter.ai/api/v1/auth/keys";
|
||||
const CALLBACK_URL: &str = "http://localhost:3000";
|
||||
const AUTH_TIMEOUT: Duration = Duration::from_secs(180); // 3 minutes
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PkceAuthFlow {
|
||||
code_verifier: String,
|
||||
code_challenge: String,
|
||||
server_shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenResponse {
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TokenRequest {
|
||||
code: String,
|
||||
code_verifier: String,
|
||||
code_challenge_method: String,
|
||||
}
|
||||
|
||||
impl PkceAuthFlow {
|
||||
pub fn new() -> Result<Self> {
|
||||
let code_verifier: String = rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(128)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&code_verifier);
|
||||
let hash = hasher.finalize();
|
||||
|
||||
let code_challenge = URL_SAFE_NO_PAD.encode(hash);
|
||||
|
||||
Ok(Self {
|
||||
code_verifier,
|
||||
code_challenge,
|
||||
server_shutdown_tx: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_auth_url(&self) -> String {
|
||||
format!(
|
||||
"{}?callback_url={}&code_challenge={}&code_challenge_method=S256",
|
||||
OPENROUTER_AUTH_URL,
|
||||
urlencoding::encode(CALLBACK_URL),
|
||||
urlencoding::encode(&self.code_challenge)
|
||||
)
|
||||
}
|
||||
|
||||
/// Start local server and wait for callback
|
||||
pub async fn start_server(&mut self) -> Result<String> {
|
||||
let (code_tx, code_rx) = oneshot::channel::<String>();
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
||||
|
||||
// Store shutdown sender so we can stop the server later
|
||||
self.server_shutdown_tx = Some(shutdown_tx);
|
||||
|
||||
// Start the server in a background task
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = server::run_callback_server(code_tx, shutdown_rx).await {
|
||||
eprintln!("Server error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for the authorization code with timeout
|
||||
match timeout(AUTH_TIMEOUT, code_rx).await {
|
||||
Ok(Ok(code)) => Ok(code),
|
||||
Ok(Err(_)) => Err(anyhow!("Failed to receive authorization code")),
|
||||
Err(_) => Err(anyhow!("Authentication timeout - please try again")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn exchange_code(&self, code: String) -> Result<String> {
|
||||
let client = Client::new();
|
||||
|
||||
let request_body = TokenRequest {
|
||||
code: code.clone(),
|
||||
code_verifier: self.code_verifier.clone(),
|
||||
code_challenge_method: "S256".to_string(),
|
||||
};
|
||||
|
||||
eprintln!("Exchanging code for API key...");
|
||||
eprintln!("Code: {}", code);
|
||||
eprintln!("Code verifier length: {}", self.code_verifier.len());
|
||||
eprintln!("Code challenge: {}", self.code_challenge);
|
||||
|
||||
let response = client
|
||||
.post(OPENROUTER_TOKEN_URL)
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
eprintln!("Token exchange failed!");
|
||||
eprintln!("Status: {}", status);
|
||||
eprintln!("Error response: {}", error_text);
|
||||
return Err(anyhow!(
|
||||
"Failed to exchange code: {} - {}",
|
||||
status,
|
||||
error_text
|
||||
));
|
||||
}
|
||||
|
||||
let token_response: TokenResponse = response.json().await?;
|
||||
Ok(token_response.key)
|
||||
}
|
||||
|
||||
/// Complete flow: open browser, wait for callback, exchange code
|
||||
pub async fn complete_flow(&mut self) -> Result<String> {
|
||||
let auth_url = self.get_auth_url();
|
||||
|
||||
println!("Opening browser for authentication...");
|
||||
eprintln!("Auth URL: {}", auth_url);
|
||||
|
||||
if let Err(e) = webbrowser::open(&auth_url) {
|
||||
eprintln!("Failed to open browser automatically: {}", e);
|
||||
println!("Please open this URL manually: {}", auth_url);
|
||||
}
|
||||
|
||||
println!("Waiting for authentication callback...");
|
||||
let code = self.start_server().await?;
|
||||
|
||||
println!("Authorization code received. Exchanging for API key...");
|
||||
eprintln!("Received code: {}", code);
|
||||
|
||||
let api_key = self.exchange_code(code).await?;
|
||||
|
||||
// Shutdown the server if it's still running
|
||||
if let Some(tx) = self.server_shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
|
||||
Ok(api_key)
|
||||
}
|
||||
}
|
||||
|
||||
pub use self::PkceAuthFlow as OpenRouterAuth;
|
||||
|
||||
use crate::config::Config;
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn configure_openrouter(config: &Config, api_key: String) -> Result<()> {
|
||||
config.set_secret("OPENROUTER_API_KEY", Value::String(api_key))?;
|
||||
config.set_param("GOOSE_PROVIDER", Value::String("openrouter".to_string()))?;
|
||||
config.set_param(
|
||||
"GOOSE_MODEL",
|
||||
Value::String(OPENROUTER_DEFAULT_MODEL.to_string()),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
extract::Query,
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use include_dir::{include_dir, Dir};
|
||||
use minijinja::{context, Environment};
|
||||
use serde::Deserialize;
|
||||
use std::net::SocketAddr;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
static TEMPLATES_DIR: Dir =
|
||||
include_dir!("$CARGO_MANIFEST_DIR/src/config/signup_openrouter/templates");
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CallbackQuery {
|
||||
code: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Run the callback server on localhost:3000
|
||||
pub async fn run_callback_server(
|
||||
code_tx: oneshot::Sender<String>,
|
||||
shutdown_rx: oneshot::Receiver<()>,
|
||||
) -> Result<()> {
|
||||
let app = Router::new().route("/", get(handle_callback));
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
let state = std::sync::Arc::new(tokio::sync::Mutex::new(Some(code_tx)));
|
||||
|
||||
axum::serve(listener, app.with_state(state.clone()).into_make_service())
|
||||
.with_graceful_shutdown(async move {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_callback(
|
||||
Query(params): Query<CallbackQuery>,
|
||||
state: axum::extract::State<
|
||||
std::sync::Arc<tokio::sync::Mutex<Option<oneshot::Sender<String>>>>,
|
||||
>,
|
||||
) -> impl IntoResponse {
|
||||
if let Some(error) = params.error {
|
||||
let mut env = Environment::new();
|
||||
let template_content = TEMPLATES_DIR
|
||||
.get_file("error.html")
|
||||
.expect("error.html template not found")
|
||||
.contents_utf8()
|
||||
.expect("error.html is not valid UTF-8");
|
||||
|
||||
env.add_template("error", template_content).unwrap();
|
||||
let tmpl = env.get_template("error").unwrap();
|
||||
let rendered = tmpl.render(context! { error => error }).unwrap();
|
||||
|
||||
return (StatusCode::BAD_REQUEST, Html(rendered));
|
||||
}
|
||||
|
||||
if let Some(code) = params.code {
|
||||
let mut tx_guard = state.lock().await;
|
||||
if let Some(tx) = tx_guard.take() {
|
||||
let _ = tx.send(code);
|
||||
}
|
||||
|
||||
let success_html = TEMPLATES_DIR
|
||||
.get_file("success.html")
|
||||
.expect("success.html template not found")
|
||||
.contents_utf8()
|
||||
.expect("success.html is not valid UTF-8");
|
||||
|
||||
return (StatusCode::OK, Html(success_html.to_string()));
|
||||
}
|
||||
|
||||
let invalid_html = TEMPLATES_DIR
|
||||
.get_file("invalid.html")
|
||||
.expect("invalid.html template not found")
|
||||
.contents_utf8()
|
||||
.expect("invalid.html is not valid UTF-8");
|
||||
|
||||
(StatusCode::BAD_REQUEST, Html(invalid_html.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authentication Failed</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
max-width: 500px;
|
||||
}
|
||||
h1 {
|
||||
color: #d32f2f;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
p {
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.error {
|
||||
background-color: #ffebee;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-top: 20px;
|
||||
color: #c62828;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>❌ Authentication Failed</h1>
|
||||
<p>There was an error during the authentication process.</p>
|
||||
<div class="error">{{ error }}</div>
|
||||
<p>Please close this tab and try again.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Invalid Request</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
max-width: 500px;
|
||||
}
|
||||
h1 {
|
||||
color: #ff9800;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
p {
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>⚠️ Invalid Request</h1>
|
||||
<p>This doesn't appear to be a valid authentication callback.</p>
|
||||
<p>Please close this tab and try the authentication process again.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authentication Successful</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
max-width: 500px;
|
||||
}
|
||||
h1 {
|
||||
color: #4caf50;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
p {
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.checkmark {
|
||||
font-size: 48px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="checkmark">✅</div>
|
||||
<h1>Authentication Successful!</h1>
|
||||
<p>You have successfully authenticated with OpenRouter.</p>
|
||||
<p>You can now close this tab and return to Goose.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,68 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::config::signup_openrouter::PkceAuthFlow;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[test]
|
||||
fn test_pkce_flow_creation() {
|
||||
let flow = PkceAuthFlow::new().expect("Failed to create PKCE flow");
|
||||
|
||||
// Verify code_verifier is 128 characters
|
||||
assert_eq!(flow.code_verifier.len(), 128);
|
||||
|
||||
// Verify code_challenge is base64url encoded (no padding)
|
||||
assert!(!flow.code_challenge.contains('='));
|
||||
assert!(!flow.code_challenge.contains('+'));
|
||||
assert!(!flow.code_challenge.contains('/'));
|
||||
|
||||
// Verify auth URL is properly formatted
|
||||
let auth_url = flow.get_auth_url();
|
||||
assert!(auth_url.starts_with("https://openrouter.ai/auth"));
|
||||
assert!(auth_url.contains("callback_url=http%3A%2F%2Flocalhost%3A3000"));
|
||||
assert!(auth_url.contains(&format!("code_challenge={}", flow.code_challenge)));
|
||||
assert!(auth_url.contains("code_challenge_method=S256"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_flows_have_different_verifiers() {
|
||||
let flow1 = PkceAuthFlow::new().expect("Failed to create PKCE flow 1");
|
||||
let flow2 = PkceAuthFlow::new().expect("Failed to create PKCE flow 2");
|
||||
|
||||
// Verify that different flows have different verifiers and challenges
|
||||
assert_ne!(flow1.code_verifier, flow2.code_verifier);
|
||||
assert_ne!(flow1.code_challenge, flow2.code_challenge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_code_verifier_is_alphanumeric() {
|
||||
let flow = PkceAuthFlow::new().expect("Failed to create PKCE flow");
|
||||
|
||||
// Verify all characters in code_verifier are alphanumeric
|
||||
assert!(flow.code_verifier.chars().all(|c| c.is_alphanumeric()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_code_challenge_matches_verifier() {
|
||||
let flow = PkceAuthFlow::new().expect("Failed to create PKCE flow");
|
||||
|
||||
// Manually compute the expected challenge
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&flow.code_verifier);
|
||||
let hash = hasher.finalize();
|
||||
let expected_challenge = URL_SAFE_NO_PAD.encode(hash);
|
||||
|
||||
// Verify the challenge matches
|
||||
assert_eq!(flow.code_challenge, expected_challenge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pkce_verifier_length_bounds() {
|
||||
// PKCE spec requires verifier to be 43-128 characters
|
||||
// Our implementation uses 128 characters
|
||||
let flow = PkceAuthFlow::new().expect("Failed to create PKCE flow");
|
||||
|
||||
assert!(flow.code_verifier.len() >= 43);
|
||||
assert!(flow.code_verifier.len() <= 128);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ pub const OPENROUTER_KNOWN_MODELS: &[&str] = &[
|
||||
"anthropic/claude-sonnet-4",
|
||||
"google/gemini-2.5-pro",
|
||||
"deepseek/deepseek-r1-0528",
|
||||
"qwen/qwen3-coder",
|
||||
"moonshotai/kimi-k2",
|
||||
];
|
||||
pub const OPENROUTER_DOC_URL: &str = "https://openrouter.ai/models";
|
||||
|
||||
|
||||
@@ -1,33 +1,116 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useConfig } from './ConfigContext';
|
||||
import { SetupModal } from './SetupModal';
|
||||
import { startOpenRouterSetup } from '../utils/openRouterSetup';
|
||||
import WelcomeGooseLogo from './WelcomeGooseLogo';
|
||||
import { initializeSystem } from '../utils/providerUtils';
|
||||
import { toastService } from '../toasts';
|
||||
|
||||
interface ProviderGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ProviderGuard({ children }: ProviderGuardProps) {
|
||||
const { read } = useConfig();
|
||||
const { read, getExtensions, addExtension } = useConfig();
|
||||
const navigate = useNavigate();
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const [hasProvider, setHasProvider] = useState(false);
|
||||
const [showFirstTimeSetup, setShowFirstTimeSetup] = useState(false);
|
||||
const [openRouterSetupState, setOpenRouterSetupState] = useState<{
|
||||
show: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
showProgress: boolean;
|
||||
showRetry: boolean;
|
||||
autoClose?: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkProvider = async () => {
|
||||
const handleOpenRouterSetup = async () => {
|
||||
setOpenRouterSetupState({
|
||||
show: true,
|
||||
title: 'Setting up OpenRouter',
|
||||
message: 'A browser window will open for authentication...',
|
||||
showProgress: true,
|
||||
showRetry: false,
|
||||
});
|
||||
|
||||
const result = await startOpenRouterSetup();
|
||||
if (result.success) {
|
||||
setOpenRouterSetupState({
|
||||
show: true,
|
||||
title: 'Setup Complete!',
|
||||
message: 'OpenRouter has been configured successfully. Initializing Goose...',
|
||||
showProgress: true,
|
||||
showRetry: false,
|
||||
});
|
||||
|
||||
// After successful OpenRouter setup, force reload config and initialize system
|
||||
try {
|
||||
// Get the latest config from disk
|
||||
const config = window.electron.getConfig();
|
||||
const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER;
|
||||
const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL;
|
||||
|
||||
if (provider && model) {
|
||||
// Initialize the system with the new provider/model
|
||||
await initializeSystem(provider as string, model as string, {
|
||||
getExtensions,
|
||||
addExtension,
|
||||
});
|
||||
|
||||
toastService.configure({ silent: false });
|
||||
toastService.success({
|
||||
title: 'Success!',
|
||||
msg: `Started goose with ${model} by OpenRouter. You can change the model via the lower right corner.`,
|
||||
});
|
||||
|
||||
// Close the modal and mark as having provider
|
||||
setOpenRouterSetupState(null);
|
||||
setShowFirstTimeSetup(false);
|
||||
setHasProvider(true);
|
||||
} else {
|
||||
console.log('No provider/model configured, redirecting to welcome');
|
||||
navigate('/welcome', { replace: true });
|
||||
throw new Error('Provider or model not found after OpenRouter setup');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize after OpenRouter setup:', error);
|
||||
toastService.configure({ silent: false });
|
||||
toastService.error({
|
||||
title: 'Initialization Failed',
|
||||
msg: `Failed to initialize with OpenRouter: ${error instanceof Error ? error.message : String(error)}`,
|
||||
traceback: error instanceof Error ? error.stack || '' : '',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setOpenRouterSetupState({
|
||||
show: true,
|
||||
title: 'Openrouter setup pending',
|
||||
message: result.message,
|
||||
showProgress: false,
|
||||
showRetry: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkProvider = async () => {
|
||||
try {
|
||||
const config = window.electron.getConfig();
|
||||
console.log('ProviderGuard - Full config:', config);
|
||||
|
||||
const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER;
|
||||
const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL;
|
||||
|
||||
if (provider && model) {
|
||||
console.log('ProviderGuard - Provider and model found, continuing normally');
|
||||
setHasProvider(true);
|
||||
} else {
|
||||
console.log('ProviderGuard - No provider/model configured, showing first time setup');
|
||||
setShowFirstTimeSetup(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking provider configuration:', error);
|
||||
// On error, assume no provider and redirect to welcome
|
||||
console.error('Error checking provider configuration:', error);
|
||||
navigate('/welcome', { replace: true });
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
@@ -35,9 +118,10 @@ export default function ProviderGuard({ children }: ProviderGuardProps) {
|
||||
};
|
||||
|
||||
checkProvider();
|
||||
}, [read, navigate]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [read]);
|
||||
|
||||
if (isChecking) {
|
||||
if (isChecking && !openRouterSetupState?.show && !showFirstTimeSetup) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-textStandard"></div>
|
||||
@@ -45,8 +129,57 @@ export default function ProviderGuard({ children }: ProviderGuardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (openRouterSetupState?.show) {
|
||||
return (
|
||||
<SetupModal
|
||||
title={openRouterSetupState.title}
|
||||
message={openRouterSetupState.message}
|
||||
showProgress={openRouterSetupState.showProgress}
|
||||
showRetry={openRouterSetupState.showRetry}
|
||||
onRetry={handleOpenRouterSetup}
|
||||
autoClose={openRouterSetupState.autoClose}
|
||||
onClose={() => setOpenRouterSetupState(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (showFirstTimeSetup) {
|
||||
return (
|
||||
<div className="h-screen w-full flex flex-col items-center justify-center bg-background-default">
|
||||
<div className="max-w-md w-full mx-auto p-8 text-center">
|
||||
<WelcomeGooseLogo />
|
||||
<h1 className="text-2xl font-bold text-text-standard mt-8 mb-4">Welcome to Goose!</h1>
|
||||
<p className="text-text-muted mb-8">
|
||||
Let's get you set up with an AI provider to start using Goose.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={handleOpenRouterSetup}
|
||||
className="w-full px-6 py-3 bg-background-muted text-text-standard rounded-lg hover:bg-background-hover transition-colors font-medium"
|
||||
>
|
||||
Automatic setup with OpenRouter (recommended)
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/welcome', { replace: true })}
|
||||
className="w-full px-6 py-3 bg-background-muted text-text-standard rounded-lg hover:bg-background-hover transition-colors font-medium"
|
||||
>
|
||||
Configure Other Providers (advanced)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-text-muted mt-6">
|
||||
OpenRouter provides access to multiple AI models. To use this it will need to create an
|
||||
account with OpenRouter.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasProvider) {
|
||||
// This will be handled by the navigation above, but we return null to be safe
|
||||
// This shouldn't happen, but just in case
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface SetupModalProps {
|
||||
title: string;
|
||||
message: string;
|
||||
showProgress?: boolean;
|
||||
showRetry?: boolean;
|
||||
onRetry?: () => void;
|
||||
autoClose?: number;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function SetupModal({
|
||||
title,
|
||||
message,
|
||||
showProgress,
|
||||
showRetry,
|
||||
onRetry,
|
||||
autoClose,
|
||||
onClose,
|
||||
}: SetupModalProps) {
|
||||
useEffect(() => {
|
||||
if (autoClose && onClose) {
|
||||
const timer = window.setTimeout(() => {
|
||||
onClose();
|
||||
}, autoClose);
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [autoClose, onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-gray-100">{title}</h2>
|
||||
<p className="mb-6 text-gray-700 dark:text-gray-300">{message}</p>
|
||||
|
||||
{showProgress && (
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onClose && (
|
||||
<div className="mb-4">
|
||||
<Button onClick={onClose} className="w-full">
|
||||
Close
|
||||
</Button>
|
||||
<br />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showRetry && onRetry && (
|
||||
<Button onClick={onRetry} className="w-full">
|
||||
Retry Setup
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface OpenRouterSetupStatus {
|
||||
isRunning: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export async function startOpenRouterSetup(): Promise<{ success: boolean; message: string }> {
|
||||
const baseUrl = `${window.appConfig.get('GOOSE_API_HOST')}:${window.appConfig.get('GOOSE_PORT')}`;
|
||||
const response = await fetch(`${baseUrl}/handle_openrouter`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to start Openrouter setup:', response.statusText);
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to start Openrouter setup ['${response.status}]`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user