feat: openrouter out of the box experience for goose installations (#3507)

This commit is contained in:
Michael Neale
2025-07-30 17:17:43 +10:00
committed by GitHub
parent faf252a5a7
commit 601c678722
15 changed files with 977 additions and 100 deletions
+218 -90
View File
@@ -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(())
}
+2
View File
@@ -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()))
}
+60
View File
@@ -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),
}))
}
}
}
+1 -1
View File
@@ -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
View File
@@ -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);
}
}
+2
View File
@@ -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";