feat(runtime-provider-management): add provider directory bridge

This commit is contained in:
777genius 2026-04-25 19:20:59 +03:00
parent 825cfc00d1
commit 7fb5c8cf85
15 changed files with 865 additions and 18 deletions

View file

@ -1,6 +1,8 @@
import type {
RuntimeProviderManagementConnectApiKeyInput,
RuntimeProviderManagementDirectoryResponse,
RuntimeProviderManagementForgetInput,
RuntimeProviderManagementLoadDirectoryInput,
RuntimeProviderManagementLoadViewInput,
RuntimeProviderManagementLoadModelsInput,
RuntimeProviderManagementModelTestResponse,
@ -15,6 +17,9 @@ export interface RuntimeProviderManagementApi {
loadView(
input: RuntimeProviderManagementLoadViewInput
): Promise<RuntimeProviderManagementViewResponse>;
loadProviderDirectory(
input: RuntimeProviderManagementLoadDirectoryInput
): Promise<RuntimeProviderManagementDirectoryResponse>;
connectWithApiKey(
input: RuntimeProviderManagementConnectApiKeyInput
): Promise<RuntimeProviderManagementProviderResponse>;

View file

@ -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';

View file

@ -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;

View file

@ -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<RuntimeProviderManagementDirectoryResponse> {
return port.loadProviderDirectory(input);
}
export function connectRuntimeProviderWithApiKey(
port: RuntimeProviderManagementPort,
input: RuntimeProviderManagementConnectApiKeyInput

View file

@ -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<RuntimeProviderManagementDirectoryResponse> => {
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);

View file

@ -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<RuntimeProviderManagementViewResponse> => port.loadView(input),
loadProviderDirectory: (
input: RuntimeProviderManagementLoadDirectoryInput
): Promise<RuntimeProviderManagementDirectoryResponse> => port.loadProviderDirectory(input),
connectWithApiKey: (
input: RuntimeProviderManagementConnectApiKeyInput
): Promise<RuntimeProviderManagementProviderResponse> => port.connectWithApiKey(input),

View file

@ -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<T extends { env: NodeJS.ProcessEnv }>(
options: T,
projectPath: string | null
@ -233,6 +243,51 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv
}
}
async loadProviderDirectory(
input: RuntimeProviderManagementLoadDirectoryInput
): Promise<RuntimeProviderManagementDirectoryResponse> {
const { binaryPath, env } = await resolveCliEnv();
if (!binaryPath) {
return errorResponse<RuntimeProviderManagementDirectoryResponse>(
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<RuntimeProviderManagementDirectoryResponse>(stdout);
} catch (error) {
const response =
extractJsonObjectFromError<RuntimeProviderManagementDirectoryResponse>(error);
if (response) {
return response;
}
return errorResponse<RuntimeProviderManagementDirectoryResponse>(
input.runtimeId,
normalizeCommandFailure(error)
);
}
}
async connectWithApiKey(
input: RuntimeProviderManagementConnectApiKeyInput
): Promise<RuntimeProviderManagementProviderResponse> {
@ -329,6 +384,10 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv
);
return extractJsonObject<RuntimeProviderManagementProviderResponse>(stdout);
} catch (error) {
const response = extractJsonObjectFromError<RuntimeProviderManagementProviderResponse>(error);
if (response) {
return response;
}
return errorResponse<RuntimeProviderManagementProviderResponse>(
input.runtimeId,
normalizeCommandFailure(error)

View file

@ -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<RuntimeProviderManagementViewResponse> =>
ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_VIEW, input),
loadProviderDirectory: (
input: RuntimeProviderManagementLoadDirectoryInput
): Promise<RuntimeProviderManagementDirectoryResponse> =>
ipcRenderer.invoke(RUNTIME_PROVIDER_MANAGEMENT_DIRECTORY, input),
connectWithApiKey: (
input: RuntimeProviderManagementConnectApiKeyInput
): Promise<RuntimeProviderManagementProviderResponse> =>

View file

@ -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<void>;
selectProvider: (providerId: string) => void;
setProviderQuery: (value: string) => void;
openDirectory: () => void;
closeDirectory: () => void;
setDirectoryQuery: (value: string) => void;
setDirectoryFilter: (value: RuntimeProviderDirectoryFilterDto) => void;
loadMoreDirectory: () => Promise<void>;
refreshDirectory: () => Promise<void>;
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<RuntimeProviderManagementViewDto | null>(null);
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null);
const [providerQuery, setProviderQuery] = useState('');
const [directoryOpen, setDirectoryOpen] = useState(false);
const [directoryLoading, setDirectoryLoading] = useState(false);
const [directoryRefreshing, setDirectoryRefreshing] = useState(false);
const [directoryError, setDirectoryError] = useState<string | null>(null);
const [directoryEntries, setDirectoryEntries] = useState<
readonly RuntimeProviderDirectoryEntryDto[]
>([]);
const [directoryTotalCount, setDirectoryTotalCount] = useState<number | null>(null);
const [directoryNextCursor, setDirectoryNextCursor] = useState<string | null>(null);
const [directoryQuery, setDirectoryQuery] = useState('');
const [directoryFilter, setDirectoryFilterState] =
useState<RuntimeProviderDirectoryFilterDto>('all');
const [directoryLoaded, setDirectoryLoaded] = useState(false);
const [directorySelectedProviderId, setDirectorySelectedProviderId] = useState<string | null>(
null
);
const [directorySupported, setDirectorySupported] = useState(true);
const [activeFormProviderId, setActiveFormProviderId] = useState<string | null>(null);
const [apiKeyValue, setApiKeyValue] = useState('');
const [modelPickerProviderId, setModelPickerProviderId] = useState<string | null>(null);
@ -166,6 +205,7 @@ export function useRuntimeProviderManagement(
const [savingProviderId, setSavingProviderId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const directoryRequestSeq = useRef(0);
const refresh = useCallback(async (): Promise<void> => {
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<void> => {
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<void> => {
if (!directoryNextCursor || directoryLoading || directoryRefreshing) {
return;
}
await loadDirectoryPage({
append: true,
cursor: directoryNextCursor,
});
}, [directoryLoading, directoryNextCursor, directoryRefreshing, loadDirectoryPage]);
const refreshDirectory = useCallback(async (): Promise<void> => {
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,

View file

@ -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 (
<div
role="button"
tabIndex={disabled ? -1 : 0}
data-testid={`runtime-provider-directory-row-${provider.providerId}`}
className={`cursor-pointer rounded-lg border p-3 transition-all hover:border-sky-300/60 hover:bg-sky-400/[0.08] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400/40 ${
active
? 'border-sky-300/70 bg-sky-400/[0.075] shadow-[0_0_0_1px_rgba(125,211,252,0.22)]'
: 'border-[var(--color-border-subtle)] bg-white/[0.02]'
}`}
onClick={() => actions.selectDirectoryProvider(provider.providerId)}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
actions.selectDirectoryProvider(provider.providerId);
}}
>
<div className="grid grid-cols-[1fr_auto] gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<ProviderBrandIcon provider={provider} />
<span className="text-sm font-medium text-[var(--color-text)]">
{provider.displayName}
</span>
{provider.recommended ? <Badge variant="secondary">Recommended</Badge> : null}
<span
className={`rounded-md border px-2 py-0.5 text-[11px] ${
provider.state === 'connected'
? 'border-emerald-400/35 bg-emerald-400/10 text-emerald-200'
: 'border-white/10 bg-white/[0.04] text-[var(--color-text-muted)]'
}`}
>
{formatDirectorySetupKind(provider)}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--color-text-secondary)]">
<span>{getDirectoryModelsLabel(provider)}</span>
{provider.sourceLabel ? <span>{provider.sourceLabel}</span> : null}
{provider.providerSource ? <span>{provider.providerSource}</span> : null}
{provider.ownership.map((owner) => (
<Badge
key={owner}
variant="outline"
className="border-white/10 px-1.5 py-0 text-[10px]"
>
{owner}
</Badge>
))}
</div>
{provider.detail ? (
<div className="mt-1 text-xs text-[var(--color-text-muted)]">{provider.detail}</div>
) : null}
</div>
<div className="flex shrink-0 items-start justify-end gap-1.5">
{connect ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled || busy || !connect.enabled}
title={connect.disabledReason ?? undefined}
onClick={(event) => {
event.stopPropagation();
actions.startConnect(provider.providerId);
}}
>
{busy ? (
<Loader2 className="mr-1 size-3.5 animate-spin" />
) : (
<KeyRound className="mr-1 size-3.5" />
)}
{connect.label}
</Button>
) : null}
{forget ? (
<Button
type="button"
size="sm"
variant="ghost"
disabled={disabled || busy || !forget.enabled}
title={forget.disabledReason ?? undefined}
onClick={(event) => {
event.stopPropagation();
void actions.forgetProvider(provider.providerId);
}}
>
{busy ? (
<Loader2 className="mr-1 size-3.5 animate-spin" />
) : (
<Trash2 className="mr-1 size-3.5" />
)}
{forget.label}
</Button>
) : null}
{!connect && configure ? (
<Button
type="button"
size="sm"
variant="outline"
disabled
title={configure.disabledReason ?? undefined}
onClick={(event) => event.stopPropagation()}
>
{configure.label}
</Button>
) : null}
</div>
</div>
</div>
);
}
function ProviderDirectoryPanel({
state,
actions,
disabled,
}: {
readonly state: RuntimeProviderManagementState;
readonly actions: RuntimeProviderManagementActions;
readonly disabled: boolean;
}): JSX.Element {
return (
<div
className="rounded-lg border p-3"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255,255,255,0.018)',
}}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<Button type="button" size="sm" variant="ghost" onClick={actions.closeDirectory}>
<ArrowLeft className="mr-1 size-3.5" />
Providers
</Button>
<div className="text-xs text-[var(--color-text-muted)]">
{state.directoryTotalCount === null
? 'All OpenCode providers'
: `${state.directoryTotalCount} OpenCode providers`}
</div>
</div>
<div className="mt-3 space-y-2">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-text-muted)]" />
<Input
data-testid="runtime-provider-directory-search"
value={state.directoryQuery}
disabled={disabled || state.directoryLoading}
onChange={(event) => actions.setDirectoryQuery(event.target.value)}
placeholder="Search all OpenCode providers"
className="h-9 pr-3 text-sm"
style={{ paddingLeft: 40 }}
/>
</div>
<div className="flex flex-wrap gap-1.5">
{DIRECTORY_FILTERS.map((filter) => (
<Button
key={filter.id}
type="button"
size="sm"
variant={state.directoryFilter === filter.id ? 'default' : 'outline'}
className="h-7 px-2 text-xs"
disabled={disabled || state.directoryLoading}
onClick={() => actions.setDirectoryFilter(filter.id)}
>
{filter.label}
</Button>
))}
</div>
</div>
{state.directoryError ? (
<div className="mt-3 rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
{state.directoryError}
</div>
) : null}
<div className="mt-3 max-h-[48vh] space-y-2 overflow-y-auto pr-1">
{state.directoryLoading && state.directoryEntries.length === 0 ? (
<RuntimeProviderLoadingPlaceholder />
) : null}
{state.directoryEntries.map((provider) => (
<DirectoryProviderRow
key={provider.providerId}
provider={provider}
active={state.directorySelectedProviderId === provider.providerId}
disabled={disabled || state.directoryLoading}
busy={state.savingProviderId === provider.providerId}
actions={actions}
/>
))}
</div>
{!state.directoryLoading && state.directoryEntries.length === 0 && !state.directoryError ? (
<div className="mt-3 rounded-md border border-white/10 px-3 py-3 text-sm text-[var(--color-text-muted)]">
No providers match this search.
</div>
) : null}
{state.directoryNextCursor ? (
<div className="mt-3 flex justify-center">
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled || state.directoryRefreshing}
onClick={() => void actions.loadMoreDirectory()}
>
{state.directoryRefreshing ? <Loader2 className="mr-1 size-3.5 animate-spin" /> : null}
Load more
</Button>
</div>
) : null}
</div>
);
}
function ModelBadges({
model,
usedForNewTeams,

View file

@ -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 (

View file

@ -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,

View file

@ -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({

View file

@ -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)),

View file

@ -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(),