Merge pull request #179 from 777genius/fix/windows-provider-status-fallback

fix: add Windows provider status fallback
This commit is contained in:
Илия 2026-05-27 22:02:54 +03:00 committed by GitHub
commit bb18979d60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 455 additions and 62 deletions

View file

@ -25,6 +25,8 @@ const logger = createLogger('ClaudeMultimodelBridgeService');
const PROVIDER_STATUS_TIMEOUT_MS = 90_000;
const PROVIDER_STATUS_SUMMARY_TIMEOUT_MS = 30_000;
const LEGACY_FALLBACK_PROVIDER_STATUS_SUMMARY_TIMEOUT_MS = 5_000;
const LEGACY_PROVIDER_AUTH_TIMEOUT_MS = 15_000;
const PROVIDER_MODELS_TIMEOUT_MS = 25_000;
const PROVIDER_STATUS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
const PROVIDER_MODELS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
@ -112,34 +114,35 @@ interface RuntimeProviderModelCatalogResponse {
};
}
interface ProviderStatusPayloadResponse {
supported?: boolean;
authenticated?: boolean;
authMethod?: string | null;
verificationState?: 'verified' | 'unknown' | 'offline' | 'error';
canLoginFromUi?: boolean;
statusMessage?: string | null;
detailMessage?: string | null;
capabilities?: {
teamLaunch?: boolean;
oneShot?: boolean;
extensions?: RuntimeExtensionCapabilitiesResponse;
};
backend?: {
kind?: string;
label?: string;
endpointLabel?: string | null;
projectId?: string | null;
authMethodDetail?: string | null;
} | null;
runtimeCapabilities?: RuntimeProviderCapabilitiesResponse;
subscriptionRateLimits?: RuntimeSubscriptionRateLimitSnapshotResponse | null;
}
interface ProviderStatusCommandResponse {
schemaVersion?: number;
providers?: Record<
string,
{
supported?: boolean;
authenticated?: boolean;
authMethod?: string | null;
verificationState?: 'verified' | 'unknown' | 'offline' | 'error';
canLoginFromUi?: boolean;
statusMessage?: string | null;
detailMessage?: string | null;
capabilities?: {
teamLaunch?: boolean;
oneShot?: boolean;
extensions?: RuntimeExtensionCapabilitiesResponse;
};
backend?: {
kind?: string;
label?: string;
endpointLabel?: string | null;
projectId?: string | null;
authMethodDetail?: string | null;
} | null;
runtimeCapabilities?: RuntimeProviderCapabilitiesResponse;
subscriptionRateLimits?: RuntimeSubscriptionRateLimitSnapshotResponse | null;
}
>;
provider?: string;
status?: ProviderStatusPayloadResponse;
providers?: Record<string, ProviderStatusPayloadResponse>;
}
interface ProviderModelsCommandResponse {
@ -879,6 +882,170 @@ export class ClaudeMultimodelBridgeService {
return lower.includes('timed out') || lower.includes('timeout');
}
private shouldUseLegacyProviderTimeoutFallback(providerId: CliProviderId): boolean {
return providerId === 'anthropic' || providerId === 'codex';
}
private getProviderStatusRuntimeTimeout(
providerId: CliProviderId,
options: { summary?: boolean; timeoutMs?: number }
): number {
if (options.summary && this.shouldUseLegacyProviderTimeoutFallback(providerId)) {
return Math.min(
options.timeoutMs ?? PROVIDER_STATUS_SUMMARY_TIMEOUT_MS,
LEGACY_FALLBACK_PROVIDER_STATUS_SUMMARY_TIMEOUT_MS
);
}
return (
options.timeoutMs ??
(options.summary ? PROVIDER_STATUS_SUMMARY_TIMEOUT_MS : PROVIDER_STATUS_TIMEOUT_MS)
);
}
private getLegacyProviderStatusPayload(
providerId: CliProviderId,
parsed: ProviderStatusCommandResponse
): ProviderStatusPayloadResponse | undefined {
if (parsed.providers?.[providerId]) {
return parsed.providers[providerId];
}
return parsed.provider === providerId ? parsed.status : undefined;
}
private mergeLegacyProviderStatusPayload(
provider: CliProviderStatus,
runtimeStatus: ProviderStatusPayloadResponse | undefined
): CliProviderStatus {
if (!runtimeStatus) {
return provider;
}
return {
...provider,
supported: runtimeStatus.supported === true,
authenticated: runtimeStatus.authenticated === true,
authMethod: runtimeStatus.authMethod ?? null,
verificationState: runtimeStatus.verificationState ?? 'unknown',
statusMessage: runtimeStatus.statusMessage ?? null,
detailMessage: runtimeStatus.detailMessage ?? null,
canLoginFromUi: runtimeStatus.canLoginFromUi !== false,
capabilities: {
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
oneShot: runtimeStatus.capabilities?.oneShot === true,
extensions: mapRuntimeExtensionCapabilities(
provider.providerId,
runtimeStatus.capabilities?.extensions
),
},
backend: runtimeStatus.backend?.kind
? {
kind: runtimeStatus.backend.kind,
label: runtimeStatus.backend.label ?? runtimeStatus.backend.kind,
endpointLabel: runtimeStatus.backend.endpointLabel ?? null,
projectId: runtimeStatus.backend.projectId ?? null,
authMethodDetail: runtimeStatus.backend.authMethodDetail ?? null,
}
: null,
};
}
private async getProviderStatusFromLegacyProbes(
binaryPath: string,
providerId: CliProviderId
): Promise<CliProviderStatus> {
const { env, connectionIssues } = await this.buildProviderCliEnv(binaryPath, providerId);
let provider = createDefaultProviderStatus(providerId);
let fulfilledProbeCount = 0;
const authStatusPromise =
providerId === 'anthropic' || providerId === 'codex'
? execCli(binaryPath, ['auth', 'status', '--json', '--provider', providerId], {
timeout: LEGACY_PROVIDER_AUTH_TIMEOUT_MS,
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
env,
})
: Promise.resolve(null);
const modelListPromise = execCli(
binaryPath,
['model', 'list', '--json', '--provider', providerId],
{
timeout: PROVIDER_MODELS_TIMEOUT_MS,
maxBuffer: PROVIDER_MODELS_MAX_BUFFER_BYTES,
env,
}
);
const [authStatusResult, modelListResult] = await Promise.allSettled([
authStatusPromise,
modelListPromise,
]);
if (authStatusResult.status === 'fulfilled' && authStatusResult.value) {
const parsed = extractJsonObject<ProviderStatusCommandResponse>(
authStatusResult.value.stdout
);
provider = this.mergeLegacyProviderStatusPayload(
provider,
this.getLegacyProviderStatusPayload(providerId, parsed)
);
fulfilledProbeCount += 1;
} else if (authStatusResult.status === 'rejected') {
logger.warn(
`Legacy provider auth status unavailable for ${providerId}: ${
authStatusResult.reason instanceof Error
? authStatusResult.reason.message
: String(authStatusResult.reason)
}`
);
}
if (modelListResult.status === 'fulfilled') {
const parsed = extractJsonObject<ProviderModelsCommandResponse>(modelListResult.value.stdout);
const runtimeModels = extractModelIds(parsed.providers?.[providerId]?.models);
if (runtimeModels.length > 0) {
provider = {
...provider,
models: runtimeModels,
};
}
fulfilledProbeCount += 1;
} else {
logger.warn(
`Legacy provider models unavailable for ${providerId}: ${
modelListResult.reason instanceof Error
? modelListResult.reason.message
: String(modelListResult.reason)
}`
);
}
if (fulfilledProbeCount === 0) {
throw new Error(`Legacy provider probes unavailable for ${providerId}`);
}
return providerConnectionService.enrichProviderStatus(
this.applyConnectionIssue(provider, connectionIssues)
);
}
private async getProviderStatusFromLegacyProbesOrError(
binaryPath: string,
providerId: CliProviderId,
originalError: unknown
): Promise<CliProviderStatus> {
try {
return await this.getProviderStatusFromLegacyProbes(binaryPath, providerId);
} catch (fallbackError) {
logger.warn(
`Legacy provider probes unavailable for ${providerId}: ${
fallbackError instanceof Error ? fallbackError.message : String(fallbackError)
}`
);
return createRuntimeStatusErrorProviderStatus(providerId, originalError);
}
}
private mapRuntimeProviderStatus(
providerId: CliProviderId,
runtimeStatus: NonNullable<UnifiedRuntimeStatusResponse['providers']>[string] | undefined
@ -1024,9 +1191,7 @@ export class ClaudeMultimodelBridgeService {
if (options.summary) {
args.push('--summary');
}
const timeout =
options.timeoutMs ??
(options.summary ? PROVIDER_STATUS_SUMMARY_TIMEOUT_MS : PROVIDER_STATUS_TIMEOUT_MS);
const timeout = this.getProviderStatusRuntimeTimeout(providerId, options);
const { stdout } = await execCli(binaryPath, args, {
timeout,
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
@ -1081,6 +1246,7 @@ export class ClaudeMultimodelBridgeService {
}
})
);
failures.sort((a, b) => providerIds.indexOf(a.providerId) - providerIds.indexOf(b.providerId));
if (failures.length === 0) {
return this.buildProviderStatusesSnapshot(providers, providerIds);
@ -1091,10 +1257,18 @@ export class ClaudeMultimodelBridgeService {
logger.warn(
`Provider-scoped runtime status timed out for ${failures
.map(({ providerId }) => providerId)
.join(', ')}; using error provider statuses without slower fallback probes`
.join(', ')}; falling back to scoped legacy provider probes`
);
for (const { providerId, error } of failures) {
providers.set(providerId, createRuntimeStatusErrorProviderStatus(providerId, error));
const fallbackProviders = await Promise.all(
failures.map(async ({ providerId, error }) => ({
providerId,
provider: this.shouldUseLegacyProviderTimeoutFallback(providerId)
? await this.getProviderStatusFromLegacyProbesOrError(binaryPath, providerId, error)
: createRuntimeStatusErrorProviderStatus(providerId, error),
}))
);
for (const { providerId, provider } of fallbackProviders) {
providers.set(providerId, provider);
}
onUpdate?.(this.buildProviderStatusesSnapshot(providers, providerIds));
return this.buildProviderStatusesSnapshot(providers, providerIds);
@ -1109,8 +1283,18 @@ export class ClaudeMultimodelBridgeService {
.join(', ')}; using partial provider statuses`
);
for (const { providerId, error } of failures) {
providers.set(providerId, createRuntimeStatusErrorProviderStatus(providerId, error));
const fallbackProviders = await Promise.all(
failures.map(async ({ providerId, error }) => ({
providerId,
provider:
this.isRuntimeStatusTimeoutError(error) &&
this.shouldUseLegacyProviderTimeoutFallback(providerId)
? await this.getProviderStatusFromLegacyProbesOrError(binaryPath, providerId, error)
: createRuntimeStatusErrorProviderStatus(providerId, error),
}))
);
for (const { providerId, provider } of fallbackProviders) {
providers.set(providerId, provider);
}
onUpdate?.(this.buildProviderStatusesSnapshot(providers, providerIds));
return this.buildProviderStatusesSnapshot(providers, providerIds);
@ -1322,6 +1506,17 @@ export class ClaudeMultimodelBridgeService {
try {
return await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId);
} catch (fullError) {
if (
this.isRuntimeStatusTimeoutError(fullError) &&
this.shouldUseLegacyProviderTimeoutFallback(providerId)
) {
logger.warn(
`Provider-scoped full runtime status timed out for ${providerId}, falling back to scoped legacy probes: ${
fullError instanceof Error ? fullError.message : String(fullError)
}`
);
return this.getProviderStatusFromLegacyProbesOrError(binaryPath, providerId, fullError);
}
logger.warn(
`Provider-scoped full runtime status unavailable for ${providerId}, returning scoped error: ${
fullError instanceof Error ? fullError.message : String(fullError)
@ -1332,10 +1527,21 @@ export class ClaudeMultimodelBridgeService {
}
logger.warn(
`Provider-scoped summary runtime status unavailable for ${providerId}, returning scoped error: ${
`Provider-scoped summary runtime status unavailable for ${providerId}: ${
error instanceof Error ? error.message : String(error)
}`
);
if (
this.isRuntimeStatusTimeoutError(error) &&
this.shouldUseLegacyProviderTimeoutFallback(providerId)
) {
logger.warn(
`Provider-scoped summary runtime status timed out for ${providerId}, falling back to scoped legacy probes: ${
error instanceof Error ? error.message : String(error)
}`
);
return this.getProviderStatusFromLegacyProbesOrError(binaryPath, providerId, error);
}
return createRuntimeStatusErrorProviderStatus(providerId, error);
}
}

View file

@ -281,7 +281,7 @@ describe('ClaudeMultimodelBridgeService', () => {
},
} as const;
execCliMock.mockImplementation((_binaryPath, args) => {
execCliMock.mockImplementation((_binaryPath, args, _options) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
const providerArgIndex = Array.isArray(args) ? args.indexOf('--provider') : -1;
const providerId =
@ -344,16 +344,50 @@ describe('ClaudeMultimodelBridgeService', () => {
expect(calls).not.toContain('model list --json --provider all');
});
it('returns a scoped provider error when single-provider summary status times out', async () => {
execCliMock.mockImplementation((_binaryPath, args) => {
it('falls back to scoped legacy provider probes when single-provider summary status times out', async () => {
execCliMock.mockImplementation((_binaryPath, args, options) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
if (normalizedArgs === 'runtime status --json --provider codex --summary') {
return Promise.reject(
new Error(
'Command timed out after 30000ms: /mock/agent_teams_orchestrator runtime status --json --provider codex --summary'
`Command timed out after ${options?.timeout}ms: /mock/agent_teams_orchestrator runtime status --json --provider codex --summary`
)
);
}
if (normalizedArgs === 'auth status --json --provider codex') {
return Promise.resolve({
stdout: JSON.stringify({
schemaVersion: 1,
provider: 'codex',
status: {
supported: true,
authenticated: false,
authMethod: null,
verificationState: 'unknown',
canLoginFromUi: false,
statusMessage: 'Codex native runtime unavailable',
capabilities: {
teamLaunch: true,
oneShot: true,
},
},
}),
stderr: '',
});
}
if (normalizedArgs === 'model list --json --provider codex') {
return Promise.resolve({
stdout: JSON.stringify({
schemaVersion: 1,
providers: {
codex: {
models: [{ id: 'gpt-5.4', label: 'GPT-5.4' }],
},
},
}),
stderr: '',
});
}
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
});
@ -367,16 +401,23 @@ describe('ClaudeMultimodelBridgeService', () => {
expect(provider).toMatchObject({
providerId: 'codex',
verificationState: 'error',
statusMessage: 'Provider status unavailable',
supported: true,
authenticated: false,
verificationState: 'unknown',
statusMessage: 'Codex native runtime unavailable',
models: ['gpt-5.4'],
});
expect(provider.detailMessage).toContain('Command timed out after 30000ms');
expect(calls).toEqual(['runtime status --json --provider codex --summary']);
expect(vi.mocked(console.warn).mock.calls.map((call) => call.join(' '))).toEqual([
expect.stringContaining(
'Provider-scoped summary runtime status unavailable for codex, returning scoped error'
),
expect(calls).toEqual([
'runtime status --json --provider codex --summary',
'auth status --json --provider codex',
'model list --json --provider codex',
]);
expect(execCliMock.mock.calls[0][2]?.timeout).toBe(5000);
expect(vi.mocked(console.warn).mock.calls.map((call) => call.join(' '))).toEqual(
expect.arrayContaining([
expect.stringContaining('Provider-scoped summary runtime status timed out for codex'),
])
);
vi.mocked(console.warn).mockClear();
});
@ -480,7 +521,7 @@ describe('ClaudeMultimodelBridgeService', () => {
]);
});
it('does not cascade aggregate summary timeouts into slower fallback probes', async () => {
it('falls back to scoped legacy probes for Anthropic and Codex aggregate summary timeouts', async () => {
execCliMock.mockImplementation((_binaryPath, args, options) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
if (
@ -494,6 +535,73 @@ describe('ClaudeMultimodelBridgeService', () => {
)
);
}
if (normalizedArgs === 'auth status --json --provider anthropic') {
return Promise.resolve({
stdout: JSON.stringify({
schemaVersion: 1,
provider: 'anthropic',
status: {
supported: true,
authenticated: true,
authMethod: 'claude.ai',
verificationState: 'verified',
canLoginFromUi: true,
capabilities: {
teamLaunch: true,
oneShot: true,
},
},
}),
stderr: '',
});
}
if (normalizedArgs === 'model list --json --provider anthropic') {
return Promise.resolve({
stdout: JSON.stringify({
schemaVersion: 1,
providers: {
anthropic: {
models: [{ id: 'opus[1m]', label: 'Opus 4.7 (1M)' }],
},
},
}),
stderr: '',
});
}
if (normalizedArgs === 'auth status --json --provider codex') {
return Promise.resolve({
stdout: JSON.stringify({
schemaVersion: 1,
provider: 'codex',
status: {
supported: true,
authenticated: false,
authMethod: null,
verificationState: 'unknown',
canLoginFromUi: false,
statusMessage: 'Codex native runtime unavailable',
capabilities: {
teamLaunch: true,
oneShot: true,
},
},
}),
stderr: '',
});
}
if (normalizedArgs === 'model list --json --provider codex') {
return Promise.resolve({
stdout: JSON.stringify({
schemaVersion: 1,
providers: {
codex: {
models: [{ id: 'gpt-5.4', label: 'GPT-5.4' }],
},
},
}),
stderr: '',
});
}
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
});
@ -505,22 +613,46 @@ describe('ClaudeMultimodelBridgeService', () => {
const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator');
const calls = execCliMock.mock.calls.map((call) => call[1].join(' '));
expect(execCliMock).toHaveBeenCalledTimes(3);
expect(execCliMock.mock.calls.map((call) => call[2]?.timeout)).toEqual([30000, 30000, 30000]);
expect(calls).toEqual([
'runtime status --json --provider anthropic --summary',
'runtime status --json --provider codex --summary',
'runtime status --json --provider opencode --summary',
]);
expect(execCliMock).toHaveBeenCalledTimes(7);
expect(
execCliMock.mock.calls.map((call) => call[2]?.timeout as number).sort((a, b) => a - b)
).toEqual([5000, 5000, 15000, 15000, 25000, 25000, 30000]);
expect(calls).toEqual(
expect.arrayContaining([
'runtime status --json --provider anthropic --summary',
'runtime status --json --provider codex --summary',
'runtime status --json --provider opencode --summary',
'auth status --json --provider anthropic',
'model list --json --provider anthropic',
'auth status --json --provider codex',
'model list --json --provider codex',
])
);
expect(providers.map((provider) => provider.providerId)).toEqual([
'anthropic',
'codex',
'opencode',
]);
expect(providers.every((provider) => provider.verificationState === 'error')).toBe(true);
expect(
providers.every((provider) => provider.statusMessage === 'Provider status unavailable')
).toBe(true);
expect(providers[0]).toMatchObject({
providerId: 'anthropic',
supported: true,
authenticated: true,
verificationState: 'verified',
models: ['opus[1m]'],
});
expect(providers[1]).toMatchObject({
providerId: 'codex',
supported: true,
authenticated: false,
verificationState: 'unknown',
statusMessage: 'Codex native runtime unavailable',
models: ['gpt-5.4'],
});
expect(providers[2]).toMatchObject({
providerId: 'opencode',
verificationState: 'error',
statusMessage: 'Provider status unavailable',
});
expect(vi.mocked(console.warn).mock.calls.map((call) => call.join(' '))).toEqual([
expect.stringContaining(
'Provider-scoped runtime status timed out for anthropic, codex, opencode'
@ -1016,7 +1148,7 @@ describe('ClaudeMultimodelBridgeService', () => {
execCliMock.mock.calls.find(
(call) => call[1].join(' ') === 'runtime status --json --provider codex --summary'
)?.[2]?.timeout
).toBe(30_000);
).toBe(5_000);
expect(
execCliMock.mock.calls.find(
(call) => call[1].join(' ') === 'runtime status --json --provider codex'

View file

@ -583,6 +583,62 @@ describe('CLI status visibility during completed install state', () => {
});
});
it('renders Anthropic legacy fallback status as connected with model badges', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';
storeState.cliStatus = createInstalledCliStatus({
flavor: 'agent_teams_orchestrator',
displayName: 'Multimodel runtime',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
authLoggedIn: true,
authStatusChecking: false,
providers: [
{
providerId: 'anthropic',
displayName: 'Anthropic',
supported: true,
authenticated: true,
authMethod: 'claude.ai',
verificationState: 'verified',
statusMessage: null,
models: ['opus', 'opus[1m]'],
modelAvailability: [],
canLoginFromUi: true,
capabilities: {
teamLaunch: true,
oneShot: true,
},
backend: null,
modelCatalog: null,
modelCatalogRefreshState: 'idle',
runtimeCapabilities: null,
},
],
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CliStatusBanner));
await Promise.resolve();
});
expect(host.textContent).toContain('Anthropic');
expect(host.textContent).toContain('Connected via');
expect(host.textContent).toContain('Opus');
expect(host.textContent).not.toContain('Provider status unavailable');
expect(host.textContent).not.toContain('Models unavailable for this runtime build');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('keeps connected provider details visible while a refresh is in flight', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliInstallerState = 'idle';
@ -752,8 +808,7 @@ describe('CLI status visibility during completed install state', () => {
state: 'runtime-missing',
available: false,
selectable: false,
statusMessage:
'Codex CLI not found. Install Codex to use native account management.',
statusMessage: 'Codex CLI not found. Install Codex to use native account management.',
detailMessage: 'Codex native runtime is missing.',
models: [],
}),