From f21c71f72ad97495ab84f663aa340f6a75d20a53 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 11 Nov 2025 16:04:15 +1100 Subject: [PATCH] provider detection --- crates/goose-server/src/openapi.rs | 2 + .../src/routes/config_management.rs | 33 +++++ ui/desktop/openapi.json | 40 ++++++ ui/desktop/src/api/sdk.gen.ts | 9 +- ui/desktop/src/api/types.gen.ts | 21 +++ ui/desktop/src/components/ProviderGuard.tsx | 132 ++++++++++++++++-- 6 files changed, 228 insertions(+), 9 deletions(-) diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index b3f9aedb14..6dcf164660 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -346,6 +346,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::config_management::remove_custom_provider, super::routes::config_management::check_provider, super::routes::config_management::set_config_provider, + super::routes::config_management::detect_api_keys, super::routes::agent::start_agent, super::routes::agent::resume_agent, super::routes::agent::get_tools, @@ -398,6 +399,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::config_management::UpdateCustomProviderRequest, super::routes::config_management::CheckProviderRequest, super::routes::config_management::SetProviderRequest, + super::routes::config_management::DetectedApiKey, super::routes::reply::PermissionConfirmationRequest, super::routes::reply::ChatRequest, super::routes::session::ImportSessionRequest, diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index 98f1ef0b4f..e60cd16138 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -781,6 +781,38 @@ pub async fn set_config_provider( Ok(()) } +#[derive(Serialize, ToSchema)] +pub struct DetectedApiKey { + pub provider: String, + pub env_var: String, +} + +#[utoipa::path( + get, + path = "/config/detect_api_keys", + responses( + (status = 200, description = "API key detection result", body = Option), + ) +)] +pub async fn detect_api_keys() -> Result>, StatusCode> { + let keys_to_check = [ + ("anthropic", "ANTHROPIC_API_KEY"), + ("openai", "OPENAI_API_KEY"), + ("openrouter", "OPENROUTER_API_KEY"), + ]; + + for (provider, env_var) in keys_to_check.iter() { + if std::env::var(env_var).is_ok() { + return Ok(Json(Some(DetectedApiKey { + provider: provider.to_string(), + env_var: env_var.to_string(), + }))); + } + } + + Ok(Json(None)) +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/config", get(read_all_config)) @@ -807,6 +839,7 @@ pub fn routes(state: Arc) -> Router { .route("/config/custom-providers/{id}", get(get_custom_provider)) .route("/config/check_provider", post(check_provider)) .route("/config/set_provider", post(set_config_provider)) + .route("/config/detect_api_keys", get(detect_api_keys)) .with_state(state) } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index fd45570d58..a6a13fe8bd 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -551,6 +551,31 @@ } } }, + "/config/detect_api_keys": { + "get": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "detect_api_keys", + "responses": { + "200": { + "description": "API key detection result", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/DetectedApiKey" + } + ], + "nullable": true + } + } + } + } + } + } + }, "/config/extensions": { "get": { "tags": [ @@ -2448,6 +2473,21 @@ } } }, + "DetectedApiKey": { + "type": "object", + "required": [ + "provider", + "env_var" + ], + "properties": { + "env_var": { + "type": "string" + }, + "provider": { + "type": "string" + } + } + }, "EmbeddedResource": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 0634e71abc..26e22dba64 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; +import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectApiKeysData, DetectApiKeysResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -163,6 +163,13 @@ export const updateCustomProvider = (optio }); }; +export const detectApiKeys = (options?: Options) => { + return (options?.client ?? client).get({ + url: '/config/detect_api_keys', + ...options + }); +}; + export const getExtensions = (options?: Options) => { return (options?.client ?? client).get({ url: '/config/extensions', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 75de116e56..c327233366 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -121,6 +121,11 @@ export type DeleteRecipeRequest = { id: string; }; +export type DetectedApiKey = { + env_var: string; + provider: string; +}; + export type EmbeddedResource = { _meta?: { [key: string]: unknown; @@ -1341,6 +1346,22 @@ export type UpdateCustomProviderResponses = { export type UpdateCustomProviderResponse = UpdateCustomProviderResponses[keyof UpdateCustomProviderResponses]; +export type DetectApiKeysData = { + body?: never; + path?: never; + query?: never; + url: '/config/detect_api_keys'; +}; + +export type DetectApiKeysResponses = { + /** + * API key detection result + */ + 200: DetectedApiKey | null; +}; + +export type DetectApiKeysResponse = DetectApiKeysResponses[keyof DetectApiKeysResponses]; + export type GetExtensionsData = { body?: never; path?: never; diff --git a/ui/desktop/src/components/ProviderGuard.tsx b/ui/desktop/src/components/ProviderGuard.tsx index 1eae8e2cd0..4d610cad10 100644 --- a/ui/desktop/src/components/ProviderGuard.tsx +++ b/ui/desktop/src/components/ProviderGuard.tsx @@ -10,6 +10,7 @@ import { OllamaSetup } from './OllamaSetup'; import { Goose } from './icons/Goose'; import { OpenRouter } from './icons'; +import { detectApiKeys, providers, setConfigProvider } from '../api/sdk.gen'; interface ProviderGuardProps { didSelectProvider: boolean; @@ -23,6 +24,10 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG const [hasProvider, setHasProvider] = useState(false); const [showFirstTimeSetup, setShowFirstTimeSetup] = useState(false); const [showOllamaSetup, setShowOllamaSetup] = useState(false); + const [detectedApiKey, setDetectedApiKey] = useState<{ + provider: string; + env_var: string; + } | null>(null); const [openRouterSetupState, setOpenRouterSetupState] = useState<{ show: boolean; @@ -101,6 +106,50 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG } }; + const handleDetectedKeySetup = async () => { + if (!detectedApiKey) return; + + try { + console.log(`Setting up with detected ${detectedApiKey.provider} API key`); + + // Get provider metadata to find default model + const providersResult = await providers(); + const providerDetails = providersResult.data?.find((p) => p.name === detectedApiKey.provider); + + if (!providerDetails) { + throw new Error(`Provider ${detectedApiKey.provider} not found`); + } + + const defaultModel = providerDetails.metadata.default_model; + + // Set the provider and model + await setConfigProvider({ + body: { + provider: detectedApiKey.provider, + model: defaultModel, + }, + }); + + toastService.configure({ silent: false }); + toastService.success({ + title: 'Success!', + msg: `Started goose with ${defaultModel} using your ${detectedApiKey.env_var}. You can change the model via the dropdown.`, + }); + + setShowFirstTimeSetup(false); + setHasProvider(true); + navigate('/', { replace: true }); + } catch (error) { + console.error('Failed to setup with detected API key:', error); + toastService.configure({ silent: false }); + toastService.error({ + title: 'Setup Failed', + msg: `Failed to setup with detected API key: ${error instanceof Error ? error.message : String(error)}`, + traceback: error instanceof Error ? error.stack || '' : '', + }); + } + }; + const handleOpenRouterSetup = async () => { setOpenRouterSetupState({ show: true, @@ -173,17 +222,26 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER; const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL; - // Always check for Ollama regardless of provider status - if (provider && model) { console.log('ProviderGuard - Provider and model found, continuing normally'); setHasProvider(true); } else { console.log('ProviderGuard - No provider/model configured'); + + // Detect API keys in environment + try { + const result = await detectApiKeys(); + if (result.data) { + console.log('ProviderGuard - Detected API key:', result.data); + setDetectedApiKey(result.data); + } + } catch { + console.log('ProviderGuard - No API keys detected in environment'); + } + setShowFirstTimeSetup(true); } } catch (error) { - // On error, assume no provider and redirect to welcome console.error('Error checking provider configuration:', error); navigate('/welcome', { replace: true }); } finally { @@ -192,11 +250,7 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG }; checkProvider(); - }, [ - navigate, - read, - didSelectProvider, // When the user makes a selection, re-trigger this check - ]); + }, [navigate, read, didSelectProvider]); if ( isChecking && @@ -287,6 +341,68 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG {/* Setup options - same width container */}
+ {/* Quick Setup Card - shown when API key is detected */} + {detectedApiKey && ( +
+
+ + Ready to Go! + +
+ +
+
+
+
+ + + +

+ Quick Setup with{' '} + {detectedApiKey.provider.charAt(0).toUpperCase() + + detectedApiKey.provider.slice(1)} +

+
+
+
+ + + +
+
+

+ We detected your {detectedApiKey.env_var} environment variable! +

+

+ Click here to start using Goose immediately with your existing API key. +

+
+
+ )} +
{/* Tetrate Card */} {/* Recommended badge - positioned relative to wrapper */}