Show provider failures (#6744)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2026-01-27 14:53:06 -05:00
committed by GitHub
parent b5700461cc
commit f583255088
6 changed files with 90 additions and 62 deletions
@@ -17,7 +17,6 @@ use goose::providers::canonical::maybe_get_canonical_model;
use goose::providers::create_with_default_model; use goose::providers::create_with_default_model;
use goose::providers::errors::ProviderError; use goose::providers::errors::ProviderError;
use goose::providers::providers as get_providers; use goose::providers::providers as get_providers;
use goose::providers::{retry_operation, RetryConfig};
use goose::{ use goose::{
agents::execute_commands, agents::ExtensionConfig, config::permission::PermissionLevel, agents::execute_commands, agents::ExtensionConfig, config::permission::PermissionLevel,
slash_commands, slash_commands,
@@ -408,10 +407,7 @@ pub async fn get_provider_models(
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let models_result = retry_operation(&RetryConfig::default(), || async { let models_result = provider.fetch_recommended_models().await;
provider.fetch_recommended_models().await
})
.await;
match models_result { match models_result {
Ok(Some(models)) => Ok(Json(models)), Ok(Some(models)) => Ok(Json(models)),
@@ -8,7 +8,6 @@ import {
addExtension as apiAddExtension, addExtension as apiAddExtension,
removeExtension as apiRemoveExtension, removeExtension as apiRemoveExtension,
providers, providers,
getProviderModels as apiGetProviderModels,
} from '../api'; } from '../api';
import { syncBundledExtensions } from './settings/extensions'; import { syncBundledExtensions } from './settings/extensions';
import type { import type {
@@ -41,7 +40,6 @@ interface ConfigContextType {
removeExtension: (name: string) => Promise<void>; removeExtension: (name: string) => Promise<void>;
getProviders: (b: boolean) => Promise<ProviderDetails[]>; getProviders: (b: boolean) => Promise<ProviderDetails[]>;
getExtensions: (b: boolean) => Promise<FixedExtensionEntry[]>; getExtensions: (b: boolean) => Promise<FixedExtensionEntry[]>;
getProviderModels: (providerName: string) => Promise<string[]>;
disableAllExtensions: () => Promise<void>; disableAllExtensions: () => Promise<void>;
enableBotExtensions: (extensions: ExtensionConfig[]) => Promise<void>; enableBotExtensions: (extensions: ExtensionConfig[]) => Promise<void>;
} }
@@ -187,19 +185,6 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
return providersListRef.current; return providersListRef.current;
}, []); }, []);
const getProviderModels = useCallback(async (providerName: string): Promise<string[]> => {
try {
const response = await apiGetProviderModels({
path: { name: providerName },
throwOnError: true,
});
return response.data || [];
} catch (error) {
console.error(`Failed to fetch models for provider ${providerName}:`, error);
return [];
}
}, []);
useEffect(() => { useEffect(() => {
// Load all configuration data and providers on mount // Load all configuration data and providers on mount
(async () => { (async () => {
@@ -284,7 +269,6 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
toggleExtension, toggleExtension,
getProviders, getProviders,
getExtensions, getExtensions,
getProviderModels,
disableAllExtensions, disableAllExtensions,
enableBotExtensions, enableBotExtensions,
}; };
@@ -301,7 +285,6 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
toggleExtension, toggleExtension,
getProviders, getProviders,
getExtensions, getExtensions,
getProviderModels,
reloadConfig, reloadConfig,
]); ]);
@@ -1,4 +1,4 @@
import { ProviderDetails } from '../../../api'; import { ProviderDetails, getProviderModels } from '../../../api';
export default interface Model { export default interface Model {
id?: number; // Make `id` optional to allow user-defined models id?: number; // Make `id` optional to allow user-defined models
@@ -48,24 +48,19 @@ export interface ProviderModelsResult {
error: string | null; error: string | null;
} }
/**
* Fetches recommended models for all active providers in parallel.
* Falls back to known_models if fetching fails or returns no models.
*/
export async function fetchModelsForProviders( export async function fetchModelsForProviders(
activeProviders: ProviderDetails[], activeProviders: ProviderDetails[]
getProviderModelsFunc: (providerName: string) => Promise<string[]>
): Promise<ProviderModelsResult[]> { ): Promise<ProviderModelsResult[]> {
const modelPromises = activeProviders.map(async (p) => { const modelPromises = activeProviders.map(async (p) => {
const providerName = p.name;
try { try {
let models = await getProviderModelsFunc(providerName); const response = await getProviderModels({
if ((!models || models.length === 0) && p.metadata.known_models?.length) { path: { name: p.name },
models = p.metadata.known_models.map((m) => m.name); throwOnError: true,
} });
const models = response.data || [];
return { provider: p, models, error: null }; return { provider: p, models, error: null };
} catch (e: unknown) { } catch (e: unknown) {
const errorMessage = `Failed to fetch models for ${providerName}${e instanceof Error ? `: ${e.message}` : ''}`; const errorMessage = `Failed to fetch models for ${p.name}${e instanceof Error ? `: ${e.message}` : ''}`;
return { return {
provider: p, provider: p,
models: null, models: null,
@@ -5,8 +5,8 @@ import { Button } from '../../../ui/button';
import { Select } from '../../../ui/Select'; import { Select } from '../../../ui/Select';
import { Input } from '../../../ui/input'; import { Input } from '../../../ui/input';
import { getPredefinedModelsFromEnv, shouldShowPredefinedModels } from '../predefinedModelsUtils'; import { getPredefinedModelsFromEnv, shouldShowPredefinedModels } from '../predefinedModelsUtils';
import { fetchModelsForProviders } from '../modelInterface';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../../ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../../ui/dialog';
import { fetchModelsForProviders } from '../modelInterface';
interface LeadWorkerSettingsProps { interface LeadWorkerSettingsProps {
isOpen: boolean; isOpen: boolean;
@@ -14,7 +14,7 @@ interface LeadWorkerSettingsProps {
} }
export function LeadWorkerSettings({ isOpen, onClose }: LeadWorkerSettingsProps) { export function LeadWorkerSettings({ isOpen, onClose }: LeadWorkerSettingsProps) {
const { read, upsert, getProviders, getProviderModels, remove } = useConfig(); const { read, upsert, getProviders, remove } = useConfig();
const { currentModel } = useModelAndProvider(); const { currentModel } = useModelAndProvider();
const [leadModel, setLeadModel] = useState<string>(''); const [leadModel, setLeadModel] = useState<string>('');
const [workerModel, setWorkerModel] = useState<string>(''); const [workerModel, setWorkerModel] = useState<string>('');
@@ -104,7 +104,8 @@ export function LeadWorkerSettings({ isOpen, onClose }: LeadWorkerSettingsProps)
const providers = await getProviders(false); const providers = await getProviders(false);
const activeProviders = providers.filter((p) => p.is_configured); const activeProviders = providers.filter((p) => p.is_configured);
const results = await fetchModelsForProviders(activeProviders, getProviderModels); const results = await fetchModelsForProviders(activeProviders);
results.forEach(({ provider: p, models, error }) => { results.forEach(({ provider: p, models, error }) => {
if (error) { if (error) {
console.error(error); console.error(error);
@@ -139,7 +140,7 @@ export function LeadWorkerSettings({ isOpen, onClose }: LeadWorkerSettingsProps)
}; };
loadConfig(); loadConfig();
}, [read, getProviders, getProviderModels, currentModel, isOpen]); }, [read, getProviders, currentModel, isOpen]);
// If current models are not in the list (e.g., previously set to custom), switch to custom mode // If current models are not in the list (e.g., previously set to custom), switch to custom mode
useEffect(() => { useEffect(() => {
@@ -75,7 +75,7 @@ export const SwitchModelModal = ({
initialProvider, initialProvider,
titleOverride, titleOverride,
}: SwitchModelModalProps) => { }: SwitchModelModalProps) => {
const { getProviders, getProviderModels, read } = useConfig(); const { getProviders, read } = useConfig();
const { changeModel, currentModel, currentProvider } = useModelAndProvider(); const { changeModel, currentModel, currentProvider } = useModelAndProvider();
const [providerOptions, setProviderOptions] = useState<{ value: string; label: string }[]>([]); const [providerOptions, setProviderOptions] = useState<{ value: string; label: string }[]>([]);
type ModelOption = { value: string; label: string; provider: string; isDisabled?: boolean }; type ModelOption = { value: string; label: string; provider: string; isDisabled?: boolean };
@@ -96,6 +96,7 @@ export const SwitchModelModal = ({
const [predefinedModels, setPredefinedModels] = useState<Model[]>([]); const [predefinedModels, setPredefinedModels] = useState<Model[]>([]);
const [loadingModels, setLoadingModels] = useState<boolean>(false); const [loadingModels, setLoadingModels] = useState<boolean>(false);
const [userClearedModel, setUserClearedModel] = useState(false); const [userClearedModel, setUserClearedModel] = useState(false);
const [providerErrors, setProviderErrors] = useState<Record<string, string>>({});
// Validate form data // Validate form data
const validateForm = useCallback(() => { const validateForm = useCallback(() => {
@@ -204,24 +205,22 @@ export const SwitchModelModal = ({
setLoadingModels(true); setLoadingModels(true);
// Fetching models for all providers (always recommended) const results = await fetchModelsForProviders(activeProviders);
const results = await fetchModelsForProviders(activeProviders, getProviderModels);
// Process results and build grouped options // Process results and build grouped options
const groupedOptions: { const groupedOptions: {
options: { value: string; label: string; provider: string; providerType: ProviderType }[]; options: { value: string; label: string; provider: string; providerType: ProviderType }[];
}[] = []; }[] = [];
const errors: string[] = []; const errorMap: Record<string, string> = {};
results.forEach(({ provider: p, models, error }) => { results.forEach(({ provider: p, models, error }) => {
const modelList = error
? p.metadata.known_models?.map(({ name }) => name) || []
: models || [];
if (error) { if (error) {
errors.push(error); errorMap[p.name] = error;
return;
} }
const modelList = models || [];
const options: { const options: {
value: string; value: string;
label: string; label: string;
@@ -248,10 +247,8 @@ export const SwitchModelModal = ({
} }
}); });
// Log errors if any providers failed (don't show to user) // Save provider errors to state
if (errors.length > 0) { setProviderErrors(errorMap);
console.error('Provider model fetch errors:', errors);
}
setModelOptions(groupedOptions); setModelOptions(groupedOptions);
setOriginalModelOptions(groupedOptions); setOriginalModelOptions(groupedOptions);
@@ -261,7 +258,7 @@ export const SwitchModelModal = ({
setLoadingModels(false); setLoadingModels(false);
} }
})(); })();
}, [getProviders, getProviderModels, usePredefinedModels, read, initialProvider]); }, [getProviders, usePredefinedModels, read, initialProvider]);
const filteredModelOptions = provider const filteredModelOptions = provider
? modelOptions.filter((group) => group.options[0]?.provider === provider) ? modelOptions.filter((group) => group.options[0]?.provider === provider)
@@ -454,7 +451,24 @@ export const SwitchModelModal = ({
{provider && ( {provider && (
<> <>
{!isCustomModel ? ( {providerErrors[provider] ? (
/* Show error message when provider failed to connect */
<div className="rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3">
<div className="flex items-start">
<div className="flex-1">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Could not contact provider
</h3>
<div className="mt-1 text-sm text-red-700 dark:text-red-300">
{providerErrors[provider]}
</div>
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
Check your provider configuration in Settings Providers
</div>
</div>
</div>
</div>
) : !isCustomModel ? (
<div> <div>
<Select <Select
options={ options={
@@ -466,10 +480,14 @@ export const SwitchModelModal = ({
} }
onChange={handleModelChange} onChange={handleModelChange}
onInputChange={handleInputChange} onInputChange={handleInputChange}
value={model ? { value: model, label: model } : null} value={
placeholder={ loadingModels
loadingModels ? 'Loading models…' : 'Select a model, type to search' ? { value: '', label: 'Loading models…', isDisabled: true }
: model
? { value: model, label: model }
: null
} }
placeholder="Select a model, type to search"
isClearable isClearable
isDisabled={loadingModels} isDisabled={loadingModels}
/> />
@@ -1,4 +1,4 @@
import { checkProvider } from '../../../../../../api'; import { getProviderModels, readConfig } from '../../../../../../api';
/** /**
* Standalone function to submit provider configuration * Standalone function to submit provider configuration
@@ -21,6 +21,30 @@ export const providerConfigSubmitHandler = async (
) => { ) => {
const parameters = provider.metadata.config_keys || []; const parameters = provider.metadata.config_keys || [];
// Save current NON-SECRET config values for rollback on failure
// We skip secrets because readConfig returns masked values for secrets,
// and upserting those masked values would corrupt the actual secret
const previousConfigValues: Record<string, { value: unknown; isSecret: boolean }> = {};
const nonSecretParams = parameters.filter((param) => !param.secret);
await Promise.all(
nonSecretParams.map(async (param) => {
try {
const currentValue = await readConfig({
body: { key: param.name, is_secret: false },
});
if (currentValue.data) {
previousConfigValues[param.name] = {
value: currentValue.data,
isSecret: false,
};
}
} catch {
// No previous value exists, that's fine
}
})
);
const requiredParams = parameters.filter((param) => param.required); const requiredParams = parameters.filter((param) => param.required);
if (requiredParams.length === 0 && parameters.length > 0) { if (requiredParams.length === 0 && parameters.length > 0) {
const allOptionalWithDefaults = parameters.every( const allOptionalWithDefaults = parameters.every(
@@ -70,8 +94,19 @@ export const providerConfigSubmitHandler = async (
); );
await Promise.all(upsertPromises); await Promise.all(upsertPromises);
await checkProvider({
body: { provider: provider.name }, try {
throwOnError: true, await getProviderModels({
}); path: { name: provider.name },
throwOnError: true,
});
} catch (error) {
const rollbackPromises: Promise<void>[] = [];
for (const [key, { value, isSecret }] of Object.entries(previousConfigValues)) {
rollbackPromises.push(upsertFn(key, value, isSecret));
}
await Promise.all(rollbackPromises);
throw error;
}
}; };