feat: new onboarding flow (#7266)

This commit is contained in:
Lifei Zhou
2026-03-17 12:33:41 +11:00
committed by GitHub
parent a518c42cbc
commit 4604354bc2
40 changed files with 1800 additions and 441 deletions
+2
View File
@@ -469,6 +469,7 @@ derive_utoipa!(Icon as IconSchema);
super::routes::recipe::recipe_to_yaml,
super::routes::setup::start_openrouter_setup,
super::routes::setup::start_tetrate_setup,
super::routes::setup::start_nanogpt_setup,
super::routes::tunnel::start_tunnel,
super::routes::tunnel::stop_tunnel,
super::routes::tunnel::get_tunnel_status,
@@ -510,6 +511,7 @@ derive_utoipa!(Icon as IconSchema);
goose::providers::catalog::ProviderTemplate,
goose::providers::catalog::ModelTemplate,
goose::providers::catalog::ModelCapabilities,
super::routes::config_management::CreateCustomProviderResponse,
super::routes::config_management::CheckProviderRequest,
super::routes::config_management::SetProviderRequest,
super::routes::config_management::ModelInfoQuery,
@@ -633,19 +633,24 @@ pub async fn validate_config() -> Result<Json<String>, ErrorResponse> {
Ok(Json("Config file is valid".to_string()))
}
#[derive(Serialize, ToSchema)]
pub struct CreateCustomProviderResponse {
pub provider_name: String,
}
#[utoipa::path(
post,
path = "/config/custom-providers",
request_body = UpdateCustomProviderRequest,
responses(
(status = 200, description = "Custom provider created successfully", body = String),
(status = 200, description = "Custom provider created successfully", body = CreateCustomProviderResponse),
(status = 400, description = "Invalid request"),
(status = 500, description = "Internal server error")
)
)]
pub async fn create_custom_provider(
Json(request): Json<UpdateCustomProviderRequest>,
) -> Result<Json<String>, ErrorResponse> {
) -> Result<Json<CreateCustomProviderResponse>, ErrorResponse> {
let config = goose::config::declarative_providers::create_custom_provider(
goose::config::declarative_providers::CreateCustomProviderParams {
engine: request.engine,
@@ -663,7 +668,9 @@ pub async fn create_custom_provider(
goose::providers::refresh_custom_providers().await?;
Ok(Json(format!("Custom provider added - ID: {}", config.id())))
Ok(Json(CreateCustomProviderResponse {
provider_name: config.id().to_string(),
}))
}
#[utoipa::path(
+35 -2
View File
@@ -1,6 +1,7 @@
use crate::routes::errors::ErrorResponse;
use crate::state::AppState;
use axum::{routing::post, Json, Router};
use goose::config::signup_nanogpt::{complete_nanogpt_auth, configure_nanogpt};
use goose::config::signup_openrouter::OpenRouterAuth;
use goose::config::signup_tetrate::{configure_tetrate, TetrateAuth};
use goose::config::{configure_openrouter, Config};
@@ -18,6 +19,7 @@ pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/handle_openrouter", post(start_openrouter_setup))
.route("/handle_tetrate", post(start_tetrate_setup))
.route("/handle_nanogpt", post(start_nanogpt_setup))
.with_state(state)
}
@@ -50,7 +52,7 @@ async fn start_openrouter_setup() -> Result<Json<SetupResponse>, ErrorResponse>
}
Err(e) => Ok(Json(SetupResponse {
success: false,
message: format!("Setup failed: {}", e),
message: e.to_string(),
})),
}
}
@@ -84,7 +86,38 @@ async fn start_tetrate_setup() -> Result<Json<SetupResponse>, ErrorResponse> {
}
Err(e) => Ok(Json(SetupResponse {
success: false,
message: format!("Setup failed: {}", e),
message: e.to_string(),
})),
}
}
#[utoipa::path(
post,
path = "/handle_nanogpt",
responses(
(status = 200, body=SetupResponse)
),
)]
async fn start_nanogpt_setup() -> Result<Json<SetupResponse>, ErrorResponse> {
match complete_nanogpt_auth().await {
Ok(api_key) => {
let config = Config::global();
if let Err(e) = configure_nanogpt(config, api_key) {
return Ok(Json(SetupResponse {
success: false,
message: format!("Failed to configure NanoGPT: {}", e),
}));
}
Ok(Json(SetupResponse {
success: true,
message: "NanoGPT setup completed successfully".to_string(),
}))
}
Err(e) => Ok(Json(SetupResponse {
success: false,
message: e.to_string(),
})),
}
}
+2
View File
@@ -7,6 +7,7 @@ mod migrations;
pub mod paths;
pub mod permission;
pub mod search_path;
pub mod signup_nanogpt;
pub mod signup_openrouter;
pub mod signup_tetrate;
@@ -21,6 +22,7 @@ pub use extensions::{
};
pub use goose_mode::GooseMode;
pub use permission::PermissionManager;
pub use signup_nanogpt::configure_nanogpt;
pub use signup_openrouter::configure_openrouter;
pub use signup_tetrate::configure_tetrate;
@@ -0,0 +1,128 @@
use anyhow::{anyhow, Result};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tokio::time::{sleep, timeout};
use crate::config::Config;
/// Default model for NanoGPT configuration
pub const NANOGPT_DEFAULT_MODEL: &str = "openai/gpt-4.1-nano";
const NANOGPT_START_URL: &str = "https://nano-gpt.com/api/cli-login/start";
const NANOGPT_POLL_URL: &str = "https://nano-gpt.com/api/cli-login/poll";
const AUTH_TIMEOUT: Duration = Duration::from_secs(180); // 3 minutes
const POLL_INTERVAL: Duration = Duration::from_secs(2);
#[derive(Debug, Serialize)]
struct StartRequest {
client_name: String,
}
#[derive(Debug, Deserialize)]
struct StartResponse {
device_code: String,
verification_uri_complete: String,
}
#[derive(Debug, Serialize)]
struct PollRequest {
device_code: String,
}
#[derive(Debug, Deserialize)]
struct PollResponse {
key: String,
}
async fn poll_for_token(device_code: &str) -> Result<String> {
let client = Client::new();
loop {
sleep(POLL_INTERVAL).await;
let body = PollRequest {
device_code: device_code.to_string(),
};
let response = client.post(NANOGPT_POLL_URL).json(&body).send().await?;
// https://docs.nano-gpt.com/integrations/cli-login#response-codes
match response.status().as_u16() {
200 => {
let poll_resp: PollResponse = response.json().await?;
return Ok(poll_resp.key);
}
202 => {
continue;
}
410 => {
return Err(anyhow!("Device code has expired - please try again"));
}
409 => {
return Err(anyhow!("Device code has already been consumed"));
}
404 => {
return Err(anyhow!("Invalid device code"));
}
429 => {
return Err(anyhow!(
"Too many requests to NanoGPT. Please wait a moment and try again."
));
}
other => {
let error_text = response.text().await.unwrap_or_default();
return Err(anyhow!(
"Unexpected poll response: {} - {}",
other,
error_text
));
}
}
}
}
pub async fn complete_nanogpt_auth() -> Result<String> {
let client = Client::new();
let body = StartRequest {
client_name: "goose".to_string(),
};
let response = client.post(NANOGPT_START_URL).json(&body).send().await?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(anyhow!(
"Failed to start NanoGPT device flow: {} - {}",
status,
error_text
));
}
let start_resp: StartResponse = response.json().await?;
println!("Opening browser for NanoGPT authentication...");
if let Err(e) = webbrowser::open(&start_resp.verification_uri_complete) {
eprintln!("Failed to open browser automatically: {}", e);
println!(
"Please open this URL manually: {}",
start_resp.verification_uri_complete
);
}
println!("Waiting for NanoGPT authorization...");
match timeout(AUTH_TIMEOUT, poll_for_token(&start_resp.device_code)).await {
Ok(Ok(api_key)) => Ok(api_key),
Ok(Err(e)) => Err(e),
Err(_) => Err(anyhow!("Authentication timed out - please try again")),
}
}
pub fn configure_nanogpt(config: &Config, api_key: String) -> Result<()> {
config.set_secret("NANOGPT_API_KEY", &api_key)?;
config.set_goose_provider("nano-gpt")?;
config.set_goose_model(NANOGPT_DEFAULT_MODEL)?;
Ok(())
}
+5 -6
View File
@@ -581,15 +581,14 @@ pub async fn emit_event(
event_name: &str,
mut properties: HashMap<String, serde_json::Value>,
) -> Result<(), String> {
if !is_telemetry_enabled() {
// Only onboarding events are enabled for now. These bypass the telemetry
// check so we can track the funnel before the user makes their choice.
let is_onboarding_event =
event_name.starts_with("onboarding_") || event_name == "telemetry_preference_set";
if !is_onboarding_event {
return Ok(());
}
// Temporarily disabled - only session_started events are sent
let _ = (event_name, &mut properties);
return Ok(());
#[allow(unreachable_code)]
let installation = load_or_create_installation();
insert(&mut properties, "os", std::env::consts::OS);
+5
View File
@@ -168,6 +168,11 @@ impl ProviderDef for AnthropicProvider {
),
],
)
.with_setup_steps(vec![
"Go to https://platform.claude.com/settings/keys",
"Click 'Create Key'",
"Copy the key and paste it above",
])
}
fn from_env(
+11
View File
@@ -176,6 +176,9 @@ pub struct ProviderMetadata {
pub model_doc_link: String,
/// Required configuration keys
pub config_keys: Vec<ConfigKey>,
/// step-by-step instructions for set up providers eg: api key
#[serde(default)]
pub setup_steps: Vec<String>,
}
impl ProviderMetadata {
@@ -208,6 +211,7 @@ impl ProviderMetadata {
.collect(),
model_doc_link: model_doc_link.to_string(),
config_keys,
setup_steps: vec![],
}
}
@@ -228,6 +232,7 @@ impl ProviderMetadata {
known_models: models,
model_doc_link: model_doc_link.to_string(),
config_keys,
setup_steps: vec![],
}
}
@@ -240,8 +245,14 @@ impl ProviderMetadata {
known_models: vec![],
model_doc_link: "".to_string(),
config_keys: vec![],
setup_steps: vec![],
}
}
pub fn with_setup_steps(mut self, steps: Vec<&str>) -> Self {
self.setup_steps = steps.into_iter().map(|s| s.to_string()).collect();
self
}
}
/// Configuration key metadata for provider setup
+6
View File
@@ -121,6 +121,12 @@ impl ProviderDef for GoogleProvider {
ConfigKey::new("GOOGLE_HOST", false, false, Some(GOOGLE_API_HOST), false),
],
)
.with_setup_steps(vec![
"Go to https://aistudio.google.com and sign in with your Google account",
"Click 'Get API key' on the left sidebar",
"Create a new API key or select an existing one",
"Copy the key and paste it above",
])
}
fn from_env(
+2
View File
@@ -20,6 +20,7 @@ use super::{
lead_worker::LeadWorkerProvider,
litellm::LiteLLMProvider,
local_inference::LocalInferenceProvider,
nanogpt::NanoGptProvider,
ollama::OllamaProvider,
openai::OpenAiProvider,
openrouter::OpenRouterProvider,
@@ -65,6 +66,7 @@ async fn init_registry() -> RwLock<ProviderRegistry> {
registry.register::<GithubCopilotProvider>(false);
registry.register::<GoogleProvider>(true);
registry.register::<LiteLLMProvider>(false);
registry.register::<NanoGptProvider>(true);
registry.register::<OllamaProvider>(true);
registry.register::<OpenAiProvider>(true);
registry.register::<OpenRouterProvider>(true);
+1
View File
@@ -28,6 +28,7 @@ mod init;
pub mod lead_worker;
pub mod litellm;
pub mod local_inference;
pub mod nanogpt;
pub mod oauth;
pub mod ollama;
pub mod openai;
+213
View File
@@ -0,0 +1,213 @@
use super::api_client::{ApiClient, AuthMethod};
use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata};
use super::errors::ProviderError;
use super::openai_compatible::{handle_status_openai_compat, stream_openai_compat};
use super::retry::ProviderRetry;
use super::utils::{ImageFormat, RequestLog};
use crate::conversation::message::Message;
use crate::model::ModelConfig;
use crate::providers::formats::openai::create_request;
use anyhow::Result;
use async_trait::async_trait;
use futures::future::BoxFuture;
use rmcp::model::Tool;
const NANOGPT_PROVIDER_NAME: &str = "nano-gpt";
pub const NANOGPT_API_HOST: &str = "https://nano-gpt.com/api/v1";
pub const NANOGPT_SUBSCRIPTION_HOST: &str = "https://nano-gpt.com/api/subscription/v1";
pub const NANOGPT_DEFAULT_MODEL: &str = "anthropic/claude-sonnet-4.6";
pub const NANOGPT_DOC_URL: &str = "https://docs.nano-gpt.com/";
const NANOGPT_API_KEY: &str = "NANOGPT_API_KEY";
#[derive(serde::Serialize)]
pub struct NanoGptProvider {
#[serde(skip)]
api_client: ApiClient,
model: ModelConfig,
#[serde(skip)]
name: String,
}
impl NanoGptProvider {
async fn check_subscription(api_key: &str) -> bool {
let client = match ApiClient::new(
NANOGPT_SUBSCRIPTION_HOST.to_string(),
AuthMethod::BearerToken(api_key.to_string()),
) {
Ok(c) => c,
Err(_) => return false,
};
match client.response_get(None, "usage").await {
Ok(resp) => resp
.json::<serde_json::Value>()
.await
.ok()
.and_then(|json| json.get("active")?.as_bool())
.unwrap_or(false),
Err(_) => false,
}
}
pub async fn from_env(model: ModelConfig) -> Result<Self> {
let config = crate::config::Config::global();
let api_key: String = config.get_secret(NANOGPT_API_KEY)?;
let is_subscription = Self::check_subscription(&api_key).await;
let host = if is_subscription {
tracing::debug!("NanoGPT subscription active, using subscription endpoint");
NANOGPT_SUBSCRIPTION_HOST.to_string()
} else {
tracing::debug!("NanoGPT using pay-as-you-go endpoint");
NANOGPT_API_HOST.to_string()
};
let api_client = ApiClient::new(host, AuthMethod::BearerToken(api_key))?;
Ok(Self {
api_client,
model,
name: NANOGPT_PROVIDER_NAME.to_string(),
})
}
}
impl ProviderDef for NanoGptProvider {
type Provider = Self;
fn metadata() -> ProviderMetadata {
ProviderMetadata::new(
NANOGPT_PROVIDER_NAME,
"NanoGPT",
"Access multiple AI models through NanoGPT's unified API",
NANOGPT_DEFAULT_MODEL,
vec![NANOGPT_DEFAULT_MODEL],
NANOGPT_DOC_URL,
vec![ConfigKey::new(NANOGPT_API_KEY, true, true, None, true)],
)
}
fn from_env(
model: ModelConfig,
_extensions: Vec<crate::config::ExtensionConfig>,
) -> BoxFuture<'static, Result<Self::Provider>> {
Box::pin(Self::from_env(model))
}
}
#[async_trait]
impl Provider for NanoGptProvider {
fn get_name(&self) -> &str {
&self.name
}
fn get_model_config(&self) -> ModelConfig {
self.model.clone()
}
async fn fetch_supported_models(&self) -> Result<Vec<String>, ProviderError> {
let response = self
.api_client
.request(None, "models?detailed=true")
.response_get()
.await
.map_err(|e| {
ProviderError::RequestFailed(format!(
"Failed to fetch models from NanoGPT API: {}",
e
))
})?;
let json: serde_json::Value = response.json().await.map_err(|e| {
ProviderError::RequestFailed(format!(
"Failed to parse NanoGPT models API response as JSON: {}",
e
))
})?;
if let Some(err_obj) = json.get("error") {
let msg = err_obj
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
return Err(ProviderError::RequestFailed(format!(
"NanoGPT API returned an error: {}",
msg
)));
}
let data = json.get("data").and_then(|v| v.as_array()).ok_or_else(|| {
ProviderError::RequestFailed("Missing 'data' field in JSON response".into())
})?;
let mut models: Vec<String> = data
.iter()
.filter_map(|model| {
let id = model.get("id").and_then(|v| v.as_str())?;
let supports_tool_calling = model
.get("capabilities")
.and_then(|c| c.get("tool_calling"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
if supports_tool_calling {
Some(id.to_string())
} else {
None
}
})
.collect();
models.sort();
Ok(models)
}
async fn stream(
&self,
model_config: &ModelConfig,
session_id: &str,
system: &str,
messages: &[Message],
tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
let payload = create_request(
model_config,
system,
messages,
tools,
&ImageFormat::OpenAi,
true,
)?;
let mut log = RequestLog::start(model_config, &payload)?;
let response = self
.with_retry(|| async {
let resp = self
.api_client
.response_post(Some(session_id), "chat/completions", &payload)
.await?;
handle_status_openai_compat(resp).await
})
.await
.inspect_err(|e| {
let _ = log.error(e);
})?;
stream_openai_compat(response, log)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_metadata() {
let metadata = NanoGptProvider::metadata();
assert_eq!(metadata.name, "nano-gpt");
assert_eq!(metadata.default_model, "anthropic/claude-sonnet-4.6");
assert_eq!(metadata.config_keys[0].name, NANOGPT_API_KEY);
assert!(metadata.config_keys[0].required);
assert!(metadata.config_keys[0].secret);
}
}
+6
View File
@@ -338,6 +338,12 @@ impl ProviderDef for OpenAiProvider {
ConfigKey::new("OPENAI_TIMEOUT", false, false, Some("600"), false),
],
)
.with_setup_steps(vec![
"Go to https://platform.openai.com and sign up or log in",
"Navigate to API Keys in the left sidebar",
"Click 'Create new secret key'",
"Copy the key and paste it above",
])
}
fn from_env(
+5
View File
@@ -168,6 +168,11 @@ impl ProviderDef for OpenRouterProvider {
),
],
)
.with_setup_steps(vec![
"Go to https://openrouter.ai/settings/keys",
"Click 'Create' or use an existing API key",
"Copy the key and paste it above",
])
}
fn from_env(
@@ -139,6 +139,7 @@ impl ProviderRegistry {
known_models,
model_doc_link: base_metadata.model_doc_link,
config_keys,
setup_steps: vec![],
};
self.entries.insert(
+1
View File
@@ -373,6 +373,7 @@ mod tests {
known_models: vec![],
model_doc_link: "".to_string(),
config_keys: vec![],
setup_steps: vec![],
}
}
+1
View File
@@ -191,6 +191,7 @@ impl ProviderDef for MockCompactionProvider {
known_models: vec![],
model_doc_link: "".to_string(),
config_keys: vec![],
setup_steps: vec![],
}
}