agent-ecosystem/src/main/services/runtime/ClaudeMultimodelBridgeService.ts

553 lines
19 KiB
TypeScript

import { execCli } from '@main/utils/childProcess';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { createLogger } from '@shared/utils/logger';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
import { providerConnectionService } from './ProviderConnectionService';
import type { CliProviderId, CliProviderStatus } from '@shared/types';
const logger = createLogger('ClaudeMultimodelBridgeService');
const PROVIDER_STATUS_TIMEOUT_MS = 10_000;
const PROVIDER_MODELS_TIMEOUT_MS = 10_000;
interface RuntimeExtensionCapabilityResponse {
status?: 'supported' | 'read-only' | 'unsupported';
ownership?: 'shared' | 'provider-scoped';
reason?: string | null;
}
interface RuntimeExtensionCapabilitiesResponse {
plugins?: RuntimeExtensionCapabilityResponse;
mcp?: RuntimeExtensionCapabilityResponse;
skills?: RuntimeExtensionCapabilityResponse;
apiKeys?: RuntimeExtensionCapabilityResponse;
}
interface ProviderStatusCommandResponse {
schemaVersion?: number;
providers?: Record<
string,
{
supported?: boolean;
authenticated?: boolean;
authMethod?: string | null;
verificationState?: 'verified' | 'unknown' | 'offline' | 'error';
canLoginFromUi?: boolean;
statusMessage?: string | null;
capabilities?: {
teamLaunch?: boolean;
oneShot?: boolean;
extensions?: RuntimeExtensionCapabilitiesResponse;
};
backend?: {
kind?: string;
label?: string;
endpointLabel?: string | null;
projectId?: string | null;
authMethodDetail?: string | null;
} | null;
}
>;
}
interface ProviderModelsCommandResponse {
schemaVersion?: number;
providers?: Record<
string,
{
models?: (string | { id?: string; label?: string; description?: string })[];
}
>;
}
interface UnifiedRuntimeStatusResponse {
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;
selectedBackendId?: string | null;
resolvedBackendId?: string | null;
availableBackends?: {
id?: string;
label?: string;
description?: string;
selectable?: boolean;
recommended?: boolean;
available?: boolean;
statusMessage?: string | null;
detailMessage?: string | null;
}[];
externalRuntimeDiagnostics?: {
id?: string;
label?: string;
detected?: boolean;
statusMessage?: string | null;
detailMessage?: string | null;
}[];
models?: (string | { id?: string; label?: string; description?: string })[];
capabilities?: {
teamLaunch?: boolean;
oneShot?: boolean;
extensions?: RuntimeExtensionCapabilitiesResponse;
};
backend?: {
kind?: string;
label?: string;
endpointLabel?: string | null;
projectId?: string | null;
authMethodDetail?: string | null;
} | null;
}
>;
}
const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini'];
function extractJsonObject<T>(raw: string): T {
const trimmed = raw.trim();
try {
return JSON.parse(trimmed) as T;
} catch {
const start = trimmed.indexOf('{');
const end = trimmed.lastIndexOf('}');
if (start >= 0 && end > start) {
return JSON.parse(trimmed.slice(start, end + 1)) as T;
}
throw new Error('No JSON object found in CLI output');
}
}
function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStatus {
return {
providerId,
displayName:
providerId === 'anthropic' ? 'Anthropic' : providerId === 'codex' ? 'Codex' : 'Gemini',
supported: false,
authenticated: false,
authMethod: null,
verificationState: 'unknown',
modelVerificationState: 'idle',
statusMessage: null,
models: [],
modelAvailability: [],
canLoginFromUi: true,
capabilities: {
teamLaunch: false,
oneShot: false,
extensions: createDefaultCliExtensionCapabilities(),
},
selectedBackendId: null,
resolvedBackendId: null,
availableBackends: [],
externalRuntimeDiagnostics: [],
backend: null,
connection: null,
};
}
function mapRuntimeExtensionCapabilities(
capabilities?: RuntimeExtensionCapabilitiesResponse
): CliProviderStatus['capabilities']['extensions'] {
const defaults = createDefaultCliExtensionCapabilities();
return {
plugins: {
...defaults.plugins,
status: capabilities?.plugins?.status ?? defaults.plugins.status,
ownership: capabilities?.plugins?.ownership ?? defaults.plugins.ownership,
reason: capabilities?.plugins?.reason ?? defaults.plugins.reason,
},
mcp: {
...defaults.mcp,
status: capabilities?.mcp?.status ?? defaults.mcp.status,
ownership: capabilities?.mcp?.ownership ?? defaults.mcp.ownership,
reason: capabilities?.mcp?.reason ?? defaults.mcp.reason,
},
skills: {
...defaults.skills,
status: capabilities?.skills?.status ?? defaults.skills.status,
ownership: capabilities?.skills?.ownership ?? defaults.skills.ownership,
reason: capabilities?.skills?.reason ?? defaults.skills.reason,
},
apiKeys: {
...defaults.apiKeys,
status: capabilities?.apiKeys?.status ?? defaults.apiKeys.status,
ownership: capabilities?.apiKeys?.ownership ?? defaults.apiKeys.ownership,
reason: capabilities?.apiKeys?.reason ?? defaults.apiKeys.reason,
},
};
}
function extractModelIds(
models: (string | { id?: string; label?: string; description?: string })[] | undefined
): string[] {
if (!models) {
return [];
}
return models.flatMap<string>((model) => {
if (typeof model === 'string') {
return [model];
}
if (typeof model?.id === 'string' && model.id.trim().length > 0) {
return [model.id.trim()];
}
return [];
});
}
export class ClaudeMultimodelBridgeService {
private async buildCliEnv(
binaryPath: string
): Promise<Awaited<ReturnType<typeof buildProviderAwareCliEnv>>> {
return buildProviderAwareCliEnv({ binaryPath });
}
private async buildProviderCliEnv(
binaryPath: string,
providerId: CliProviderId
): Promise<Awaited<ReturnType<typeof buildProviderAwareCliEnv>>> {
return buildProviderAwareCliEnv({ binaryPath, providerId });
}
private isUnifiedRuntimeUnsupported(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
const lower = message.toLowerCase();
return (
lower.includes('unknown command') ||
lower.includes('unknown option') ||
lower.includes('no such command') ||
lower.includes('did you mean') ||
lower.includes('runtime status')
);
}
private mapRuntimeProviderStatus(
providerId: CliProviderId,
runtimeStatus: NonNullable<UnifiedRuntimeStatusResponse['providers']>[string] | undefined
): CliProviderStatus {
const provider = createDefaultProviderStatus(providerId);
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,
canLoginFromUi: runtimeStatus.canLoginFromUi !== false,
capabilities: {
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
oneShot: runtimeStatus.capabilities?.oneShot === true,
extensions: mapRuntimeExtensionCapabilities(runtimeStatus.capabilities?.extensions),
},
selectedBackendId: runtimeStatus.selectedBackendId ?? null,
resolvedBackendId: runtimeStatus.resolvedBackendId ?? null,
availableBackends:
runtimeStatus.availableBackends?.map((backend) => ({
id: backend.id ?? 'unknown',
label: backend.label ?? backend.id ?? 'Unknown',
description: backend.description ?? '',
selectable: backend.selectable !== false,
recommended: backend.recommended === true,
available: backend.available === true,
statusMessage: backend.statusMessage ?? null,
detailMessage: backend.detailMessage ?? null,
})) ?? [],
externalRuntimeDiagnostics:
runtimeStatus.externalRuntimeDiagnostics?.map((diagnostic) => ({
id: diagnostic.id ?? 'unknown',
label: diagnostic.label ?? diagnostic.id ?? 'Unknown',
detected: diagnostic.detected === true,
statusMessage: diagnostic.statusMessage ?? null,
detailMessage: diagnostic.detailMessage ?? null,
})) ?? [],
models: extractModelIds(runtimeStatus.models),
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 applyConnectionIssue(
provider: CliProviderStatus,
connectionIssues: Partial<Record<CliProviderId, string>>
): CliProviderStatus {
const issue = connectionIssues[provider.providerId];
if (!issue) {
return provider;
}
return {
...provider,
authenticated: false,
authMethod: null,
verificationState: 'error',
statusMessage: issue,
backend: null,
};
}
private applyConnectionIssues(
providers: CliProviderStatus[],
connectionIssues: Partial<Record<CliProviderId, string>>
): CliProviderStatus[] {
return providers.map((provider) => this.applyConnectionIssue(provider, connectionIssues));
}
async getProviderStatus(
binaryPath: string,
providerId: CliProviderId
): Promise<CliProviderStatus> {
await resolveInteractiveShellEnv();
const { env, connectionIssues } = await this.buildCliEnv(binaryPath);
try {
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
)
);
} catch (error) {
if (!this.isUnifiedRuntimeUnsupported(error)) {
logger.warn(
`Provider-scoped runtime status unavailable for ${providerId}, falling back to full probe: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
const providers = await this.getProviderStatuses(binaryPath);
return (
providers.find((provider) => provider.providerId === providerId) ??
createDefaultProviderStatus(providerId)
);
}
private async buildGeminiStatus(binaryPath: string): Promise<CliProviderStatus> {
const provider = createDefaultProviderStatus('gemini');
const { env } = await this.buildProviderCliEnv(binaryPath, 'gemini');
try {
const { stdout } = await execCli(
binaryPath,
['model', 'list', '--json', '--provider', 'all'],
{
timeout: PROVIDER_MODELS_TIMEOUT_MS,
env,
}
);
const parsed = extractJsonObject<ProviderModelsCommandResponse>(stdout);
const models = extractModelIds(parsed.providers?.gemini?.models);
if (models.length > 0) {
provider.supported = true;
provider.models = models;
provider.capabilities = {
teamLaunch: true,
oneShot: true,
extensions: createDefaultCliExtensionCapabilities(),
};
}
} catch (error) {
logger.warn(
`Gemini model list unavailable: ${error instanceof Error ? error.message : String(error)}`
);
}
const authState = await resolveGeminiRuntimeAuth(env);
if (authState.authenticated) {
provider.authenticated = true;
provider.authMethod =
authState.authMethod === 'adc_authorized_user' ||
authState.authMethod === 'adc_service_account'
? `gemini_${authState.authMethod}`
: authState.authMethod;
provider.verificationState = 'verified';
provider.statusMessage = null;
if (authState.authMethod === 'cli_oauth_personal') {
provider.backend = {
kind: 'cli',
label: 'Gemini CLI',
endpointLabel: 'Code Assist (cloudcode-pa.googleapis.com/v1internal)',
projectId: authState.projectId,
authMethodDetail: authState.authMethod,
};
}
return provider;
}
provider.statusMessage =
authState.statusMessage ?? 'Set GEMINI_API_KEY or Google ADC to use Gemini.';
return provider;
}
async getProviderStatuses(
binaryPath: string,
onUpdate?: (providers: CliProviderStatus[]) => void
): Promise<CliProviderStatus[]> {
await resolveInteractiveShellEnv();
const { env, connectionIssues } = await this.buildCliEnv(binaryPath);
try {
const { stdout } = await execCli(binaryPath, ['runtime', 'status', '--json'], {
timeout: PROVIDER_STATUS_TIMEOUT_MS,
env,
});
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
const providers = await providerConnectionService.enrichProviderStatuses(
this.applyConnectionIssues(
ORDERED_PROVIDER_IDS.map((providerId) =>
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId])
),
connectionIssues
)
);
onUpdate?.(providers);
return providers;
} catch (error) {
if (!this.isUnifiedRuntimeUnsupported(error)) {
logger.warn(
`Unified runtime status unavailable, falling back to legacy probes: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
const [statusResult, modelsResult] = await Promise.allSettled([
execCli(binaryPath, ['auth', 'status', '--json', '--provider', 'all'], {
timeout: PROVIDER_STATUS_TIMEOUT_MS,
env,
}),
execCli(binaryPath, ['model', 'list', '--json', '--provider', 'all'], {
timeout: PROVIDER_MODELS_TIMEOUT_MS,
env,
}),
]);
const providers = new Map<CliProviderId, CliProviderStatus>(
ORDERED_PROVIDER_IDS.map((providerId) => [
providerId,
createDefaultProviderStatus(providerId),
])
);
if (statusResult.status === 'fulfilled') {
try {
const parsed = extractJsonObject<ProviderStatusCommandResponse>(statusResult.value.stdout);
for (const providerId of ORDERED_PROVIDER_IDS.filter((id) => id !== 'gemini')) {
const runtimeStatus = parsed.providers?.[providerId];
if (!runtimeStatus) continue;
providers.set(providerId, {
...providers.get(providerId)!,
supported: runtimeStatus.supported === true,
authenticated: runtimeStatus.authenticated === true,
authMethod: runtimeStatus.authMethod ?? null,
verificationState: runtimeStatus.verificationState ?? 'unknown',
statusMessage: runtimeStatus.statusMessage ?? null,
canLoginFromUi: runtimeStatus.canLoginFromUi !== false,
capabilities: {
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
oneShot: runtimeStatus.capabilities?.oneShot === true,
extensions: mapRuntimeExtensionCapabilities(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,
});
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
}
} catch (error) {
logger.warn(
`Failed to parse provider auth status JSON: ${
error instanceof Error ? error.message : String(error)
}`
);
}
} else {
const message =
statusResult.reason instanceof Error
? statusResult.reason.message
: String(statusResult.reason);
logger.warn(`Provider auth status unavailable: ${message}`);
for (const providerId of ORDERED_PROVIDER_IDS) {
providers.set(providerId, {
...providers.get(providerId)!,
statusMessage: 'Provider status not supported by current claude-multimodel build',
});
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
}
}
if (modelsResult.status === 'fulfilled') {
try {
const parsed = extractJsonObject<ProviderModelsCommandResponse>(modelsResult.value.stdout);
for (const providerId of ORDERED_PROVIDER_IDS.filter((id) => id !== 'gemini')) {
const runtimeModels = extractModelIds(parsed.providers?.[providerId]?.models);
if (runtimeModels.length === 0) continue;
providers.set(providerId, {
...providers.get(providerId)!,
models: runtimeModels,
});
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
}
} catch (error) {
logger.warn(
`Failed to parse provider models JSON: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
providers.set('gemini', await this.buildGeminiStatus(binaryPath));
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
const enrichedProviders = await providerConnectionService.enrichProviderStatuses(
this.applyConnectionIssues(
ORDERED_PROVIDER_IDS.map((providerId) => providers.get(providerId)!),
connectionIssues
)
);
onUpdate?.(enrichedProviders);
return enrichedProviders;
}
}