diff --git a/src/features/runtime-provider-management/contracts/api.ts b/src/features/runtime-provider-management/contracts/api.ts index e1d084c7..15b75aa1 100644 --- a/src/features/runtime-provider-management/contracts/api.ts +++ b/src/features/runtime-provider-management/contracts/api.ts @@ -1,6 +1,8 @@ import type { RuntimeProviderManagementConnectApiKeyInput, + RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementForgetInput, + RuntimeProviderManagementLoadDirectoryInput, RuntimeProviderManagementLoadViewInput, RuntimeProviderManagementLoadModelsInput, RuntimeProviderManagementModelTestResponse, @@ -15,6 +17,9 @@ export interface RuntimeProviderManagementApi { loadView( input: RuntimeProviderManagementLoadViewInput ): Promise; + loadProviderDirectory( + input: RuntimeProviderManagementLoadDirectoryInput + ): Promise; connectWithApiKey( input: RuntimeProviderManagementConnectApiKeyInput ): Promise; diff --git a/src/features/runtime-provider-management/contracts/channels.ts b/src/features/runtime-provider-management/contracts/channels.ts index c04e8f40..5eada331 100644 --- a/src/features/runtime-provider-management/contracts/channels.ts +++ b/src/features/runtime-provider-management/contracts/channels.ts @@ -1,4 +1,5 @@ export const RUNTIME_PROVIDER_MANAGEMENT_VIEW = 'runtimeProviderManagement:view'; +export const RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY = 'runtimeProviderManagement:directory'; export const RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY = 'runtimeProviderManagement:connectApiKey'; export const RUNTIME_PROVIDER_MANAGEMENT_FORGET = 'runtimeProviderManagement:forget'; diff --git a/src/features/runtime-provider-management/contracts/types.ts b/src/features/runtime-provider-management/contracts/types.ts index 65996d5d..a2b131fc 100644 --- a/src/features/runtime-provider-management/contracts/types.ts +++ b/src/features/runtime-provider-management/contracts/types.ts @@ -58,6 +58,64 @@ export interface RuntimeProviderConnectionDto { detail: string | null; } +export type RuntimeProviderDirectoryFilterDto = + | 'all' + | 'connected' + | 'configured' + | 'connectable' + | 'manual' + | 'has-models'; + +export type RuntimeProviderSetupKindDto = + | 'connected' + | 'connect-api-key' + | 'configure-manually' + | 'requires-environment' + | 'available-readonly' + | 'unsupported'; + +export type RuntimeProviderDirectorySourceDto = + | 'opencode-provider' + | 'config-provider' + | 'inventory' + | 'seed'; + +export interface RuntimeProviderDirectoryEntryDto { + providerId: string; + displayName: string; + state: RuntimeProviderConnectionStateDto; + setupKind: RuntimeProviderSetupKindDto; + ownership: readonly RuntimeProviderOwnershipDto[]; + recommended: boolean; + modelCount: number | null; + authMethods: readonly RuntimeProviderAuthMethodDto[]; + defaultModelId: string | null; + sources: readonly RuntimeProviderDirectorySourceDto[]; + sourceLabel: string | null; + providerSource: string | null; + detail: string | null; + actions: readonly RuntimeProviderActionDescriptorDto[]; + metadata: { + hasKnownModels: boolean; + requiresManualConfig: boolean; + supportedInlineAuth: boolean; + }; +} + +export interface RuntimeProviderDirectoryDto { + runtimeId: RuntimeProviderManagementRuntimeId; + totalCount: number; + returnedCount: number; + query: string | null; + filter: RuntimeProviderDirectoryFilterDto; + limit: number; + cursor: string | null; + nextCursor: string | null; + entries: readonly RuntimeProviderDirectoryEntryDto[]; + diagnostics: readonly string[]; + fetchedAt: string; +} + export interface RuntimeProviderManagementViewDto { runtimeId: RuntimeProviderManagementRuntimeId; title: string; @@ -93,6 +151,13 @@ export interface RuntimeProviderManagementViewResponse { error?: RuntimeProviderManagementErrorDto; } +export interface RuntimeProviderManagementDirectoryResponse { + schemaVersion: 1; + runtimeId: RuntimeProviderManagementRuntimeId; + directory?: RuntimeProviderDirectoryDto; + error?: RuntimeProviderManagementErrorDto; +} + export interface RuntimeProviderManagementProviderResponse { schemaVersion: 1; runtimeId: RuntimeProviderManagementRuntimeId; @@ -153,6 +218,16 @@ export interface RuntimeProviderManagementLoadViewInput { projectPath?: string | null; } +export interface RuntimeProviderManagementLoadDirectoryInput { + runtimeId: RuntimeProviderManagementRuntimeId; + projectPath?: string | null; + query?: string | null; + filter?: RuntimeProviderDirectoryFilterDto | null; + limit?: number | null; + cursor?: string | null; + refresh?: boolean | null; +} + export interface RuntimeProviderManagementConnectApiKeyInput { runtimeId: RuntimeProviderManagementRuntimeId; providerId: string; diff --git a/src/features/runtime-provider-management/core/application/runtimeProviderManagementUseCases.ts b/src/features/runtime-provider-management/core/application/runtimeProviderManagementUseCases.ts index d2e9d297..49ddd890 100644 --- a/src/features/runtime-provider-management/core/application/runtimeProviderManagementUseCases.ts +++ b/src/features/runtime-provider-management/core/application/runtimeProviderManagementUseCases.ts @@ -1,7 +1,9 @@ import type { RuntimeProviderManagementPort } from './RuntimeProviderManagementPort'; import type { RuntimeProviderManagementConnectApiKeyInput, + RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementForgetInput, + RuntimeProviderManagementLoadDirectoryInput, RuntimeProviderManagementLoadModelsInput, RuntimeProviderManagementLoadViewInput, RuntimeProviderManagementModelTestResponse, @@ -19,6 +21,13 @@ export function loadRuntimeProviderManagementView( return port.loadView(input); } +export function loadRuntimeProviderDirectory( + port: RuntimeProviderManagementPort, + input: RuntimeProviderManagementLoadDirectoryInput +): Promise { + return port.loadProviderDirectory(input); +} + export function connectRuntimeProviderWithApiKey( port: RuntimeProviderManagementPort, input: RuntimeProviderManagementConnectApiKeyInput diff --git a/src/features/runtime-provider-management/main/adapters/input/registerRuntimeProviderManagementIpc.ts b/src/features/runtime-provider-management/main/adapters/input/registerRuntimeProviderManagementIpc.ts index 2ddf95b2..dab5396b 100644 --- a/src/features/runtime-provider-management/main/adapters/input/registerRuntimeProviderManagementIpc.ts +++ b/src/features/runtime-provider-management/main/adapters/input/registerRuntimeProviderManagementIpc.ts @@ -1,5 +1,6 @@ import { RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY, + RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY, RUNTIME_PROVIDER_MANAGEMENT_FORGET, RUNTIME_PROVIDER_MANAGEMENT_MODELS, RUNTIME_PROVIDER_MANAGEMENT_SET_DEFAULT_MODEL, @@ -11,7 +12,9 @@ import { createLogger } from '@shared/utils/logger'; import type { RuntimeProviderManagementFeatureFacade } from '../../composition/createRuntimeProviderManagementFeature'; import type { RuntimeProviderManagementConnectApiKeyInput, + RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementForgetInput, + RuntimeProviderManagementLoadDirectoryInput, RuntimeProviderManagementLoadModelsInput, RuntimeProviderManagementLoadViewInput, RuntimeProviderManagementModelTestResponse, @@ -52,6 +55,29 @@ export function registerRuntimeProviderManagementIpc( } ); + ipcMain.handle( + RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY, + async ( + _event, + input: RuntimeProviderManagementLoadDirectoryInput + ): Promise => { + try { + return await feature.loadProviderDirectory(input); + } catch (error) { + logger.error('Failed to load runtime provider directory', error); + return { + schemaVersion: 1, + runtimeId: input.runtimeId, + error: { + code: 'runtime-unhealthy', + message: error instanceof Error ? error.message : 'Failed to load provider directory', + recoverable: true, + }, + }; + } + } + ); + ipcMain.handle( RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY, async ( @@ -173,6 +199,7 @@ export function registerRuntimeProviderManagementIpc( export function removeRuntimeProviderManagementIpc(ipcMain: IpcMain): void { ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_VIEW); + ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY); ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY); ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_FORGET); ipcMain.removeHandler(RUNTIME_PROVIDER_MANAGEMENT_MODELS); diff --git a/src/features/runtime-provider-management/main/composition/createRuntimeProviderManagementFeature.ts b/src/features/runtime-provider-management/main/composition/createRuntimeProviderManagementFeature.ts index 0eadd686..3473d19a 100644 --- a/src/features/runtime-provider-management/main/composition/createRuntimeProviderManagementFeature.ts +++ b/src/features/runtime-provider-management/main/composition/createRuntimeProviderManagementFeature.ts @@ -4,7 +4,9 @@ import type { RuntimeProviderManagementPort } from '../../core/application'; import type { RuntimeProviderManagementApi, RuntimeProviderManagementConnectApiKeyInput, + RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementForgetInput, + RuntimeProviderManagementLoadDirectoryInput, RuntimeProviderManagementLoadModelsInput, RuntimeProviderManagementLoadViewInput, RuntimeProviderManagementModelTestResponse, @@ -28,6 +30,9 @@ export function createRuntimeProviderManagementFeature( loadView: ( input: RuntimeProviderManagementLoadViewInput ): Promise => port.loadView(input), + loadProviderDirectory: ( + input: RuntimeProviderManagementLoadDirectoryInput + ): Promise => port.loadProviderDirectory(input), connectWithApiKey: ( input: RuntimeProviderManagementConnectApiKeyInput ): Promise => port.connectWithApiKey(input), diff --git a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts index b7066658..95b74222 100644 --- a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts +++ b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts @@ -6,8 +6,10 @@ import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import type { RuntimeProviderManagementApi, RuntimeProviderManagementConnectApiKeyInput, + RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementErrorDto, RuntimeProviderManagementForgetInput, + RuntimeProviderManagementLoadDirectoryInput, RuntimeProviderManagementLoadModelsInput, RuntimeProviderManagementLoadViewInput, RuntimeProviderManagementModelsResponse, @@ -26,6 +28,7 @@ const COMMAND_ERROR_DETAIL_LIMIT = 1_600; type RuntimeProviderManagementErrorResponse = | RuntimeProviderManagementViewResponse + | RuntimeProviderManagementDirectoryResponse | RuntimeProviderManagementProviderResponse | RuntimeProviderManagementModelsResponse | RuntimeProviderManagementModelTestResponse; @@ -116,6 +119,13 @@ function appendProjectPathArgs(args: string[], projectPath: string | null): stri return projectPath ? [...args, '--project-path', projectPath] : args; } +function appendOptionalArg(args: string[], name: string, value: string | null | undefined): void { + const normalized = value?.trim(); + if (normalized) { + args.push(name, normalized); + } +} + function runtimeProviderCommandOptions( options: T, projectPath: string | null @@ -233,6 +243,51 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv } } + async loadProviderDirectory( + input: RuntimeProviderManagementLoadDirectoryInput + ): Promise { + const { binaryPath, env } = await resolveCliEnv(); + if (!binaryPath) { + return errorResponse( + input.runtimeId, + 'Multimodel runtime binary was not found.', + 'runtime-missing' + ); + } + + const projectPath = normalizeProjectPath(input.projectPath); + const args = ['runtime', 'providers', 'directory', '--runtime', input.runtimeId, '--json']; + appendOptionalArg(args, '--project-path', projectPath); + appendOptionalArg(args, '--query', input.query ?? null); + appendOptionalArg(args, '--filter', input.filter ?? null); + if (typeof input.limit === 'number' && Number.isFinite(input.limit) && input.limit > 0) { + args.push('--limit', String(Math.floor(input.limit))); + } + appendOptionalArg(args, '--cursor', input.cursor ?? null); + if (input.refresh) { + args.push('--refresh'); + } + + try { + const { stdout } = await execCli( + binaryPath, + args, + runtimeProviderCommandOptions({ env, timeout: COMMAND_TIMEOUT_MS }, projectPath) + ); + return extractJsonObject(stdout); + } catch (error) { + const response = + extractJsonObjectFromError(error); + if (response) { + return response; + } + return errorResponse( + input.runtimeId, + normalizeCommandFailure(error) + ); + } + } + async connectWithApiKey( input: RuntimeProviderManagementConnectApiKeyInput ): Promise { @@ -329,6 +384,10 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv ); return extractJsonObject(stdout); } catch (error) { + const response = extractJsonObjectFromError(error); + if (response) { + return response; + } return errorResponse( input.runtimeId, normalizeCommandFailure(error) diff --git a/src/features/runtime-provider-management/preload/createRuntimeProviderManagementBridge.ts b/src/features/runtime-provider-management/preload/createRuntimeProviderManagementBridge.ts index efc6bd70..cd019414 100644 --- a/src/features/runtime-provider-management/preload/createRuntimeProviderManagementBridge.ts +++ b/src/features/runtime-provider-management/preload/createRuntimeProviderManagementBridge.ts @@ -1,5 +1,6 @@ import { RUNTIME_PROVIDER_MANAGEMENT_CONNECT_API_KEY, + RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY, RUNTIME_PROVIDER_MANAGEMENT_FORGET, RUNTIME_PROVIDER_MANAGEMENT_MODELS, RUNTIME_PROVIDER_MANAGEMENT_SET_DEFAULT_MODEL, @@ -10,7 +11,9 @@ import { import type { RuntimeProviderManagementConnectApiKeyInput, + RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementForgetInput, + RuntimeProviderManagementLoadDirectoryInput, RuntimeProviderManagementLoadModelsInput, RuntimeProviderManagementLoadViewInput, RuntimeProviderManagementModelTestResponse, @@ -30,6 +33,10 @@ export function createRuntimeProviderManagementBridge( input: RuntimeProviderManagementLoadViewInput ): Promise => ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_VIEW, input), + loadProviderDirectory: ( + input: RuntimeProviderManagementLoadDirectoryInput + ): Promise => + ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY, input), connectWithApiKey: ( input: RuntimeProviderManagementConnectApiKeyInput ): Promise => diff --git a/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts b/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts index 50ba6d3a..67ede6c9 100644 --- a/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts +++ b/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; @@ -10,6 +10,8 @@ import { import type { RuntimeProviderConnectionDto, + RuntimeProviderDirectoryEntryDto, + RuntimeProviderDirectoryFilterDto, RuntimeProviderManagementRuntimeId, RuntimeProviderManagementViewDto, RuntimeProviderModelDto, @@ -30,6 +32,18 @@ export interface RuntimeProviderManagementState { providers: readonly RuntimeProviderConnectionDto[]; selectedProviderId: string | null; providerQuery: string; + directoryOpen: boolean; + directoryLoading: boolean; + directoryRefreshing: boolean; + directoryError: string | null; + directoryEntries: readonly RuntimeProviderDirectoryEntryDto[]; + directoryTotalCount: number | null; + directoryNextCursor: string | null; + directoryQuery: string; + directoryFilter: RuntimeProviderDirectoryFilterDto; + directoryLoaded: boolean; + directorySelectedProviderId: string | null; + directorySupported: boolean; activeFormProviderId: string | null; apiKeyValue: string; modelPickerProviderId: string | null; @@ -52,6 +66,14 @@ export interface RuntimeProviderManagementActions { refresh: () => Promise; selectProvider: (providerId: string) => void; setProviderQuery: (value: string) => void; + openDirectory: () => void; + closeDirectory: () => void; + setDirectoryQuery: (value: string) => void; + setDirectoryFilter: (value: RuntimeProviderDirectoryFilterDto) => void; + loadMoreDirectory: () => Promise; + refreshDirectory: () => Promise; + selectDirectoryProvider: (providerId: string) => void; + searchAllProviders: (query: string) => void; startConnect: (providerId: string) => void; cancelConnect: () => void; setApiKeyValue: (value: string) => void; @@ -146,6 +168,23 @@ export function useRuntimeProviderManagement( const [view, setView] = useState(null); const [selectedProviderId, setSelectedProviderId] = useState(null); const [providerQuery, setProviderQuery] = useState(''); + const [directoryOpen, setDirectoryOpen] = useState(false); + const [directoryLoading, setDirectoryLoading] = useState(false); + const [directoryRefreshing, setDirectoryRefreshing] = useState(false); + const [directoryError, setDirectoryError] = useState(null); + const [directoryEntries, setDirectoryEntries] = useState< + readonly RuntimeProviderDirectoryEntryDto[] + >([]); + const [directoryTotalCount, setDirectoryTotalCount] = useState(null); + const [directoryNextCursor, setDirectoryNextCursor] = useState(null); + const [directoryQuery, setDirectoryQuery] = useState(''); + const [directoryFilter, setDirectoryFilterState] = + useState('all'); + const [directoryLoaded, setDirectoryLoaded] = useState(false); + const [directorySelectedProviderId, setDirectorySelectedProviderId] = useState( + null + ); + const [directorySupported, setDirectorySupported] = useState(true); const [activeFormProviderId, setActiveFormProviderId] = useState(null); const [apiKeyValue, setApiKeyValue] = useState(''); const [modelPickerProviderId, setModelPickerProviderId] = useState(null); @@ -166,6 +205,7 @@ export function useRuntimeProviderManagement( const [savingProviderId, setSavingProviderId] = useState(null); const [error, setError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); + const directoryRequestSeq = useRef(0); const refresh = useCallback(async (): Promise => { if (!options.enabled) { @@ -199,9 +239,109 @@ export function useRuntimeProviderManagement( } }, [options.enabled, options.projectPath, options.runtimeId]); + const loadDirectoryPage = useCallback( + async ( + input: { + append?: boolean; + refresh?: boolean; + query?: string; + filter?: RuntimeProviderDirectoryFilterDto; + cursor?: string | null; + } = {} + ): Promise => { + if (!options.enabled || !directorySupported) { + return; + } + + const append = input.append === true; + const refreshDirectoryData = input.refresh === true; + const query = input.query ?? directoryQuery; + const filter = input.filter ?? directoryFilter; + const cursor = input.cursor ?? (append ? directoryNextCursor : null); + const requestSeq = directoryRequestSeq.current + 1; + directoryRequestSeq.current = requestSeq; + + if (append) { + setDirectoryRefreshing(true); + } else if (refreshDirectoryData) { + setDirectoryRefreshing(true); + } else { + setDirectoryLoading(true); + } + setDirectoryError(null); + + try { + const response = await api.runtimeProviderManagement.loadProviderDirectory({ + runtimeId: options.runtimeId, + projectPath: options.projectPath ?? null, + query: query.trim() || null, + filter, + limit: 50, + cursor, + refresh: refreshDirectoryData, + }); + if (directoryRequestSeq.current !== requestSeq) { + return; + } + if (response.error) { + setDirectoryError(response.error.message); + if ( + response.error.code === 'unsupported-action' || + response.error.message.toLowerCase().includes('unknown command') + ) { + setDirectorySupported(false); + } + return; + } + const directory = response.directory; + if (!directory) { + setDirectoryError('Provider directory response was empty'); + return; + } + setDirectoryLoaded(true); + setDirectoryTotalCount(directory.totalCount); + setDirectoryNextCursor(directory.nextCursor); + setDirectoryEntries((current) => + append ? [...current, ...directory.entries] : directory.entries + ); + } catch (loadError) { + if (directoryRequestSeq.current === requestSeq) { + setDirectoryError( + loadError instanceof Error ? loadError.message : 'Failed to load provider directory' + ); + } + } finally { + if (directoryRequestSeq.current === requestSeq) { + setDirectoryLoading(false); + setDirectoryRefreshing(false); + } + } + }, + [ + directoryFilter, + directoryNextCursor, + directoryQuery, + directorySupported, + options.enabled, + options.projectPath, + options.runtimeId, + ] + ); + useEffect(() => { if (!options.enabled) { setProviderQuery(''); + setDirectoryOpen(false); + setDirectoryLoading(false); + setDirectoryRefreshing(false); + setDirectoryError(null); + setDirectoryEntries([]); + setDirectoryTotalCount(null); + setDirectoryNextCursor(null); + setDirectoryQuery(''); + setDirectoryFilterState('all'); + setDirectoryLoaded(false); + setDirectorySelectedProviderId(null); setApiKeyValue(''); setActiveFormProviderId(null); const reset = resetModelState(); @@ -216,6 +356,34 @@ export function useRuntimeProviderManagement( void refresh(); }, [options.enabled, refresh]); + useEffect(() => { + if (!options.enabled || !directoryOpen || !directorySupported) { + return; + } + + const timeout = window.setTimeout( + () => { + void loadDirectoryPage({ + append: false, + query: directoryQuery, + filter: directoryFilter, + cursor: null, + }); + }, + directoryLoaded ? 250 : 0 + ); + + return () => window.clearTimeout(timeout); + }, [ + directoryFilter, + directoryLoaded, + directoryOpen, + directoryQuery, + directorySupported, + loadDirectoryPage, + options.enabled, + ]); + useEffect(() => { if (!options.enabled || !modelPickerProviderId) { return; @@ -281,13 +449,17 @@ export function useRuntimeProviderManagement( const selectedProvider = view?.providers.find( (provider) => provider.providerId === selectedProviderId ); + const selectedDirectoryProvider = directoryEntries.find( + (provider) => provider.providerId === selectedProviderId + ); if ( - selectedProvider && - selectedProvider.state === 'connected' && - selectedProvider.modelCount > 0 + (selectedProvider?.state === 'connected' && selectedProvider.modelCount > 0) || + (selectedDirectoryProvider?.state === 'connected' && + selectedDirectoryProvider.modelCount !== 0) ) { - if (modelPickerProviderId !== selectedProvider.providerId) { - setModelPickerProviderId(selectedProvider.providerId); + const providerId = selectedProvider?.providerId ?? selectedDirectoryProvider!.providerId; + if (modelPickerProviderId !== providerId) { + setModelPickerProviderId(providerId); setModelPickerMode('use'); setModelQuery(''); setModels([]); @@ -306,7 +478,85 @@ export function useRuntimeProviderManagement( setSelectedModelId(null); setModelResults({}); } - }, [activeFormProviderId, modelPickerProviderId, options.enabled, selectedProviderId, view]); + }, [ + activeFormProviderId, + directoryEntries, + modelPickerProviderId, + options.enabled, + selectedProviderId, + view, + ]); + + const openDirectory = useCallback((): void => { + if (!directorySupported) { + return; + } + setDirectoryOpen(true); + setDirectoryError(null); + }, [directorySupported]); + + const closeDirectory = useCallback((): void => { + setDirectoryOpen(false); + setDirectorySelectedProviderId(null); + }, []); + + const setDirectoryFilter = useCallback((value: RuntimeProviderDirectoryFilterDto): void => { + setDirectoryFilterState(value); + setDirectoryNextCursor(null); + }, []); + + const loadMoreDirectory = useCallback(async (): Promise => { + if (!directoryNextCursor || directoryLoading || directoryRefreshing) { + return; + } + await loadDirectoryPage({ + append: true, + cursor: directoryNextCursor, + }); + }, [directoryLoading, directoryNextCursor, directoryRefreshing, loadDirectoryPage]); + + const refreshDirectory = useCallback(async (): Promise => { + await loadDirectoryPage({ + refresh: true, + cursor: null, + }); + }, [loadDirectoryPage]); + + const selectDirectoryProvider = useCallback( + (providerId: string): void => { + setDirectorySelectedProviderId(providerId); + setSelectedProviderId(providerId); + setActiveFormProviderId(null); + + const compactProvider = view?.providers.find( + (provider) => provider.providerId === providerId + ); + const directoryProvider = directoryEntries.find( + (provider) => provider.providerId === providerId + ); + const connected = + compactProvider?.state === 'connected' || directoryProvider?.state === 'connected'; + const modelCount = compactProvider?.modelCount ?? directoryProvider?.modelCount ?? null; + + if (connected && modelCount !== 0) { + setModelPickerProviderId(providerId); + setModelPickerMode('use'); + setModelQuery(''); + setModels([]); + setModelsError(null); + setSelectedModelId(null); + setModelResults({}); + } + }, + [directoryEntries, view] + ); + + const searchAllProviders = useCallback((query: string): void => { + setDirectoryQuery(query); + setDirectoryOpen(true); + setDirectoryError(null); + setDirectoryNextCursor(null); + }, []); const startConnect = useCallback((providerId: string): void => { setSelectedProviderId(providerId); @@ -358,6 +608,12 @@ export function useRuntimeProviderManagement( setApiKeyValue(''); void Promise.resolve(options.onProviderChanged?.()) .then(() => refresh()) + .then(() => { + if (directoryOpen) { + return loadDirectoryPage({ refresh: true, cursor: null }); + } + return undefined; + }) .catch((refreshError) => { setError( refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers' @@ -372,7 +628,7 @@ export function useRuntimeProviderManagement( setSavingProviderId(null); } }, - [apiKeyValue, options, refresh] + [apiKeyValue, directoryOpen, loadDirectoryPage, options, refresh] ); const forgetProvider = useCallback( @@ -400,6 +656,12 @@ export function useRuntimeProviderManagement( setSavingProviderId(null); void Promise.resolve(options.onProviderChanged?.()) .then(() => refresh()) + .then(() => { + if (directoryOpen) { + return loadDirectoryPage({ refresh: true, cursor: null }); + } + return undefined; + }) .catch((refreshError) => { setError( refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers' @@ -413,7 +675,7 @@ export function useRuntimeProviderManagement( setSavingProviderId(null); } }, - [options, refresh] + [directoryOpen, loadDirectoryPage, options, refresh] ); const openModelPicker = useCallback( @@ -549,6 +811,18 @@ export function useRuntimeProviderManagement( providers: view?.providers ?? [], selectedProviderId, providerQuery, + directoryOpen, + directoryLoading, + directoryRefreshing, + directoryError, + directoryEntries, + directoryTotalCount, + directoryNextCursor, + directoryQuery, + directoryFilter, + directoryLoaded, + directorySelectedProviderId, + directorySupported, activeFormProviderId, apiKeyValue, modelPickerProviderId, @@ -569,6 +843,18 @@ export function useRuntimeProviderManagement( [ activeFormProviderId, apiKeyValue, + directoryEntries, + directoryError, + directoryFilter, + directoryLoaded, + directoryLoading, + directoryNextCursor, + directoryOpen, + directoryQuery, + directoryRefreshing, + directorySelectedProviderId, + directorySupported, + directoryTotalCount, error, loading, modelPickerMode, @@ -594,6 +880,14 @@ export function useRuntimeProviderManagement( refresh, selectProvider, setProviderQuery, + openDirectory, + closeDirectory, + setDirectoryQuery, + setDirectoryFilter, + loadMoreDirectory, + refreshDirectory, + selectDirectoryProvider, + searchAllProviders, startConnect, cancelConnect, setApiKeyValue, @@ -609,12 +903,19 @@ export function useRuntimeProviderManagement( }), [ cancelConnect, + closeDirectory, closeModelPicker, forgetProvider, + loadMoreDirectory, + openDirectory, openModelPicker, refresh, + refreshDirectory, + searchAllProviders, + selectDirectoryProvider, selectProvider, setDefaultModel, + setDirectoryFilter, startConnect, submitConnect, testModel, diff --git a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx index f09116ac..3b9347e2 100644 --- a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx +++ b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx @@ -12,6 +12,7 @@ import { } from '@renderer/utils/openCodeModelRecommendations'; import { AlertTriangle, + ArrowLeft, CheckCircle2, KeyRound, Loader2, @@ -36,6 +37,8 @@ import type { } from '../hooks/useRuntimeProviderManagement'; import type { RuntimeProviderConnectionDto, + RuntimeProviderDirectoryEntryDto, + RuntimeProviderDirectoryFilterDto, RuntimeProviderModelDto, RuntimeProviderModelTestResultDto, } from '@features/runtime-provider-management/contracts'; @@ -67,6 +70,49 @@ interface ProviderRowProps { readonly actions: RuntimeProviderManagementActions; } +const DIRECTORY_FILTERS: Array<{ id: RuntimeProviderDirectoryFilterDto; label: string }> = [ + { id: 'all', label: 'All' }, + { id: 'connectable', label: 'Connectable' }, + { id: 'connected', label: 'Connected' }, + { id: 'configured', label: 'Configured' }, + { id: 'manual', label: 'Manual setup' }, + { id: 'has-models', label: 'Has models' }, +]; + +function getDirectoryAction( + provider: RuntimeProviderDirectoryEntryDto, + actionId: RuntimeProviderConnectionDto['actions'][number]['id'] +) { + return provider.actions.find((action) => action.id === actionId) ?? null; +} + +function formatDirectorySetupKind(provider: RuntimeProviderDirectoryEntryDto): string { + switch (provider.setupKind) { + case 'connected': + return 'Connected'; + case 'connect-api-key': + return 'Connect'; + case 'configure-manually': + return 'Configure manually'; + case 'requires-environment': + return 'Requires environment'; + case 'available-readonly': + return 'Available'; + case 'unsupported': + return 'Unsupported'; + } +} + +function getDirectoryModelsLabel(provider: RuntimeProviderDirectoryEntryDto): string { + if (provider.modelCount === null) { + return 'models unknown'; + } + if (provider.modelCount <= 0) { + return 'models not reported'; + } + return `${provider.modelCount} model${provider.modelCount === 1 ? '' : 's'}`; +} + function stateClassName(provider: RuntimeProviderConnectionDto): string { switch (provider.state) { case 'connected': @@ -528,6 +574,242 @@ function ProviderRow({ ); } +function DirectoryProviderRow({ + provider, + active, + disabled, + busy, + actions, +}: { + readonly provider: RuntimeProviderDirectoryEntryDto; + readonly active: boolean; + readonly disabled: boolean; + readonly busy: boolean; + readonly actions: RuntimeProviderManagementActions; +}): JSX.Element { + const connect = getDirectoryAction(provider, 'connect'); + const configure = getDirectoryAction(provider, 'configure'); + const forget = getDirectoryAction(provider, 'forget'); + + return ( +
actions.selectDirectoryProvider(provider.providerId)} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + event.preventDefault(); + actions.selectDirectoryProvider(provider.providerId); + }} + > +
+
+
+ + + {provider.displayName} + + {provider.recommended ? Recommended : null} + + {formatDirectorySetupKind(provider)} + +
+
+ {getDirectoryModelsLabel(provider)} + {provider.sourceLabel ? {provider.sourceLabel} : null} + {provider.providerSource ? {provider.providerSource} : null} + {provider.ownership.map((owner) => ( + + {owner} + + ))} +
+ {provider.detail ? ( +
{provider.detail}
+ ) : null} +
+
+ {connect ? ( + + ) : null} + {forget ? ( + + ) : null} + {!connect && configure ? ( + + ) : null} +
+
+
+ ); +} + +function ProviderDirectoryPanel({ + state, + actions, + disabled, +}: { + readonly state: RuntimeProviderManagementState; + readonly actions: RuntimeProviderManagementActions; + readonly disabled: boolean; +}): JSX.Element { + return ( +
+
+ +
+ {state.directoryTotalCount === null + ? 'All OpenCode providers' + : `${state.directoryTotalCount} OpenCode providers`} +
+
+ +
+
+ + actions.setDirectoryQuery(event.target.value)} + placeholder="Search all OpenCode providers" + className="h-9 pr-3 text-sm" + style={{ paddingLeft: 40 }} + /> +
+
+ {DIRECTORY_FILTERS.map((filter) => ( + + ))} +
+
+ + {state.directoryError ? ( +
+ {state.directoryError} +
+ ) : null} + +
+ {state.directoryLoading && state.directoryEntries.length === 0 ? ( + + ) : null} + {state.directoryEntries.map((provider) => ( + + ))} +
+ + {!state.directoryLoading && state.directoryEntries.length === 0 && !state.directoryError ? ( +
+ No providers match this search. +
+ ) : null} + + {state.directoryNextCursor ? ( +
+ +
+ ) : null} +
+ ); +} + function ModelBadges({ model, usedForNewTeams, diff --git a/src/features/runtime-provider-management/renderer/ui/providerBrandIcons.tsx b/src/features/runtime-provider-management/renderer/ui/providerBrandIcons.tsx index f5b2e3cd..a82b2a94 100644 --- a/src/features/runtime-provider-management/renderer/ui/providerBrandIcons.tsx +++ b/src/features/runtime-provider-management/renderer/ui/providerBrandIcons.tsx @@ -1,8 +1,12 @@ import opencodeIconUrl from '../assets/provider-icons/opencode-favicon.png'; -import type { RuntimeProviderConnectionDto } from '@features/runtime-provider-management/contracts'; import type { CSSProperties, JSX } from 'react'; +type ProviderBrand = { + providerId: string; + displayName: string; +}; + interface SvgPath { d: string; fill?: string; @@ -393,7 +397,7 @@ function normalizeProviderKey(value: string): string { .replace(/(?:^-)|(?:-$)/g, ''); } -function getBrandIconKey(provider: RuntimeProviderConnectionDto): string | null { +function getBrandIconKey(provider: ProviderBrand): string | null { const providerId = normalizeProviderKey(provider.providerId); const displayName = normalizeProviderKey(provider.displayName); const aliasedProviderId = BRAND_ALIASES[providerId] ?? providerId; @@ -420,7 +424,7 @@ function getBrandIconKey(provider: RuntimeProviderConnectionDto): string | null return null; } -function fallbackDescriptor(provider: RuntimeProviderConnectionDto): BrandIconDescriptor { +function fallbackDescriptor(provider: ProviderBrand): BrandIconDescriptor { const displayName = provider.displayName.trim(); return { kind: 'letters', @@ -431,7 +435,7 @@ function fallbackDescriptor(provider: RuntimeProviderConnectionDto): BrandIconDe }; } -function descriptorFor(provider: RuntimeProviderConnectionDto): BrandIconDescriptor { +function descriptorFor(provider: ProviderBrand): BrandIconDescriptor { const key = getBrandIconKey(provider); return key ? (BRAND_ICONS[key] ?? LETTER_BRANDS[key] ?? fallbackDescriptor(provider)) @@ -446,11 +450,7 @@ function shellStyle(descriptor: BrandIconDescriptor): CSSProperties { }; } -export function ProviderBrandIcon({ - provider, -}: { - readonly provider: RuntimeProviderConnectionDto; -}): JSX.Element { +export function ProviderBrandIcon({ provider }: { readonly provider: ProviderBrand }): JSX.Element { const descriptor = descriptorFor(provider); return ( diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 1734304e..7e3a30d7 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -1198,6 +1198,15 @@ export class HttpAPIClient implements ElectronAPI { recoverable: true, }, }), + loadProviderDirectory: async (input) => ({ + schemaVersion: 1, + runtimeId: input.runtimeId, + error: { + code: 'runtime-unhealthy', + message: 'Runtime provider management is not available in browser mode.', + recoverable: true, + }, + }), connectWithApiKey: async (input) => ({ schemaVersion: 1, runtimeId: input.runtimeId, diff --git a/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts b/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts index 351256a4..5958f248 100644 --- a/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts +++ b/test/main/features/runtime-provider-management/AgentTeamsRuntimeProviderManagementCliClient.test.ts @@ -88,6 +88,34 @@ describe('AgentTeamsRuntimeProviderManagementCliClient', () => { ); }); + it('parses JSON error responses from failed forget commands', async () => { + const error = new Error('Command failed: /repo/cli-dev runtime providers forget'); + Object.assign(error, { + stdout: JSON.stringify({ + schemaVersion: 1, + runtimeId: 'opencode', + error: { + code: 'unsupported-action', + message: 'This OpenCode runtime does not advertise credential removal through /doc', + recoverable: true, + }, + }), + stderr: '', + }); + execCliMock.mockRejectedValue(error); + + const client = new AgentTeamsRuntimeProviderManagementCliClient(); + const response = await client.forgetCredential({ + runtimeId: 'opencode', + providerId: 'openrouter', + }); + + expect(response.error?.code).toBe('unsupported-action'); + expect(response.error?.message).toBe( + 'This OpenCode runtime does not advertise credential removal through /doc' + ); + }); + it('passes project path as cwd and CLI flag for project-aware provider management', async () => { execCliMock.mockResolvedValue({ stdout: JSON.stringify({ diff --git a/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts b/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts index ccab2832..6adb387b 100644 --- a/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts +++ b/test/main/features/runtime-provider-management/registerRuntimeProviderManagementIpc.test.ts @@ -9,6 +9,7 @@ import { import type { RuntimeProviderManagementFeatureFacade } from '../../../../src/features/runtime-provider-management/main'; import type { + RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementProviderResponse, RuntimeProviderManagementViewResponse, RuntimeProviderManagementModelsResponse, @@ -60,6 +61,23 @@ describe('registerRuntimeProviderManagementIpc', () => { detail: null, }, }; + const directoryResponse: RuntimeProviderManagementDirectoryResponse = { + schemaVersion: 1, + runtimeId: 'opencode', + directory: { + runtimeId: 'opencode', + totalCount: 0, + returnedCount: 0, + query: null, + filter: 'all', + limit: 100, + cursor: null, + nextCursor: null, + entries: [], + diagnostics: [], + fetchedAt: '2026-04-25T00:00:00.000Z', + }, + }; const forgottenResponse: RuntimeProviderManagementProviderResponse = { schemaVersion: 1, runtimeId: 'opencode', @@ -101,6 +119,7 @@ describe('registerRuntimeProviderManagementIpc', () => { }; const feature: RuntimeProviderManagementFeatureFacade = { loadView: vi.fn(() => Promise.resolve(viewResponse)), + loadProviderDirectory: vi.fn(() => Promise.resolve(directoryResponse)), connectWithApiKey: vi.fn(() => Promise.resolve(connectedResponse)), forgetCredential: vi.fn(() => Promise.resolve(forgottenResponse)), loadModels: vi.fn(() => Promise.resolve(modelsResponse)), diff --git a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts index 08f631ed..2525e887 100644 --- a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts +++ b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts @@ -53,6 +53,18 @@ function createState( providers: [], selectedProviderId: 'openrouter', providerQuery: '', + directoryOpen: false, + directoryLoading: false, + directoryRefreshing: false, + directoryError: null, + directoryEntries: [], + directoryTotalCount: null, + directoryNextCursor: null, + directoryQuery: '', + directoryFilter: 'all', + directoryLoaded: false, + directorySelectedProviderId: null, + directorySupported: true, activeFormProviderId: null, apiKeyValue: '', modelPickerProviderId: null, @@ -78,6 +90,14 @@ function createActions(): RuntimeProviderManagementActions { refresh: vi.fn(() => Promise.resolve()), selectProvider: vi.fn(), setProviderQuery: vi.fn(), + openDirectory: vi.fn(), + closeDirectory: vi.fn(), + setDirectoryQuery: vi.fn(), + setDirectoryFilter: vi.fn(), + loadMoreDirectory: vi.fn(() => Promise.resolve()), + refreshDirectory: vi.fn(() => Promise.resolve()), + selectDirectoryProvider: vi.fn(), + searchAllProviders: vi.fn(), startConnect: vi.fn(), cancelConnect: vi.fn(), setApiKeyValue: vi.fn(),