Merge pull request #179 from 777genius/fix/windows-provider-status-fallback
fix: add Windows provider status fallback
This commit is contained in:
commit
bb18979d60
3 changed files with 455 additions and 62 deletions
|
|
@ -25,6 +25,8 @@ const logger = createLogger('ClaudeMultimodelBridgeService');
|
||||||
|
|
||||||
const PROVIDER_STATUS_TIMEOUT_MS = 90_000;
|
const PROVIDER_STATUS_TIMEOUT_MS = 90_000;
|
||||||
const PROVIDER_STATUS_SUMMARY_TIMEOUT_MS = 30_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_MODELS_TIMEOUT_MS = 25_000;
|
||||||
const PROVIDER_STATUS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
const PROVIDER_STATUS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
||||||
const PROVIDER_MODELS_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 {
|
interface ProviderStatusCommandResponse {
|
||||||
schemaVersion?: number;
|
schemaVersion?: number;
|
||||||
providers?: Record<
|
provider?: string;
|
||||||
string,
|
status?: ProviderStatusPayloadResponse;
|
||||||
{
|
providers?: Record<string, 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 ProviderModelsCommandResponse {
|
interface ProviderModelsCommandResponse {
|
||||||
|
|
@ -879,6 +882,170 @@ export class ClaudeMultimodelBridgeService {
|
||||||
return lower.includes('timed out') || lower.includes('timeout');
|
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(
|
private mapRuntimeProviderStatus(
|
||||||
providerId: CliProviderId,
|
providerId: CliProviderId,
|
||||||
runtimeStatus: NonNullable<UnifiedRuntimeStatusResponse['providers']>[string] | undefined
|
runtimeStatus: NonNullable<UnifiedRuntimeStatusResponse['providers']>[string] | undefined
|
||||||
|
|
@ -1024,9 +1191,7 @@ export class ClaudeMultimodelBridgeService {
|
||||||
if (options.summary) {
|
if (options.summary) {
|
||||||
args.push('--summary');
|
args.push('--summary');
|
||||||
}
|
}
|
||||||
const timeout =
|
const timeout = this.getProviderStatusRuntimeTimeout(providerId, options);
|
||||||
options.timeoutMs ??
|
|
||||||
(options.summary ? PROVIDER_STATUS_SUMMARY_TIMEOUT_MS : PROVIDER_STATUS_TIMEOUT_MS);
|
|
||||||
const { stdout } = await execCli(binaryPath, args, {
|
const { stdout } = await execCli(binaryPath, args, {
|
||||||
timeout,
|
timeout,
|
||||||
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
|
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) {
|
if (failures.length === 0) {
|
||||||
return this.buildProviderStatusesSnapshot(providers, providerIds);
|
return this.buildProviderStatusesSnapshot(providers, providerIds);
|
||||||
|
|
@ -1091,10 +1257,18 @@ export class ClaudeMultimodelBridgeService {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Provider-scoped runtime status timed out for ${failures
|
`Provider-scoped runtime status timed out for ${failures
|
||||||
.map(({ providerId }) => providerId)
|
.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) {
|
const fallbackProviders = await Promise.all(
|
||||||
providers.set(providerId, createRuntimeStatusErrorProviderStatus(providerId, error));
|
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));
|
onUpdate?.(this.buildProviderStatusesSnapshot(providers, providerIds));
|
||||||
return this.buildProviderStatusesSnapshot(providers, providerIds);
|
return this.buildProviderStatusesSnapshot(providers, providerIds);
|
||||||
|
|
@ -1109,8 +1283,18 @@ export class ClaudeMultimodelBridgeService {
|
||||||
.join(', ')}; using partial provider statuses`
|
.join(', ')}; using partial provider statuses`
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const { providerId, error } of failures) {
|
const fallbackProviders = await Promise.all(
|
||||||
providers.set(providerId, createRuntimeStatusErrorProviderStatus(providerId, error));
|
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));
|
onUpdate?.(this.buildProviderStatusesSnapshot(providers, providerIds));
|
||||||
return this.buildProviderStatusesSnapshot(providers, providerIds);
|
return this.buildProviderStatusesSnapshot(providers, providerIds);
|
||||||
|
|
@ -1322,6 +1506,17 @@ export class ClaudeMultimodelBridgeService {
|
||||||
try {
|
try {
|
||||||
return await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId);
|
return await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId);
|
||||||
} catch (fullError) {
|
} 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(
|
logger.warn(
|
||||||
`Provider-scoped full runtime status unavailable for ${providerId}, returning scoped error: ${
|
`Provider-scoped full runtime status unavailable for ${providerId}, returning scoped error: ${
|
||||||
fullError instanceof Error ? fullError.message : String(fullError)
|
fullError instanceof Error ? fullError.message : String(fullError)
|
||||||
|
|
@ -1332,10 +1527,21 @@ export class ClaudeMultimodelBridgeService {
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.warn(
|
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)
|
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);
|
return createRuntimeStatusErrorProviderStatus(providerId, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -281,7 +281,7 @@ describe('ClaudeMultimodelBridgeService', () => {
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
execCliMock.mockImplementation((_binaryPath, args) => {
|
execCliMock.mockImplementation((_binaryPath, args, _options) => {
|
||||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||||
const providerArgIndex = Array.isArray(args) ? args.indexOf('--provider') : -1;
|
const providerArgIndex = Array.isArray(args) ? args.indexOf('--provider') : -1;
|
||||||
const providerId =
|
const providerId =
|
||||||
|
|
@ -344,16 +344,50 @@ describe('ClaudeMultimodelBridgeService', () => {
|
||||||
expect(calls).not.toContain('model list --json --provider all');
|
expect(calls).not.toContain('model list --json --provider all');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns a scoped provider error when single-provider summary status times out', async () => {
|
it('falls back to scoped legacy provider probes when single-provider summary status times out', async () => {
|
||||||
execCliMock.mockImplementation((_binaryPath, args) => {
|
execCliMock.mockImplementation((_binaryPath, args, options) => {
|
||||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||||
if (normalizedArgs === 'runtime status --json --provider codex --summary') {
|
if (normalizedArgs === 'runtime status --json --provider codex --summary') {
|
||||||
return Promise.reject(
|
return Promise.reject(
|
||||||
new Error(
|
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}`));
|
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
|
||||||
});
|
});
|
||||||
|
|
@ -367,16 +401,23 @@ describe('ClaudeMultimodelBridgeService', () => {
|
||||||
|
|
||||||
expect(provider).toMatchObject({
|
expect(provider).toMatchObject({
|
||||||
providerId: 'codex',
|
providerId: 'codex',
|
||||||
verificationState: 'error',
|
supported: true,
|
||||||
statusMessage: 'Provider status unavailable',
|
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([
|
||||||
expect(calls).toEqual(['runtime status --json --provider codex --summary']);
|
'runtime status --json --provider codex --summary',
|
||||||
expect(vi.mocked(console.warn).mock.calls.map((call) => call.join(' '))).toEqual([
|
'auth status --json --provider codex',
|
||||||
expect.stringContaining(
|
'model list --json --provider codex',
|
||||||
'Provider-scoped summary runtime status unavailable for codex, returning scoped error'
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
|
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();
|
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) => {
|
execCliMock.mockImplementation((_binaryPath, args, options) => {
|
||||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||||
if (
|
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}`));
|
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 providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator');
|
||||||
const calls = execCliMock.mock.calls.map((call) => call[1].join(' '));
|
const calls = execCliMock.mock.calls.map((call) => call[1].join(' '));
|
||||||
|
|
||||||
expect(execCliMock).toHaveBeenCalledTimes(3);
|
expect(execCliMock).toHaveBeenCalledTimes(7);
|
||||||
expect(execCliMock.mock.calls.map((call) => call[2]?.timeout)).toEqual([30000, 30000, 30000]);
|
expect(
|
||||||
expect(calls).toEqual([
|
execCliMock.mock.calls.map((call) => call[2]?.timeout as number).sort((a, b) => a - b)
|
||||||
'runtime status --json --provider anthropic --summary',
|
).toEqual([5000, 5000, 15000, 15000, 25000, 25000, 30000]);
|
||||||
'runtime status --json --provider codex --summary',
|
expect(calls).toEqual(
|
||||||
'runtime status --json --provider opencode --summary',
|
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([
|
expect(providers.map((provider) => provider.providerId)).toEqual([
|
||||||
'anthropic',
|
'anthropic',
|
||||||
'codex',
|
'codex',
|
||||||
'opencode',
|
'opencode',
|
||||||
]);
|
]);
|
||||||
expect(providers.every((provider) => provider.verificationState === 'error')).toBe(true);
|
expect(providers[0]).toMatchObject({
|
||||||
expect(
|
providerId: 'anthropic',
|
||||||
providers.every((provider) => provider.statusMessage === 'Provider status unavailable')
|
supported: true,
|
||||||
).toBe(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(vi.mocked(console.warn).mock.calls.map((call) => call.join(' '))).toEqual([
|
||||||
expect.stringContaining(
|
expect.stringContaining(
|
||||||
'Provider-scoped runtime status timed out for anthropic, codex, opencode'
|
'Provider-scoped runtime status timed out for anthropic, codex, opencode'
|
||||||
|
|
@ -1016,7 +1148,7 @@ describe('ClaudeMultimodelBridgeService', () => {
|
||||||
execCliMock.mock.calls.find(
|
execCliMock.mock.calls.find(
|
||||||
(call) => call[1].join(' ') === 'runtime status --json --provider codex --summary'
|
(call) => call[1].join(' ') === 'runtime status --json --provider codex --summary'
|
||||||
)?.[2]?.timeout
|
)?.[2]?.timeout
|
||||||
).toBe(30_000);
|
).toBe(5_000);
|
||||||
expect(
|
expect(
|
||||||
execCliMock.mock.calls.find(
|
execCliMock.mock.calls.find(
|
||||||
(call) => call[1].join(' ') === 'runtime status --json --provider codex'
|
(call) => call[1].join(' ') === 'runtime status --json --provider codex'
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
it('keeps connected provider details visible while a refresh is in flight', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
storeState.cliInstallerState = 'idle';
|
storeState.cliInstallerState = 'idle';
|
||||||
|
|
@ -752,8 +808,7 @@ describe('CLI status visibility during completed install state', () => {
|
||||||
state: 'runtime-missing',
|
state: 'runtime-missing',
|
||||||
available: false,
|
available: false,
|
||||||
selectable: false,
|
selectable: false,
|
||||||
statusMessage:
|
statusMessage: 'Codex CLI not found. Install Codex to use native account management.',
|
||||||
'Codex CLI not found. Install Codex to use native account management.',
|
|
||||||
detailMessage: 'Codex native runtime is missing.',
|
detailMessage: 'Codex native runtime is missing.',
|
||||||
models: [],
|
models: [],
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue