Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
46cc640
first pass at flow with CLI
michaelneale Jul 18, 2025
172d67d
checkpoint
michaelneale Jul 18, 2025
bebb16b
now has gui
michaelneale Jul 21, 2025
0bd7230
Merge branch 'main' into micn/install-with-openrouter
michaelneale Jul 21, 2025
0cd926c
tidy up
michaelneale Jul 21, 2025
5a8f563
some test coverage
michaelneale Jul 21, 2025
9e58a88
setup in cli correctly
michaelneale Jul 21, 2025
921ce72
move logic to goose crate where it should be
michaelneale Jul 21, 2025
94608f3
slightly less ominous warning, but may only be at dev time
michaelneale Jul 21, 2025
be9da5c
Merge branch 'main' into micn/install-with-openrouter
michaelneale Jul 22, 2025
538204f
message
michaelneale Jul 22, 2025
8040864
Merge branch 'main' into micn/install-with-openrouter
michaelneale Jul 23, 2025
d5899e6
run it with option to show providers
michaelneale Jul 23, 2025
81fa3a0
fix up goose configure to offer openrouter login when fresh
michaelneale Jul 23, 2025
a54703a
Merge branch 'main' into micn/install-with-openrouter
michaelneale Jul 24, 2025
9056916
Merge branch 'main' into micn/install-with-openrouter
michaelneale Jul 28, 2025
56dc6f6
cli shouldn't change
michaelneale Jul 28, 2025
39cba52
change path for openrouter flow, remove comments, tidy up tests based…
michaelneale Jul 28, 2025
a49e252
tidy up
michaelneale Jul 28, 2025
b7264b7
addressing feedback
michaelneale Jul 29, 2025
df0767b
Merge branch 'main' into micn/install-with-openrouter
michaelneale Jul 29, 2025
d261cb8
fmt
michaelneale Jul 29, 2025
b6d7eb9
error goes to welcome like before
michaelneale Jul 29, 2025
e86828b
adding to base list for openrouter
michaelneale Jul 29, 2025
8b43170
closing/restart
michaelneale Jul 29, 2025
41656d3
Merge branch 'main' into micn/install-with-openrouter
michaelneale Jul 29, 2025
e2600f1
Merge branch 'main' into micn/install-with-openrouter
michaelneale Jul 30, 2025
838997d
don't need this for singular UI client
michaelneale Jul 30, 2025
f72603b
Merge branch 'main' into micn/install-with-openrouter
michaelneale Jul 30, 2025
b4be209
will now refresh correctly
michaelneale Jul 30, 2025
67bca4b
cleanup
michaelneale Jul 30, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
292 changes: 210 additions & 82 deletions crates/goose-cli/src/commands/configure.rs

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions crates/goose-server/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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()))
}
60 changes: 60 additions & 0 deletions crates/goose-server/src/routes/setup.rs
Original file line number Diff line number Diff line change
@@ -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),
}))
}
}
}
2 changes: 1 addition & 1 deletion crates/goose/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"] }
Expand Down
2 changes: 2 additions & 0 deletions crates/goose/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
175 changes: 175 additions & 0 deletions crates/goose/src/config/signup_openrouter/mod.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
86 changes: 86 additions & 0 deletions crates/goose/src/config/signup_openrouter/server.rs
Original file line number Diff line number Diff line change
@@ -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()))
}
Loading
Loading