perf(runtime): speed up dashboard provider status loading
This commit is contained in:
parent
8da5b7f25d
commit
8ac0b43a2a
5 changed files with 290 additions and 27 deletions
|
|
@ -353,6 +353,26 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createPendingProviderStatus(providerId: CliProviderId): CliProviderStatus {
|
||||||
|
return {
|
||||||
|
...createDefaultProviderStatus(providerId),
|
||||||
|
statusMessage: 'Checking...',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRuntimeStatusErrorProviderStatus(
|
||||||
|
providerId: CliProviderId,
|
||||||
|
error: unknown
|
||||||
|
): CliProviderStatus {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
...createDefaultProviderStatus(providerId),
|
||||||
|
verificationState: 'error',
|
||||||
|
statusMessage: 'Provider status unavailable',
|
||||||
|
detailMessage: message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function mapRuntimeExtensionCapabilities(
|
function mapRuntimeExtensionCapabilities(
|
||||||
providerId: CliProviderId,
|
providerId: CliProviderId,
|
||||||
capabilities?: RuntimeExtensionCapabilitiesResponse
|
capabilities?: RuntimeExtensionCapabilitiesResponse
|
||||||
|
|
@ -668,6 +688,97 @@ export class ClaudeMultimodelBridgeService {
|
||||||
return providers.map((provider) => this.applyConnectionIssue(provider, connectionIssues));
|
return providers.map((provider) => this.applyConnectionIssue(provider, connectionIssues));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildProviderStatusesSnapshot(
|
||||||
|
providers: Map<CliProviderId, CliProviderStatus>
|
||||||
|
): CliProviderStatus[] {
|
||||||
|
return ORDERED_PROVIDER_IDS.map(
|
||||||
|
(providerId) => providers.get(providerId) ?? createPendingProviderStatus(providerId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getProviderStatusFromRuntimeStatusCommand(
|
||||||
|
binaryPath: string,
|
||||||
|
providerId: CliProviderId,
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
connectionIssues: Partial<Record<CliProviderId, string>>
|
||||||
|
): Promise<CliProviderStatus> {
|
||||||
|
const { stdout } = await execCli(
|
||||||
|
binaryPath,
|
||||||
|
['runtime', 'status', '--json', '--provider', providerId],
|
||||||
|
{
|
||||||
|
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
||||||
|
env,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
|
||||||
|
return providerConnectionService.enrichProviderStatus(
|
||||||
|
this.applyConnectionIssue(
|
||||||
|
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]),
|
||||||
|
connectionIssues
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getProviderStatusFromScopedRuntimeStatus(
|
||||||
|
binaryPath: string,
|
||||||
|
providerId: CliProviderId
|
||||||
|
): Promise<CliProviderStatus> {
|
||||||
|
const { env, connectionIssues } = await this.buildProviderCliEnv(binaryPath, providerId);
|
||||||
|
return this.getProviderStatusFromRuntimeStatusCommand(
|
||||||
|
binaryPath,
|
||||||
|
providerId,
|
||||||
|
env,
|
||||||
|
connectionIssues
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getProviderStatusesFromScopedRuntimeStatus(
|
||||||
|
binaryPath: string,
|
||||||
|
onUpdate?: (providers: CliProviderStatus[]) => void
|
||||||
|
): Promise<CliProviderStatus[] | null> {
|
||||||
|
const providers = new Map<CliProviderId, CliProviderStatus>(
|
||||||
|
ORDERED_PROVIDER_IDS.map((providerId) => [
|
||||||
|
providerId,
|
||||||
|
createPendingProviderStatus(providerId),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const failures: { providerId: CliProviderId; error: unknown }[] = [];
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
ORDERED_PROVIDER_IDS.map(async (providerId) => {
|
||||||
|
try {
|
||||||
|
providers.set(
|
||||||
|
providerId,
|
||||||
|
await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId)
|
||||||
|
);
|
||||||
|
onUpdate?.(this.buildProviderStatusesSnapshot(providers));
|
||||||
|
} catch (error) {
|
||||||
|
failures.push({ providerId, error });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failures.length === 0) {
|
||||||
|
return this.buildProviderStatusesSnapshot(providers);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length === ORDERED_PROVIDER_IDS.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`Provider-scoped runtime status failed for ${failures
|
||||||
|
.map(({ providerId }) => providerId)
|
||||||
|
.join(', ')}; using partial provider statuses`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const { providerId, error } of failures) {
|
||||||
|
providers.set(providerId, createRuntimeStatusErrorProviderStatus(providerId, error));
|
||||||
|
}
|
||||||
|
onUpdate?.(this.buildProviderStatusesSnapshot(providers));
|
||||||
|
return this.buildProviderStatusesSnapshot(providers);
|
||||||
|
}
|
||||||
|
|
||||||
private async getOpenCodeVerifySnapshot(
|
private async getOpenCodeVerifySnapshot(
|
||||||
binaryPath: string
|
binaryPath: string
|
||||||
): Promise<OpenCodeRuntimeVerifyResponse['snapshot'] | null> {
|
): Promise<OpenCodeRuntimeVerifyResponse['snapshot'] | null> {
|
||||||
|
|
@ -761,24 +872,9 @@ export class ClaudeMultimodelBridgeService {
|
||||||
providerId: CliProviderId
|
providerId: CliProviderId
|
||||||
): Promise<CliProviderStatus> {
|
): Promise<CliProviderStatus> {
|
||||||
await resolveInteractiveShellEnv();
|
await resolveInteractiveShellEnv();
|
||||||
const { env, connectionIssues } = await this.buildCliEnv(binaryPath);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execCli(
|
return await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId);
|
||||||
binaryPath,
|
|
||||||
['runtime', 'status', '--json', '--provider', providerId],
|
|
||||||
{
|
|
||||||
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
|
||||||
env,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
|
|
||||||
return providerConnectionService.enrichProviderStatus(
|
|
||||||
this.applyConnectionIssue(
|
|
||||||
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]),
|
|
||||||
connectionIssues
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!this.isUnifiedRuntimeUnsupported(error)) {
|
if (!this.isUnifiedRuntimeUnsupported(error)) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|
@ -937,6 +1033,20 @@ export class ClaudeMultimodelBridgeService {
|
||||||
onUpdate?: (providers: CliProviderStatus[]) => void
|
onUpdate?: (providers: CliProviderStatus[]) => void
|
||||||
): Promise<CliProviderStatus[]> {
|
): Promise<CliProviderStatus[]> {
|
||||||
await resolveInteractiveShellEnv();
|
await resolveInteractiveShellEnv();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const providers = await this.getProviderStatusesFromScopedRuntimeStatus(binaryPath, onUpdate);
|
||||||
|
if (providers) {
|
||||||
|
return providers;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`Provider-scoped runtime status unavailable, falling back to full probe: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { env, connectionIssues } = await this.buildCliEnv(binaryPath);
|
const { env, connectionIssues } = await this.buildCliEnv(binaryPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,9 @@ import { create } from 'zustand';
|
||||||
import { createChangeReviewSlice } from './slices/changeReviewSlice';
|
import { createChangeReviewSlice } from './slices/changeReviewSlice';
|
||||||
import {
|
import {
|
||||||
createCliInstallerSlice,
|
createCliInstallerSlice,
|
||||||
getIncompleteMultimodelProviderIds,
|
|
||||||
getModelOnlyFallbackProviderIds,
|
getModelOnlyFallbackProviderIds,
|
||||||
mergeCliStatusPreservingHydratedProviders,
|
mergeCliStatusPreservingHydratedProviders,
|
||||||
|
reconcileMultimodelProviderLoading,
|
||||||
} from './slices/cliInstallerSlice';
|
} from './slices/cliInstallerSlice';
|
||||||
import { createConfigSlice } from './slices/configSlice';
|
import { createConfigSlice } from './slices/configSlice';
|
||||||
import { createConnectionSlice } from './slices/connectionSlice';
|
import { createConnectionSlice } from './slices/connectionSlice';
|
||||||
|
|
@ -1485,20 +1485,14 @@ export function initializeNotificationListeners(): () => void {
|
||||||
state.cliStatus,
|
state.cliStatus,
|
||||||
progress.status!
|
progress.status!
|
||||||
);
|
);
|
||||||
const incompleteProviderIds = getIncompleteMultimodelProviderIds(nextStatus);
|
|
||||||
modelOnlyFallbackProviderIds = getModelOnlyFallbackProviderIds(nextStatus);
|
modelOnlyFallbackProviderIds = getModelOnlyFallbackProviderIds(nextStatus);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cliStatus: nextStatus,
|
cliStatus: nextStatus,
|
||||||
cliProviderStatusLoading:
|
cliProviderStatusLoading: reconcileMultimodelProviderLoading(
|
||||||
incompleteProviderIds.length > 0
|
nextStatus,
|
||||||
? {
|
state.cliProviderStatusLoading
|
||||||
...state.cliProviderStatusLoading,
|
),
|
||||||
...Object.fromEntries(
|
|
||||||
incompleteProviderIds.map((providerId) => [providerId, true])
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: state.cliProviderStatusLoading,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
for (const providerId of modelOnlyFallbackProviderIds) {
|
for (const providerId of modelOnlyFallbackProviderIds) {
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,24 @@ export function getModelOnlyFallbackProviderIds(
|
||||||
.map((provider) => provider.providerId);
|
.map((provider) => provider.providerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function reconcileMultimodelProviderLoading(
|
||||||
|
status: CliInstallationStatus | null,
|
||||||
|
currentLoading: Partial<Record<CliProviderId, boolean>>
|
||||||
|
): Partial<Record<CliProviderId, boolean>> {
|
||||||
|
if (status?.flavor !== 'agent_teams_orchestrator' || !status.installed) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const incompleteProviderIds = new Set(getIncompleteMultimodelProviderIds(status));
|
||||||
|
return status.providers.reduce<Partial<Record<CliProviderId, boolean>>>(
|
||||||
|
(nextLoading, provider) => ({
|
||||||
|
...nextLoading,
|
||||||
|
[provider.providerId]: incompleteProviderIds.has(provider.providerId),
|
||||||
|
}),
|
||||||
|
{ ...currentLoading }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function mergeCliStatusPreservingHydratedProviders(
|
export function mergeCliStatusPreservingHydratedProviders(
|
||||||
current: CliInstallationStatus | null,
|
current: CliInstallationStatus | null,
|
||||||
incoming: CliInstallationStatus
|
incoming: CliInstallationStatus
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,108 @@ describe('ClaudeMultimodelBridgeService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('loads all providers with parallel provider-scoped runtime status probes', async () => {
|
||||||
|
const providerPayloads = {
|
||||||
|
anthropic: {
|
||||||
|
supported: true,
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'oauth_token',
|
||||||
|
verificationState: 'verified',
|
||||||
|
canLoginFromUi: true,
|
||||||
|
models: ['claude-sonnet-4-5'],
|
||||||
|
capabilities: { teamLaunch: true, oneShot: true },
|
||||||
|
backend: { kind: 'anthropic', label: 'Anthropic' },
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
supported: true,
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'api_key',
|
||||||
|
verificationState: 'verified',
|
||||||
|
canLoginFromUi: false,
|
||||||
|
models: ['gpt-5-codex'],
|
||||||
|
capabilities: { teamLaunch: true, oneShot: true },
|
||||||
|
backend: { kind: 'codex-native', label: 'Codex native' },
|
||||||
|
},
|
||||||
|
gemini: {
|
||||||
|
supported: true,
|
||||||
|
authenticated: false,
|
||||||
|
verificationState: 'unknown',
|
||||||
|
canLoginFromUi: true,
|
||||||
|
statusMessage: 'No Gemini runtime backend is ready',
|
||||||
|
models: ['gemini-2.5-pro'],
|
||||||
|
capabilities: { teamLaunch: true, oneShot: true },
|
||||||
|
},
|
||||||
|
opencode: {
|
||||||
|
supported: true,
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'opencode_managed',
|
||||||
|
verificationState: 'verified',
|
||||||
|
canLoginFromUi: false,
|
||||||
|
models: ['openai/gpt-5.4-mini'],
|
||||||
|
capabilities: { teamLaunch: true, oneShot: false },
|
||||||
|
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
execCliMock.mockImplementation((_binaryPath, args) => {
|
||||||
|
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||||
|
const providerArgIndex = Array.isArray(args) ? args.indexOf('--provider') : -1;
|
||||||
|
const providerId =
|
||||||
|
providerArgIndex >= 0 && Array.isArray(args)
|
||||||
|
? (args[providerArgIndex + 1] as keyof typeof providerPayloads)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedArgs.startsWith('runtime status --json --provider ') &&
|
||||||
|
providerId &&
|
||||||
|
providerPayloads[providerId]
|
||||||
|
) {
|
||||||
|
return Promise.resolve({
|
||||||
|
stdout: JSON.stringify({
|
||||||
|
schemaVersion: 2,
|
||||||
|
providers: {
|
||||||
|
[providerId]: providerPayloads[providerId],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
const { ClaudeMultimodelBridgeService } =
|
||||||
|
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
|
||||||
|
const service = new ClaudeMultimodelBridgeService();
|
||||||
|
const onUpdate = vi.fn();
|
||||||
|
|
||||||
|
const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator', onUpdate);
|
||||||
|
|
||||||
|
expect(execCliMock).toHaveBeenCalledTimes(4);
|
||||||
|
expect(execCliMock.mock.calls.map((call) => call[1].join(' '))).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'runtime status --json --provider anthropic',
|
||||||
|
'runtime status --json --provider codex',
|
||||||
|
'runtime status --json --provider gemini',
|
||||||
|
'runtime status --json --provider opencode',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(providers.map((provider) => provider.providerId)).toEqual([
|
||||||
|
'anthropic',
|
||||||
|
'codex',
|
||||||
|
'gemini',
|
||||||
|
'opencode',
|
||||||
|
]);
|
||||||
|
expect(providers.find((provider) => provider.providerId === 'codex')).toMatchObject({
|
||||||
|
authenticated: true,
|
||||||
|
models: ['gpt-5-codex'],
|
||||||
|
backend: { kind: 'codex-native' },
|
||||||
|
});
|
||||||
|
expect(onUpdate).toHaveBeenCalled();
|
||||||
|
expect(onUpdate.mock.calls.at(-1)?.[0]).toEqual(providers);
|
||||||
|
});
|
||||||
|
|
||||||
it('overrides provider auth status when provider-aware env reports a missing API key', async () => {
|
it('overrides provider auth status when provider-aware env reports a missing API key', async () => {
|
||||||
buildProviderAwareCliEnvMock.mockResolvedValue({
|
buildProviderAwareCliEnvMock.mockResolvedValue({
|
||||||
env: { HOME: '/Users/tester' },
|
env: { HOME: '/Users/tester' },
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ import {
|
||||||
getIncompleteMultimodelProviderIds,
|
getIncompleteMultimodelProviderIds,
|
||||||
getModelOnlyFallbackProviderIds,
|
getModelOnlyFallbackProviderIds,
|
||||||
mergeCliStatusPreservingHydratedProviders,
|
mergeCliStatusPreservingHydratedProviders,
|
||||||
|
reconcileMultimodelProviderLoading,
|
||||||
} from '@renderer/store/slices/cliInstallerSlice';
|
} from '@renderer/store/slices/cliInstallerSlice';
|
||||||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||||
|
|
||||||
|
|
@ -251,6 +252,44 @@ describe('cliInstallerSlice', () => {
|
||||||
expect(getModelOnlyFallbackProviderIds(status)).toEqual([]);
|
expect(getModelOnlyFallbackProviderIds(status)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears loading for hydrated providers while keeping pending providers marked', () => {
|
||||||
|
const status = createMultimodelStatus([
|
||||||
|
createMultimodelProvider({
|
||||||
|
providerId: 'anthropic',
|
||||||
|
displayName: 'Anthropic',
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'oauth_token',
|
||||||
|
statusMessage: null,
|
||||||
|
models: ['claude-sonnet-4-5'],
|
||||||
|
backend: { kind: 'anthropic', label: 'Anthropic' },
|
||||||
|
}),
|
||||||
|
createMultimodelProvider({
|
||||||
|
providerId: 'codex',
|
||||||
|
displayName: 'Codex',
|
||||||
|
supported: false,
|
||||||
|
authenticated: false,
|
||||||
|
authMethod: null,
|
||||||
|
verificationState: 'unknown',
|
||||||
|
statusMessage: 'Checking...',
|
||||||
|
models: [],
|
||||||
|
backend: null,
|
||||||
|
availableBackends: [],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
reconcileMultimodelProviderLoading(status, {
|
||||||
|
anthropic: true,
|
||||||
|
codex: true,
|
||||||
|
opencode: true,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
anthropic: false,
|
||||||
|
codex: true,
|
||||||
|
opencode: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('still allows real OpenCode runtime errors to replace previous ready status', () => {
|
it('still allows real OpenCode runtime errors to replace previous ready status', () => {
|
||||||
const current = createMultimodelStatus([
|
const current = createMultimodelStatus([
|
||||||
createMultimodelProvider({
|
createMultimodelProvider({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue