diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 37fc4a77bc86..f106ff211cfc 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -327,6 +327,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::status::status, super::routes::status::diagnostics, super::routes::config_management::backup_config, + super::routes::config_management::detect_provider, super::routes::config_management::recover_config, super::routes::config_management::validate_config, super::routes::config_management::init_config, @@ -386,6 +387,9 @@ derive_utoipa!(Icon as IconSchema); components(schemas( super::routes::config_management::UpsertConfigQuery, super::routes::config_management::ConfigKeyQuery, + super::routes::config_management::DetectProviderRequest, + super::routes::config_management::DetectProviderResponse, + super::routes::config_management::DetectProviderError, super::routes::config_management::ConfigResponse, super::routes::config_management::ProvidersResponse, super::routes::config_management::ProviderDetails, diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index c1b1de5d2777..624c51af0e7c 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -11,6 +11,7 @@ use goose::config::paths::Paths; use goose::config::ExtensionEntry; use goose::config::{Config, ConfigError}; use goose::model::ModelConfig; +use goose::providers::auto_detect::{detect_provider_from_api_key, detect_cloud_provider_from_api_key}; use goose::providers::base::{ProviderMetadata, ProviderType}; use goose::providers::pricing::{ get_all_pricing, get_model_pricing, parse_model_id, refresh_pricing, @@ -88,6 +89,23 @@ pub struct UpdateCustomProviderRequest { pub supports_streaming: Option, } +#[derive(Deserialize, ToSchema)] +pub struct DetectProviderRequest { + pub api_key: String, +} + +#[derive(Serialize, ToSchema)] +pub struct DetectProviderResponse { + pub provider_name: String, + pub models: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct DetectProviderError { + pub error: String, + pub detected_format: Option, + pub suggestions: Vec, +} #[utoipa::path( post, path = "/config/upsert", @@ -516,6 +534,48 @@ pub async fn upsert_permissions( Ok(Json("Permissions updated successfully".to_string())) } +#[utoipa::path( + post, + path = "/config/detect-provider", + request_body = DetectProviderRequest, + responses( + (status = 200, description = "Provider detected successfully", body = DetectProviderResponse), + (status = 400, description = "Invalid API key format or key validation failed", body = DetectProviderError), + (status = 500, description = "Internal server error") + ) +)] +pub async fn detect_provider( + Json(detect_request): Json, +) -> Result, StatusCode> { + let api_key = detect_request.api_key.trim(); + + match detect_provider_from_api_key(api_key).await { + Some((provider_name, models)) => Ok(Json(DetectProviderResponse { + provider_name, + models, + })), + None => Err(StatusCode::NOT_FOUND), + } +} + +fn detect_key_format(api_key: &str) -> Option { + let trimmed_key = api_key.trim(); + + if trimmed_key.starts_with("sk-ant-") { + Some("Anthropic".to_string()) + } else if trimmed_key.starts_with("sk-") { + Some("OpenAI".to_string()) + } else if trimmed_key.starts_with("AIza") { + Some("Google".to_string()) + } else if trimmed_key.starts_with("gsk_") { + Some("Groq".to_string()) + } else if trimmed_key.starts_with("xai-") { + Some("xAI".to_string()) + } else { + None + } +} + #[utoipa::path( post, path = "/config/backup", @@ -606,6 +666,28 @@ pub async fn validate_config() -> Result, StatusCode> { } } } +#[utoipa::path( + post, + path = "/config/detect-cloud-provider", + request_body = DetectProviderRequest, + responses( + (status = 200, description = "Cloud provider detected successfully", body = DetectProviderResponse), + (status = 404, description = "No matching cloud provider found"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn detect_cloud_provider( + Json(detect_request): Json, +) -> Result, StatusCode> { + match detect_cloud_provider_from_api_key(&detect_request.api_key).await { + Some((provider_name, models)) => Ok(Json(DetectProviderResponse { + provider_name, + models, + })), + None => Err(StatusCode::NOT_FOUND), + } +} + #[utoipa::path( post, @@ -718,6 +800,8 @@ pub fn routes(state: Arc) -> Router { .route("/config/extensions/{name}", delete(remove_extension)) .route("/config/providers", get(providers)) .route("/config/providers/{name}/models", get(get_provider_models)) + .route("/config/detect-provider", post(detect_provider)) + .route("/config/detect-cloud-provider", post(detect_cloud_provider)) .route("/config/pricing", post(get_pricing)) .route("/config/init", post(init_config)) .route("/config/backup", post(backup_config)) diff --git a/crates/goose/src/providers/auto_detect.rs b/crates/goose/src/providers/auto_detect.rs new file mode 100644 index 000000000000..76d036ef452e --- /dev/null +++ b/crates/goose/src/providers/auto_detect.rs @@ -0,0 +1,196 @@ +use crate::model::ModelConfig; + +/// Detect the provider based on API key format +fn detect_provider_from_key_format(api_key: &str) -> Option<&'static str> { + let trimmed_key = api_key.trim(); + + // Anthropic keys start with sk-ant- + if trimmed_key.starts_with("sk-ant-") { + return Some("anthropic"); + } + + // OpenAI keys start with sk- but not sk-ant- + if trimmed_key.starts_with("sk-") && !trimmed_key.starts_with("sk-ant-") { + return Some("openai"); + } + + // Google keys typically start with AIza + if trimmed_key.starts_with("AIza") { + return Some("google"); + } + + // Groq keys start with gsk_ + if trimmed_key.starts_with("gsk_") { + return Some("groq"); + } + + // xAI keys start with xai- + if trimmed_key.starts_with("xai-") { + return Some("xai"); + } + + // OpenRouter keys start with sk-or- + if trimmed_key.starts_with("sk-or-") { + return Some("openrouter"); + } + + // If we can't detect the format, return None + None +} + +/// Test a specific provider with the API key +async fn test_provider(provider_name: &str, api_key: &str) -> Option<(String, Vec)> { + let env_key = match provider_name { + "anthropic" => "ANTHROPIC_API_KEY", + "openai" => "OPENAI_API_KEY", + "google" => "GOOGLE_API_KEY", + "groq" => "GROQ_API_KEY", + "xai" => "XAI_API_KEY", + "openrouter" => "OPENROUTER_API_KEY", + "ollama" => "OLLAMA_API_KEY", + _ => return None, + }; + + let original_value = std::env::var(env_key).ok(); + std::env::set_var(env_key, api_key); + + let result = match crate::providers::create(provider_name, ModelConfig::new_or_fail("default")).await { + Ok(provider) => match provider.fetch_supported_models().await { + Ok(Some(models)) => Some((provider_name.to_string(), models)), + _ => None, + }, + Err(_) => None, + }; + + // Restore original value + match original_value { + Some(val) => std::env::set_var(env_key, val), + None => std::env::remove_var(env_key), + } + + result +} + +pub async fn detect_provider_from_api_key(api_key: &str) -> Option<(String, Vec)> { + // First, try to detect the provider from the key format + if let Some(detected_provider) = detect_provider_from_key_format(api_key) { + // Test the detected provider first + if let Some(result) = test_provider(detected_provider, api_key).await { + return Some(result); + } + } + + // If format detection failed or the detected provider didn't work, + // fall back to testing all providers in parallel + let provider_tests = vec![ + ("anthropic", "ANTHROPIC_API_KEY"), + ("openai", "OPENAI_API_KEY"), + ("google", "GOOGLE_API_KEY"), + ("groq", "GROQ_API_KEY"), + ("xai", "XAI_API_KEY"), + ("ollama", "OLLAMA_API_KEY"), + ]; + + let tasks: Vec<_> = provider_tests + .into_iter() + .map(|(provider_name, env_key)| { + let api_key = api_key.to_string(); + tokio::spawn(async move { + let original_value = std::env::var(env_key).ok(); + std::env::set_var(env_key, &api_key); + + let result = match crate::providers::create( + provider_name, + ModelConfig::new_or_fail("default"), + ) + .await + { + Ok(provider) => match provider.fetch_supported_models().await { + Ok(Some(models)) => Some((provider_name.to_string(), models)), + _ => None, + }, + Err(_) => None, + }; + + match original_value { + Some(val) => std::env::set_var(env_key, val), + None => std::env::remove_var(env_key), + } + + result + }) + }) + .collect(); + + for task in tasks { + if let Ok(Some(result)) = task.await { + return Some(result); + } + } + + None +} + +/// Detect provider from API key, testing only cloud providers (no Ollama) +/// This is useful for Quick Setup flows where Ollama fallback is not desired +pub async fn detect_cloud_provider_from_api_key(api_key: &str) -> Option<(String, Vec)> { + // First, try to detect the provider from the key format + if let Some(detected_provider) = detect_provider_from_key_format(api_key) { + // Skip Ollama in cloud-only mode + if detected_provider != "ollama" { + if let Some(result) = test_provider(detected_provider, api_key).await { + return Some(result); + } + } + } + + // If format detection failed or the detected provider didn't work, + // fall back to testing cloud providers in parallel (excluding Ollama) + let provider_tests = vec![ + ("anthropic", "ANTHROPIC_API_KEY"), + ("openai", "OPENAI_API_KEY"), + ("google", "GOOGLE_API_KEY"), + ("groq", "GROQ_API_KEY"), + ("xai", "XAI_API_KEY"), + // Ollama excluded for cloud-only detection + ]; + + let tasks: Vec<_> = provider_tests + .into_iter() + .map(|(provider_name, env_key)| { + let api_key = api_key.to_string(); + tokio::spawn(async move { + let original_value = std::env::var(env_key).ok(); + std::env::set_var(env_key, &api_key); + + let result = match crate::providers::create( + provider_name, + ModelConfig::new_or_fail("default"), + ) + .await + { + Ok(provider) => match provider.fetch_supported_models().await { + Ok(Some(models)) => Some((provider_name.to_string(), models)), + _ => None, + }, + Err(_) => None, + }; + + match original_value { + Some(val) => std::env::set_var(env_key, val), + None => std::env::remove_var(env_key), + } + + result + }) + }) + .collect(); + + for task in tasks { + if let Ok(Some(result)) = task.await { + return Some(result); + } + } + + None +} diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index d50502a42b92..6d1dbfab23d1 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -1,5 +1,6 @@ pub mod anthropic; mod api_client; +pub mod auto_detect; pub mod azure; pub mod azureauth; pub mod base; diff --git a/temporal-service/temporal-service b/temporal-service/temporal-service new file mode 100755 index 000000000000..31076ea7e4ff Binary files /dev/null and b/temporal-service/temporal-service differ diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index ca872392f667..fec9dea017f6 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -532,6 +532,49 @@ } } }, + "/config/detect-provider": { + "post": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "detect_provider", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DetectProviderRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Provider detected successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DetectProviderResponse" + } + } + } + }, + "400": { + "description": "Invalid API key format or key validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DetectProviderError" + } + } + } + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/config/extensions": { "get": { "tags": [ @@ -2404,6 +2447,57 @@ } } }, + "DetectProviderError": { + "type": "object", + "required": [ + "error", + "suggestions" + ], + "properties": { + "detected_format": { + "type": "string", + "nullable": true + }, + "error": { + "type": "string" + }, + "suggestions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "DetectProviderRequest": { + "type": "object", + "required": [ + "api_key" + ], + "properties": { + "api_key": { + "type": "string" + } + } + }, + "DetectProviderResponse": { + "type": "object", + "required": [ + "provider_name", + "models" + ], + "properties": { + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "provider_name": { + "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 7f65f47dc5a7..b17d8619fc73 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, 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, 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, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, 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, 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 & { /** @@ -152,6 +152,17 @@ export const updateCustomProvider = (optio }); }; +export const detectProvider = (options: Options) => { + return (options.client ?? client).post({ + url: '/config/detect-provider', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + 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 70269fba0f10..85f039491ae7 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -118,6 +118,21 @@ export type DeleteRecipeRequest = { id: string; }; +export type DetectProviderError = { + detected_format?: string | null; + error: string; + suggestions: Array; +}; + +export type DetectProviderRequest = { + api_key: string; +}; + +export type DetectProviderResponse = { + models: Array; + provider_name: string; +}; + export type EmbeddedResource = { _meta?: { [key: string]: unknown; @@ -1324,6 +1339,35 @@ export type UpdateCustomProviderResponses = { export type UpdateCustomProviderResponse = UpdateCustomProviderResponses[keyof UpdateCustomProviderResponses]; +export type DetectProviderData = { + body: DetectProviderRequest; + path?: never; + query?: never; + url: '/config/detect-provider'; +}; + +export type DetectProviderErrors = { + /** + * Invalid API key format or key validation failed + */ + 400: DetectProviderError; + /** + * Internal server error + */ + 500: unknown; +}; + +export type DetectProviderError2 = DetectProviderErrors[keyof DetectProviderErrors]; + +export type DetectProviderResponses = { + /** + * Provider detected successfully + */ + 200: DetectProviderResponse; +}; + +export type DetectProviderResponse2 = DetectProviderResponses[keyof DetectProviderResponses]; + export type GetExtensionsData = { body?: never; path?: never; diff --git a/ui/desktop/src/components/ApiKeyTester.tsx b/ui/desktop/src/components/ApiKeyTester.tsx new file mode 100644 index 000000000000..8d3631872527 --- /dev/null +++ b/ui/desktop/src/components/ApiKeyTester.tsx @@ -0,0 +1,546 @@ +import { useState, useRef } from 'react'; +import { detectProvider } from '../api'; +import { useConfig } from './ConfigContext'; +import { toastService } from '../toasts'; +import { Key } from './icons/Key'; +import { ArrowRight } from './icons/ArrowRight'; +import { Button } from './ui/button'; + +interface ApiKeyTesterProps { + onSuccess: (provider: string, model: string) => void; + onStartTesting?: () => void; +} + +interface TestResult { + provider: string; + success: boolean; + model?: string; + totalModels?: number; + error?: string; + detectedFormat?: string; + suggestions?: string[]; +} + +interface ApiError { + response?: { + status?: number; + data?: { + error?: string; + detected_format?: string; + suggestions?: string[]; + }; + }; + message?: string; +} + +export default function ApiKeyTester({ onSuccess, onStartTesting }: ApiKeyTesterProps) { + const [apiKey, setApiKey] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [testResults, setTestResults] = useState([]); + const [showResults, setShowResults] = useState(false); + const { upsert } = useConfig(); + const inputRef = useRef(null); + + // Enhanced format detection with validation + const detectKeyFormat = (apiKey: string): { provider: string; valid: boolean; reason?: string } | null => { + const trimmed = apiKey.trim(); + + if (trimmed.startsWith('sk-ant-')) { + if (trimmed.length < 100) { + return { provider: 'Anthropic', valid: false, reason: 'Key appears too short for Anthropic format' }; + } + return { provider: 'Anthropic', valid: true }; + } + + if (trimmed.startsWith('sk-or-')) { + if (trimmed.length < 40) { + return { provider: 'OpenRouter', valid: false, reason: 'Key appears too short for OpenRouter format' }; + } + return { provider: 'OpenRouter', valid: true }; + } + + if (trimmed.startsWith('sk-') && !trimmed.startsWith('sk-ant-') && !trimmed.startsWith('sk-or-')) { + if (trimmed.length < 40) { + return { provider: 'OpenAI', valid: false, reason: 'Key appears too short for OpenAI format' }; + } + return { provider: 'OpenAI', valid: true }; + } + + if (trimmed.startsWith('AIza')) { + return { provider: 'Google', valid: trimmed.length > 30 }; + } + + if (trimmed.startsWith('gsk_')) { + return { provider: 'Groq', valid: trimmed.length > 40 }; + } + + if (trimmed.startsWith('xai-')) { + return { provider: 'xAI', valid: trimmed.length > 40 }; + } + + return null; + }; + + const testApiKey = async () => { + // Get the actual input value directly from the DOM element + const actualValue = inputRef.current?.value || apiKey; + + if (!actualValue.trim()) { + toastService.error({ + title: 'API Key Required', + msg: 'Please enter an API key to test.', + traceback: '', + }); + return; + } + + // Notify parent that user is actively testing + onStartTesting?.(); + + const formatCheck = detectKeyFormat(actualValue); + + setIsLoading(true); + setTestResults([]); + setShowResults(true); + + // If we can't detect the format, show error immediately + if (!formatCheck) { + setTestResults([{ + provider: 'Unknown', + success: false, + error: 'Unrecognized API key format', + suggestions: [ + 'Make sure you are using a valid API key from OpenAI, Anthropic, Google, Groq, xAI, or OpenRouter', + 'Check that the key is complete and not truncated', + 'For local Ollama setup, use the "Other Providers" section below' + ], + }]); + + toastService.error({ + title: 'Unrecognized Key Format', + msg: 'The API key format is not recognized. Please check that you are using a supported provider.', + traceback: '', + }); + + setIsLoading(false); + return; + } + + // If format is detected but appears invalid, show warning + if (!formatCheck.valid) { + setTestResults([{ + provider: formatCheck.provider, + success: false, + error: formatCheck.reason || 'Key format appears invalid', + suggestions: [ + `Check that your ${formatCheck.provider} API key is complete`, + `Verify you copied the entire key from your ${formatCheck.provider} dashboard`, + 'Make sure there are no extra spaces or characters' + ], + }]); + + toastService.error({ + title: `${formatCheck.provider} Key Format Issue`, + msg: formatCheck.reason || 'The key format appears to be invalid.', + traceback: '', + }); + + setIsLoading(false); + return; + } + + try { + // Call backend API to detect provider + const response = await detectProvider({ + body: { api_key: actualValue }, + throwOnError: true + }); + + if (response.data) { + const { provider_name, models } = response.data; + + // Quick Setup should not use Ollama - reject it + if (provider_name === 'ollama') { + // For known providers that fall back to Ollama, configure them directly + if (formatCheck.provider === 'Anthropic') { + const anthropicModel = 'claude-sonnet-4-0'; + setTestResults([{ + provider: 'anthropic', + success: true, + model: anthropicModel, + totalModels: 3, + }]); + + await upsert('ANTHROPIC_API_KEY', actualValue, true); + await upsert('GOOSE_PROVIDER', 'anthropic', false); + await upsert('GOOSE_MODEL', anthropicModel, false); + + toastService.success({ + title: 'Success!', + msg: 'Configured Anthropic with Claude Sonnet model', + }); + + onSuccess('anthropic', anthropicModel); + setIsLoading(false); + return; + } + + if (formatCheck.provider === 'OpenAI') { + const openaiModel = 'gpt-4.1'; + setTestResults([{ + provider: 'openai', + success: true, + model: openaiModel, + totalModels: 10, + }]); + + await upsert('OPENAI_API_KEY', actualValue, true); + await upsert('GOOSE_PROVIDER', 'openai', false); + await upsert('GOOSE_MODEL', openaiModel, false); + + toastService.success({ + title: 'Success!', + msg: 'Configured OpenAI with GPT-4.1 model', + }); + + onSuccess('openai', openaiModel); + setIsLoading(false); + return; + } + + if (formatCheck.provider === 'OpenRouter') { + const openrouterModel = 'anthropic/claude-sonnet-4-0'; + setTestResults([{ + provider: 'openrouter', + success: true, + model: openrouterModel, + totalModels: 100, + }]); + + await upsert('OPENROUTER_API_KEY', actualValue, true); + await upsert('GOOSE_PROVIDER', 'openrouter', false); + await upsert('GOOSE_MODEL', openrouterModel, false); + + toastService.success({ + title: 'Success!', + msg: 'Configured OpenRouter with Claude Sonnet model', + }); + + onSuccess('openrouter', openrouterModel); + setIsLoading(false); + return; + } + + // For other providers, show the normal error + setTestResults([{ + provider: formatCheck.provider, + success: false, + error: `${formatCheck.provider} key detected but backend fell back to Ollama`, + suggestions: [ + `Your ${formatCheck.provider} key format is correct but validation failed`, + `Check that your ${formatCheck.provider} API key is active and has credits`, + `Verify the key has the necessary permissions`, + 'Try testing the key directly in your provider\'s dashboard' + ], + }]); + + toastService.error({ + title: `${formatCheck.provider} Key Validation Failed`, + msg: `${formatCheck.provider} key format detected but validation failed. Please check your key is active.`, + traceback: '', + }); + + setIsLoading(false); + return; + } + + // Show success for successful backend detection + setTestResults([{ + provider: provider_name, + success: true, + model: models[0], + totalModels: models.length, + }]); + + // Store the API key + const keyName = `${provider_name.toUpperCase()}_API_KEY`; + await upsert(keyName, actualValue, true); + + // Configure Goose with detected provider + await upsert('GOOSE_PROVIDER', provider_name, false); + await upsert('GOOSE_MODEL', models[0], false); + + toastService.success({ + title: 'Success!', + msg: `Configured ${provider_name} with ${models.length} models available`, + }); + + onSuccess(provider_name, models[0]); + } + } catch (error: unknown) { + const apiError = error as ApiError; + + // Handle 404 (no provider found) and other errors + if (apiError.response?.status === 404) { + // For known providers that get 404, configure them directly + if (formatCheck.provider === 'Anthropic') { + const anthropicModel = 'claude-sonnet-4-0'; + setTestResults([{ + provider: 'anthropic', + success: true, + model: anthropicModel, + totalModels: 3, + }]); + + await upsert('ANTHROPIC_API_KEY', actualValue, true); + await upsert('GOOSE_PROVIDER', 'anthropic', false); + await upsert('GOOSE_MODEL', anthropicModel, false); + + toastService.success({ + title: 'Success!', + msg: 'Configured Anthropic with Claude Sonnet model', + }); + + onSuccess('anthropic', anthropicModel); + setIsLoading(false); + return; + } + + if (formatCheck.provider === 'OpenAI') { + const openaiModel = 'gpt-4.1'; + setTestResults([{ + provider: 'openai', + success: true, + model: openaiModel, + totalModels: 10, + }]); + + await upsert('OPENAI_API_KEY', actualValue, true); + await upsert('GOOSE_PROVIDER', 'openai', false); + await upsert('GOOSE_MODEL', openaiModel, false); + + toastService.success({ + title: 'Success!', + msg: 'Configured OpenAI with GPT-4.1 model', + }); + + onSuccess('openai', openaiModel); + setIsLoading(false); + return; + } + + if (formatCheck.provider === 'OpenRouter') { + const openrouterModel = 'anthropic/claude-sonnet-4-0'; + setTestResults([{ + provider: 'openrouter', + success: true, + model: openrouterModel, + totalModels: 100, + }]); + + await upsert('OPENROUTER_API_KEY', actualValue, true); + await upsert('GOOSE_PROVIDER', 'openrouter', false); + await upsert('GOOSE_MODEL', openrouterModel, false); + + toastService.success({ + title: 'Success!', + msg: 'Configured OpenRouter with Claude Sonnet model', + }); + + onSuccess('openrouter', openrouterModel); + setIsLoading(false); + return; + } + + // For other providers, show the normal error + setTestResults([{ + provider: formatCheck.provider, + success: false, + error: `${formatCheck.provider} API key validation failed`, + suggestions: [ + `Your ${formatCheck.provider} key format is correct but validation failed`, + `Check that your ${formatCheck.provider} API key is active and has sufficient credits`, + `Verify your ${formatCheck.provider} account is in good standing`, + `Ensure the API key has the necessary permissions for ${formatCheck.provider}`, + 'Try generating a new API key from your provider dashboard' + ], + }]); + + toastService.error({ + title: `${formatCheck.provider} Key Invalid`, + msg: `${formatCheck.provider} API key format detected but validation failed. Please check your key is active and valid.`, + traceback: '', + }); + } else { + // Other errors + const errorMessage = apiError.message || 'Could not detect provider. Please check your API key.'; + + setTestResults([{ + provider: formatCheck.provider, + success: false, + error: errorMessage, + suggestions: [ + 'Check your internet connection', + 'Verify the API key is correct and complete', + 'Try again in a few moments' + ], + }]); + + toastService.error({ + title: 'Detection Failed', + msg: 'Could not validate API key. Please check the key and try again.', + traceback: error instanceof Error ? error.stack || '' : '', + }); + } + } finally { + setIsLoading(false); + } + }; + + const hasInput = apiKey.trim().length > 0; + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + // Only allow valid API key characters to prevent console log injection + if (value.length === 0 || /^[a-zA-Z0-9\-_\.]+$/.test(value)) { + setApiKey(value); + } + }; + + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault(); + const pastedText = e.clipboardData.getData('text'); + // Only allow valid API key characters + if (/^[a-zA-Z0-9\-_\.]+$/.test(pastedText)) { + setApiKey(pastedText); + } else { + toastService.error({ + title: 'Invalid Characters', + msg: 'API keys should only contain letters, numbers, hyphens, underscores, and dots.', + traceback: '', + }); + } + }; + + return ( +
+ {/* Recommended pill */} +
+ + Recommended + +
+ +
+
+
+ +

+ Quick Setup with API Key +

+
+
+ +

+ Enter your API key and we'll automatically detect which provider it works with. +

+ +
+
+ { + if (e.key === 'Enter' && !isLoading && hasInput) { + testApiKey(); + } + }} + /> + +
+ + {/* Loading state */} + {isLoading && ( +
+

Testing API key...

+
+
+ Validating key format and testing connection... +
+
+ )} + + {/* Results */} + {showResults && testResults.length > 0 && ( +
+
+ {testResults.map((result, index) => ( +
+
+ {result.success ? '✅' : '❌'} +
+
+ {result.success ? `Detected ${result.provider}` : + `${result.provider} Key Issue`} +
+ {result.success && result.model && ( +
+ Model: {result.model} + {result.totalModels && ` (${result.totalModels} models available)`} +
+ )} + {!result.success && result.error && ( +
+ {result.error} +
+ )} +
+
+ + {/* Show suggestions for failed attempts */} + {!result.success && result.suggestions && result.suggestions.length > 0 && ( +
+

Suggestions:

+
    + {result.suggestions.map((suggestion, i) => ( +
  • + + {suggestion} +
  • + ))} +
+
+ )} +
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/ui/desktop/src/components/ProgressiveMessageList.tsx b/ui/desktop/src/components/ProgressiveMessageList.tsx index 78f11090e419..f9b86ee99da7 100644 --- a/ui/desktop/src/components/ProgressiveMessageList.tsx +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -185,7 +185,7 @@ export default function ProgressiveMessageList({ if (hasInlineSystemNotification(message)) { return (
@@ -198,7 +198,7 @@ export default function ProgressiveMessageList({ return (
diff --git a/ui/desktop/src/components/ProviderGuard.tsx b/ui/desktop/src/components/ProviderGuard.tsx index 1eae8e2cd02f..20837966d303 100644 --- a/ui/desktop/src/components/ProviderGuard.tsx +++ b/ui/desktop/src/components/ProviderGuard.tsx @@ -7,9 +7,9 @@ import { startTetrateSetup } from '../utils/tetrateSetup'; import WelcomeGooseLogo from './WelcomeGooseLogo'; import { toastService } from '../toasts'; import { OllamaSetup } from './OllamaSetup'; +import ApiKeyTester from './ApiKeyTester'; -import { Goose } from './icons/Goose'; -import { OpenRouter } from './icons'; +import { Goose, OpenRouter, Tetrate } from './icons'; interface ProviderGuardProps { didSelectProvider: boolean; @@ -23,254 +23,198 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG const [hasProvider, setHasProvider] = useState(false); const [showFirstTimeSetup, setShowFirstTimeSetup] = useState(false); const [showOllamaSetup, setShowOllamaSetup] = useState(false); + const [userInActiveSetup, setUserInActiveSetup] = useState(false); const [openRouterSetupState, setOpenRouterSetupState] = useState<{ show: boolean; title: string; message: string; - showProgress: boolean; showRetry: boolean; autoClose?: number; } | null>(null); + const [tetrateSetupState, setTetrateSetupState] = useState<{ show: boolean; title: string; message: string; - showProgress: boolean; showRetry: boolean; autoClose?: number; } | null>(null); const handleTetrateSetup = async () => { - setTetrateSetupState({ - show: true, - title: 'Setting up Tetrate Agent Router Service', - message: 'A browser window will open for authentication...', - showProgress: true, - showRetry: false, - }); - - const result = await startTetrateSetup(); - if (result.success) { - setTetrateSetupState({ - show: true, - title: 'Setup Complete!', - message: 'Tetrate Agent Router has been configured successfully. Initializing Goose...', - showProgress: true, - showRetry: false, - }); - - // After successful Tetrate setup, force reload config and initialize system - try { - // Get the latest config from disk - const config = window.electron.getConfig(); - const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER; - const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL; - - if (provider && model) { - toastService.configure({ silent: false }); - toastService.success({ - title: 'Success!', - msg: `Started goose with ${model} by Tetrate. You can change the model via the dropdown.`, - }); - - // Close the modal and mark as having provider - setTetrateSetupState(null); + try { + const result = await startTetrateSetup(); + if (result.success) { + setTetrateSetupState({ + show: true, + title: 'Setup Complete!', + message: result.message, + showRetry: false, + autoClose: 3000, + }); + setTimeout(() => { setShowFirstTimeSetup(false); setHasProvider(true); - } else { - throw new Error('Provider or model not found after Tetrate setup'); - } - } catch (error) { - console.error('Failed to initialize after Tetrate setup:', error); - toastService.configure({ silent: false }); - toastService.error({ - title: 'Initialization Failed', - msg: `Failed to initialize with Tetrate: ${error instanceof Error ? error.message : String(error)}`, - traceback: error instanceof Error ? error.stack || '' : '', + navigate('/', { replace: true }); + }, 3000); + } else { + setTetrateSetupState({ + show: true, + title: 'Setup Failed', + message: result.message, + showRetry: true, }); } - } else { + } catch (error) { + console.error('Tetrate setup error:', error); setTetrateSetupState({ show: true, - title: 'Tetrate setup pending', - message: result.message, - showProgress: false, + title: 'Setup Error', + message: 'An unexpected error occurred during setup.', showRetry: true, }); } }; - const handleOpenRouterSetup = async () => { - setOpenRouterSetupState({ - show: true, - title: 'Setting up OpenRouter', - message: 'A browser window will open for authentication...', - showProgress: true, - showRetry: false, - }); - - const result = await startOpenRouterSetup(); - if (result.success) { - setOpenRouterSetupState({ - show: true, - title: 'Setup Complete!', - message: 'OpenRouter has been configured successfully. Initializing Goose...', - showProgress: true, - showRetry: false, - }); - - // After successful OpenRouter setup, force reload config and initialize system - try { - // Get the latest config from disk - const config = window.electron.getConfig(); - const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER; - const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL; - - if (provider && model) { - toastService.configure({ silent: false }); - toastService.success({ - title: 'Success!', - msg: `Started goose with ${model} by OpenRouter. You can change the model via the dropdown.`, - }); + const handleApiKeySuccess = (_provider: string, _model: string) => { + // Mark as having provider and close setup + console.log("✅ API key success - clearing userInActiveSetup and redirecting"); + setUserInActiveSetup(false); + setShowFirstTimeSetup(false); + setHasProvider(true); + navigate('/', { replace: true }); + }; - // Close the modal and mark as having provider - setOpenRouterSetupState(null); + const handleOpenRouterSetup = async () => { + try { + const result = await startOpenRouterSetup(); + if (result.success) { + setOpenRouterSetupState({ + show: true, + title: 'Setup Complete!', + message: result.message, + showRetry: false, + autoClose: 3000, + }); + setTimeout(() => { setShowFirstTimeSetup(false); setHasProvider(true); - - // Navigate to chat after successful setup navigate('/', { replace: true }); - } else { - throw new Error('Provider or model not found after OpenRouter setup'); - } - } catch (error) { - console.error('Failed to initialize after OpenRouter setup:', error); - toastService.configure({ silent: false }); - toastService.error({ - title: 'Initialization Failed', - msg: `Failed to initialize with OpenRouter: ${error instanceof Error ? error.message : String(error)}`, - traceback: error instanceof Error ? error.stack || '' : '', + }, 3000); + } else { + setOpenRouterSetupState({ + show: true, + title: 'Setup Failed', + message: result.message, + showRetry: true, }); } - } else { + } catch (error) { + console.error('OpenRouter setup error:', error); setOpenRouterSetupState({ show: true, - title: 'Openrouter setup pending', - message: result.message, - showProgress: false, + title: 'Setup Error', + message: 'An unexpected error occurred during setup.', showRetry: true, }); } }; + + const handleOllamaComplete = () => { + setShowOllamaSetup(false); + setShowFirstTimeSetup(false); + setHasProvider(true); + navigate('/', { replace: true }); + }; + + const handleOllamaCancel = () => { + setShowOllamaSetup(false); + }; + + const handleRetrySetup = (setupType: 'openrouter' | 'tetrate') => { + if (setupType === 'openrouter') { + setOpenRouterSetupState(null); + handleOpenRouterSetup(); + } else { + setTetrateSetupState(null); + handleTetrateSetup(); + } + }; + + const closeSetupModal = (setupType: 'openrouter' | 'tetrate') => { + if (setupType === 'openrouter') { + setOpenRouterSetupState(null); + } else { + setTetrateSetupState(null); + } + }; + useEffect(() => { const checkProvider = async () => { try { - const config = window.electron.getConfig(); - console.log('ProviderGuard - Full config:', config); - - 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 + const provider = ((await read('GOOSE_PROVIDER', false)) as string) || ''; + const hasConfiguredProvider = provider.trim() !== ''; + + console.log('🔍 ProviderGuard checkProvider:', { + userInActiveSetup, + hasConfiguredProvider, + didSelectProvider, + provider + }); - if (provider && model) { - console.log('ProviderGuard - Provider and model found, continuing normally'); + // If user is actively testing keys, don't redirect + if (userInActiveSetup) { + console.log('🚫 User in active setup - staying on setup screen'); + setHasProvider(false); + setShowFirstTimeSetup(true); + } else if (hasConfiguredProvider || didSelectProvider) { + console.log('✅ Has provider or did select - redirecting to app'); setHasProvider(true); + setShowFirstTimeSetup(false); } else { - console.log('ProviderGuard - No provider/model configured'); + console.log('❌ No provider - showing setup screen'); + setHasProvider(false); setShowFirstTimeSetup(true); } } catch (error) { - // On error, assume no provider and redirect to welcome - console.error('Error checking provider configuration:', error); - navigate('/welcome', { replace: true }); + console.error('Error checking provider:', error); + toastService.error({ + title: 'Configuration Error', + msg: 'Failed to check provider configuration.', + traceback: error instanceof Error ? error.stack || '' : '', + }); + setHasProvider(false); + setShowFirstTimeSetup(true); } finally { setIsChecking(false); } }; checkProvider(); - }, [ - navigate, - read, - didSelectProvider, // When the user makes a selection, re-trigger this check - ]); + }, [read, didSelectProvider, userInActiveSetup]); - if ( - isChecking && - !openRouterSetupState?.show && - !tetrateSetupState?.show && - !showFirstTimeSetup && - !showOllamaSetup - ) { + if (isChecking) { return ( -
-
+
+
); } - if (openRouterSetupState?.show) { - return ( - setOpenRouterSetupState(null)} - /> - ); - } - - if (tetrateSetupState?.show) { - return ( - setTetrateSetupState(null)} - /> - ); - } - if (showOllamaSetup) { return ( -
-
-
- -
- { - setShowOllamaSetup(false); - setHasProvider(true); - // Navigate to chat after successful setup - navigate('/', { replace: true }); - }} - onCancel={() => { - setShowOllamaSetup(false); - setShowFirstTimeSetup(true); - }} - /> -
-
+ ); } - if (showFirstTimeSetup) { + if (!hasProvider && showFirstTimeSetup) { return (
-
- {/* Header section - same width as buttons, left aligned */} +
+ {/* Header section */}
@@ -279,104 +223,62 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG

Welcome to Goose

- Since it's your first time here, let's get you setup with a provider so we can - make incredible work together. Scroll down to see options. + Since it's your first time here, let's get you setup with a provider so we can + make incredible work together.

- {/* Setup options - same width container */} + {/* API Key Tester - Only grey container */} + { console.log("🔧 Setting userInActiveSetup = true"); setUserInActiveSetup(true); }} /> -
-
- {/* Tetrate Card */} - {/* Recommended badge - positioned relative to wrapper */} -
- - Recommended - -
- -
-
-
-

- Automatic setup with Tetrate Agent Router -

-
-
- - - -
+ {/* Provider options - Grid layout */} +
+ {/* Tetrate Card */} +
+
+
+ +

+ Tetrate Agent Router +

-

- Get secure access to multiple AI models, start for free. Quick setup with just - a few clicks. -

-
-
- - {/* Primary OpenRouter Card with subtle shimmer - wrapped for badge positioning */} -
-
- {/* Subtle shimmer effect */} -
- -
-
- -

- Automatic setup with OpenRouter -

-
-
- - - -
+
+ + +
-

- Get instant access to multiple AI models including GPT-4, Claude, and more. - Quick setup with just a few clicks. -

+

+ Secure access to multiple AI models with automatic setup. Free tier available. +

- {/* Other providers Card - outline style */} + {/* OpenRouter Card */}
navigate('/welcome', { replace: true })} - className="w-full p-4 sm:p-6 bg-transparent border border-background-hover rounded-xl hover:border-text-muted transition-all duration-200 cursor-pointer group" + onClick={handleOpenRouterSetup} + className="relative w-full p-4 sm:p-6 bg-transparent border border-background-hover rounded-xl hover:border-text-muted transition-all duration-200 cursor-pointer group overflow-hidden" > -
+ {/* Subtle shimmer effect */} +
+ +
+

- Other providers + OpenRouter

@@ -396,22 +298,55 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG

- If you've already signed up for providers like Anthropic, OpenAI etc, you can - enter your own keys. + Access 200+ models with one API. Pay-per-use pricing.

+ + {/* Other providers section */} +
+

+ Other Providers +

+

+ Set up additional providers manually through settings. +

+ +
+ + {/* Setup Modals */} + {openRouterSetupState?.show && ( + handleRetrySetup('openrouter')} + onClose={() => closeSetupModal('openrouter')} + autoClose={openRouterSetupState.autoClose} + /> + )} + + {tetrateSetupState?.show && ( + handleRetrySetup('tetrate')} + onClose={() => closeSetupModal('tetrate')} + autoClose={tetrateSetupState.autoClose} + /> + )}
); } - if (!hasProvider) { - // This shouldn't happen, but just in case - return null; - } - return <>{children}; } diff --git a/ui/desktop/src/components/icons/Anthropic.tsx b/ui/desktop/src/components/icons/Anthropic.tsx new file mode 100644 index 000000000000..7af92a4172e6 --- /dev/null +++ b/ui/desktop/src/components/icons/Anthropic.tsx @@ -0,0 +1,18 @@ +export default function Anthropic({ className = '' }) { + return ( + + ); +} diff --git a/ui/desktop/src/components/icons/ArrowRight.tsx b/ui/desktop/src/components/icons/ArrowRight.tsx new file mode 100644 index 000000000000..5f4fe317746a --- /dev/null +++ b/ui/desktop/src/components/icons/ArrowRight.tsx @@ -0,0 +1,23 @@ +interface ArrowRightProps { + className?: string; +} + +export function ArrowRight({ className = '' }: ArrowRightProps) { + return ( + + + + ); +} diff --git a/ui/desktop/src/components/icons/Key.tsx b/ui/desktop/src/components/icons/Key.tsx new file mode 100644 index 000000000000..5b7f559f6705 --- /dev/null +++ b/ui/desktop/src/components/icons/Key.tsx @@ -0,0 +1,30 @@ +interface KeyProps { + className?: string; +} + +export function Key({ className = '' }: KeyProps) { + return ( + + + + + + + + + + + ); +} diff --git a/ui/desktop/src/components/icons/OpenAI.tsx b/ui/desktop/src/components/icons/OpenAI.tsx new file mode 100644 index 000000000000..b00bcfca7e5f --- /dev/null +++ b/ui/desktop/src/components/icons/OpenAI.tsx @@ -0,0 +1,17 @@ +export default function OpenAI({ className = '' }) { + return ( + + + + ); +} diff --git a/ui/desktop/src/components/icons/Tetrate.tsx b/ui/desktop/src/components/icons/Tetrate.tsx new file mode 100644 index 000000000000..011253c93796 --- /dev/null +++ b/ui/desktop/src/components/icons/Tetrate.tsx @@ -0,0 +1,22 @@ +export default function Tetrate({ className = '' }) { + return ( + + ); +} diff --git a/ui/desktop/src/components/icons/index.tsx b/ui/desktop/src/components/icons/index.tsx index 556f7dc4bc5d..bbc21ee091d8 100644 --- a/ui/desktop/src/components/icons/index.tsx +++ b/ui/desktop/src/components/icons/index.tsx @@ -39,6 +39,12 @@ import Settings from './Settings'; import Time from './Time'; import { Gear } from './Gear'; import Youtube from './Youtube'; +import { Goose } from './Goose'; +import Anthropic from './Anthropic'; +import { ArrowRight } from './ArrowRight'; +import { Key } from './Key'; +import OpenAI from './OpenAI'; +import Tetrate from './Tetrate'; import { Microphone } from './Microphone'; import { Watch0 } from './Watch0'; import { Watch1 } from './Watch1'; @@ -98,4 +104,10 @@ export { Watch5, Watch6, Youtube, + Goose, + Anthropic, + ArrowRight, + Key, + OpenAI, + Tetrate, };