-
Notifications
You must be signed in to change notification settings - Fork 2.7k
feat: openrouter out of the box experience for goose installations #3507
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 172d67d
checkpoint
michaelneale bebb16b
now has gui
michaelneale 0bd7230
Merge branch 'main' into micn/install-with-openrouter
michaelneale 0cd926c
tidy up
michaelneale 5a8f563
some test coverage
michaelneale 9e58a88
setup in cli correctly
michaelneale 921ce72
move logic to goose crate where it should be
michaelneale 94608f3
slightly less ominous warning, but may only be at dev time
michaelneale be9da5c
Merge branch 'main' into micn/install-with-openrouter
michaelneale 538204f
message
michaelneale 8040864
Merge branch 'main' into micn/install-with-openrouter
michaelneale d5899e6
run it with option to show providers
michaelneale 81fa3a0
fix up goose configure to offer openrouter login when fresh
michaelneale a54703a
Merge branch 'main' into micn/install-with-openrouter
michaelneale 9056916
Merge branch 'main' into micn/install-with-openrouter
michaelneale 56dc6f6
cli shouldn't change
michaelneale 39cba52
change path for openrouter flow, remove comments, tidy up tests based…
michaelneale a49e252
tidy up
michaelneale b7264b7
addressing feedback
michaelneale df0767b
Merge branch 'main' into micn/install-with-openrouter
michaelneale d261cb8
fmt
michaelneale b6d7eb9
error goes to welcome like before
michaelneale e86828b
adding to base list for openrouter
michaelneale 8b43170
closing/restart
michaelneale 41656d3
Merge branch 'main' into micn/install-with-openrouter
michaelneale e2600f1
Merge branch 'main' into micn/install-with-openrouter
michaelneale 838997d
don't need this for singular UI client
michaelneale f72603b
Merge branch 'main' into micn/install-with-openrouter
michaelneale b4be209
will now refresh correctly
michaelneale 67bca4b
cleanup
michaelneale File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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), | ||
| })) | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(()) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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())) | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.