diff --git a/src/features/kilocode-model-catalog/contracts/dto.ts b/src/features/kilocode-model-catalog/contracts/dto.ts new file mode 100644 index 00000000..66f8f7f8 --- /dev/null +++ b/src/features/kilocode-model-catalog/contracts/dto.ts @@ -0,0 +1,11 @@ +import type { + CliProviderModelCatalog, + CliProviderModelCatalogItem, + CliProviderModelCatalogSource, + CliProviderModelCatalogStatus, +} from '@shared/types'; + +export type KilocodeModelCatalogDto = CliProviderModelCatalog; +export type KilocodeModelCatalogItemDto = CliProviderModelCatalogItem; +export type KilocodeModelCatalogSourceDto = CliProviderModelCatalogSource; +export type KilocodeModelCatalogStatusDto = CliProviderModelCatalogStatus; diff --git a/src/features/kilocode-model-catalog/contracts/index.ts b/src/features/kilocode-model-catalog/contracts/index.ts new file mode 100644 index 00000000..8d12ad9f --- /dev/null +++ b/src/features/kilocode-model-catalog/contracts/index.ts @@ -0,0 +1,6 @@ +export type { + KilocodeModelCatalogDto, + KilocodeModelCatalogItemDto, + KilocodeModelCatalogSourceDto, + KilocodeModelCatalogStatusDto, +} from './dto'; diff --git a/src/features/kilocode-model-catalog/core/domain/kilocodeModelCatalogFallback.ts b/src/features/kilocode-model-catalog/core/domain/kilocodeModelCatalogFallback.ts new file mode 100644 index 00000000..d5b35f60 --- /dev/null +++ b/src/features/kilocode-model-catalog/core/domain/kilocodeModelCatalogFallback.ts @@ -0,0 +1,32 @@ +import type { KilocodeModelCatalogItemDto } from '../../contracts'; + +export function createStaticKilocodeModelCatalogModels(): KilocodeModelCatalogItemDto[] { + return [ + { + id: 'claude-sonnet-4-5', + launchModel: 'claude-sonnet-4-5', + displayName: 'Claude Sonnet 4.5', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'static-fallback', + }, + { + id: 'claude-opus-4-5', + launchModel: 'claude-opus-4-5', + displayName: 'Claude Opus 4.5', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'static-fallback', + }, + ]; +} diff --git a/src/features/kilocode-model-catalog/index.ts b/src/features/kilocode-model-catalog/index.ts new file mode 100644 index 00000000..0cc3e229 --- /dev/null +++ b/src/features/kilocode-model-catalog/index.ts @@ -0,0 +1,8 @@ +export type { + KilocodeModelCatalogDto, + KilocodeModelCatalogItemDto, + KilocodeModelCatalogSourceDto, + KilocodeModelCatalogStatusDto, +} from './contracts'; +export type { KilocodeModelCatalogFeatureFacade, KilocodeModelCatalogRequest } from './main'; +export { createKilocodeModelCatalogFeature } from './main'; diff --git a/src/features/kilocode-model-catalog/main/composition/createKilocodeModelCatalogFeature.ts b/src/features/kilocode-model-catalog/main/composition/createKilocodeModelCatalogFeature.ts new file mode 100644 index 00000000..d4a68a96 --- /dev/null +++ b/src/features/kilocode-model-catalog/main/composition/createKilocodeModelCatalogFeature.ts @@ -0,0 +1,186 @@ +import { createHash } from 'node:crypto'; + +import { createStaticKilocodeModelCatalogModels } from '../../core/domain/kilocodeModelCatalogFallback'; +import { InMemoryKilocodeModelCatalogCache } from '../infrastructure/InMemoryKilocodeModelCatalogCache'; +import { KilocodeGatewayClient } from '../infrastructure/KilocodeGatewayClient'; + +import type { KilocodeModelCatalogDto, KilocodeModelCatalogItemDto } from '../../contracts'; +import type { Logger } from '@shared/utils/logger'; + +type LoggerPort = Pick; + +const CATALOG_CACHE_TTL_MS = 10 * 60_000; +const CATALOG_STALE_TTL_MS = 24 * 60 * 60_000; + +export interface KilocodeModelCatalogRequest { + apiKey?: string | null; + forceRefresh?: boolean; +} + +export interface KilocodeModelCatalogFeatureFacade { + getCatalog(options?: KilocodeModelCatalogRequest): Promise; + invalidate(): void; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function staleAtIso(): string { + return new Date(Date.now() + CATALOG_CACHE_TTL_MS).toISOString(); +} + +function buildCacheKey(apiKey: string): string { + return `kilocode:${createHash('sha256').update(apiKey).digest('hex')}`; +} + +function normalizeGatewayModels( + models: { id: string; displayName: string }[] +): KilocodeModelCatalogItemDto[] { + return models.map((model, index) => ({ + id: model.id, + launchModel: model.id, + displayName: model.displayName, + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: false, + isDefault: index === 0, + upgrade: false, + source: 'app-server' as const, + })); +} + +function createFallbackCatalog(options: { + message: string; + status?: KilocodeModelCatalogDto['status']; + appServerState: KilocodeModelCatalogDto['diagnostics']['appServerState']; +}): KilocodeModelCatalogDto { + const models = createStaticKilocodeModelCatalogModels(); + const defaultModel = models.find((m) => m.isDefault) ?? models[0] ?? null; + return { + schemaVersion: 1, + providerId: 'kilocode', + source: 'static-fallback', + status: options.status ?? 'degraded', + fetchedAt: nowIso(), + staleAt: staleAtIso(), + defaultModelId: defaultModel?.id ?? null, + defaultLaunchModel: defaultModel?.launchModel ?? null, + models, + diagnostics: { + configReadState: 'skipped', + appServerState: options.appServerState, + message: options.message, + code: null, + }, + }; +} + +export function createKilocodeModelCatalogFeature(options: { + logger: LoggerPort; +}): KilocodeModelCatalogFeatureFacade { + const cache = new InMemoryKilocodeModelCatalogCache(); + const inFlightRefreshes = new Map>(); + const client = new KilocodeGatewayClient(); + + async function getCatalog( + request: KilocodeModelCatalogRequest = {} + ): Promise { + const apiKey = request.apiKey?.trim() || process.env.KILO_API_KEY?.trim() || null; + + if (!apiKey) { + return createFallbackCatalog({ + message: 'No KiloCode API key configured. Set KILO_API_KEY or configure an API key.', + appServerState: 'runtime-missing', + status: 'unavailable', + }); + } + + const cacheKey = buildCacheKey(apiKey); + + if (request.forceRefresh !== true) { + const cached = cache.get(cacheKey, CATALOG_CACHE_TTL_MS); + if (cached) { + return cached; + } + } + + const existing = inFlightRefreshes.get(cacheKey); + if (existing) { + return existing; + } + + const refreshPromise = (async (): Promise => { + try { + const gatewayModels = await client.listModels(apiKey); + const models = normalizeGatewayModels(gatewayModels); + + if (models.length === 0) { + throw new Error('KiloCode gateway returned no models.'); + } + + const defaultModel = models[0] ?? null; + const catalog: KilocodeModelCatalogDto = { + schemaVersion: 1, + providerId: 'kilocode', + source: 'app-server', + status: 'ready', + fetchedAt: nowIso(), + staleAt: staleAtIso(), + defaultModelId: defaultModel?.id ?? null, + defaultLaunchModel: defaultModel?.launchModel ?? null, + models, + diagnostics: { + configReadState: 'skipped', + appServerState: 'healthy', + message: null, + code: null, + }, + }; + + cache.set(cacheKey, catalog); + return catalog; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const stale = cache.getLatest(cacheKey); + if (stale && Date.parse(stale.fetchedAt) + CATALOG_STALE_TTL_MS > Date.now()) { + return { + ...stale, + status: 'stale', + diagnostics: { + configReadState: 'skipped', + appServerState: 'degraded', + message, + code: null, + }, + }; + } + + options.logger.warn('KiloCode model catalog refresh failed', { error: message }); + return createFallbackCatalog({ + message, + appServerState: 'degraded', + }); + } + })(); + + inFlightRefreshes.set(cacheKey, refreshPromise); + try { + return await refreshPromise; + } finally { + if (inFlightRefreshes.get(cacheKey) === refreshPromise) { + inFlightRefreshes.delete(cacheKey); + } + } + } + + return { + getCatalog, + invalidate: () => { + cache.clear(); + inFlightRefreshes.clear(); + }, + }; +} diff --git a/src/features/kilocode-model-catalog/main/index.ts b/src/features/kilocode-model-catalog/main/index.ts new file mode 100644 index 00000000..81881177 --- /dev/null +++ b/src/features/kilocode-model-catalog/main/index.ts @@ -0,0 +1,5 @@ +export type { + KilocodeModelCatalogFeatureFacade, + KilocodeModelCatalogRequest, +} from './composition/createKilocodeModelCatalogFeature'; +export { createKilocodeModelCatalogFeature } from './composition/createKilocodeModelCatalogFeature'; diff --git a/src/features/kilocode-model-catalog/main/infrastructure/InMemoryKilocodeModelCatalogCache.ts b/src/features/kilocode-model-catalog/main/infrastructure/InMemoryKilocodeModelCatalogCache.ts new file mode 100644 index 00000000..1fe75a23 --- /dev/null +++ b/src/features/kilocode-model-catalog/main/infrastructure/InMemoryKilocodeModelCatalogCache.ts @@ -0,0 +1,37 @@ +import type { KilocodeModelCatalogDto } from '../../contracts'; + +interface CacheEntry { + value: KilocodeModelCatalogDto; + observedAt: number; +} + +export class InMemoryKilocodeModelCatalogCache { + private readonly entries = new Map(); + + get(key: string, maxAgeMs: number): KilocodeModelCatalogDto | null { + const entry = this.entries.get(key); + if (!entry) { + return null; + } + if (Date.now() - entry.observedAt > maxAgeMs) { + return null; + } + return structuredClone(entry.value); + } + + getLatest(key: string): KilocodeModelCatalogDto | null { + const entry = this.entries.get(key); + return entry ? structuredClone(entry.value) : null; + } + + set(key: string, value: KilocodeModelCatalogDto): void { + this.entries.set(key, { + value: structuredClone(value), + observedAt: Date.now(), + }); + } + + clear(): void { + this.entries.clear(); + } +} diff --git a/src/features/kilocode-model-catalog/main/infrastructure/KilocodeGatewayClient.ts b/src/features/kilocode-model-catalog/main/infrastructure/KilocodeGatewayClient.ts new file mode 100644 index 00000000..cbda81d6 --- /dev/null +++ b/src/features/kilocode-model-catalog/main/infrastructure/KilocodeGatewayClient.ts @@ -0,0 +1,101 @@ +import https from 'node:https'; + +const GATEWAY_BASE_URL = 'https://api.kilo.ai'; +// KiloCode gateway endpoint: https://kilo.ai/docs/gateway/models-and-providers +const MODELS_PATH = '/api/gateway/models'; +const REQUEST_TIMEOUT_MS = 8_000; +const ERROR_BODY_PREVIEW_LIMIT = 500; + +interface GatewayModelObject { + id?: string; + object?: string; + created?: number; + owned_by?: string; + display_name?: string; +} + +interface GatewayModelsResponse { + object?: string; + data?: GatewayModelObject[]; +} + +export interface KilocodeGatewayModel { + id: string; + displayName: string; +} + +function sanitizeErrorBody(body: string): string { + const sanitized = body + .trim() + .replace(/Bearer\s+[A-Za-z0-9._~-]+/gi, 'Bearer [redacted]') + .replace(/sk-[A-Za-z0-9_-]+/g, '[redacted-api-key]'); + if (!sanitized) { + return 'empty response body'; + } + return sanitized.length > ERROR_BODY_PREVIEW_LIMIT + ? `${sanitized.slice(0, ERROR_BODY_PREVIEW_LIMIT)}...` + : sanitized; +} + +export class KilocodeGatewayClient { + async listModels(apiKey: string): Promise { + const raw = await this.fetchModels(apiKey); + const items = raw.data ?? []; + return items + .filter( + (item): item is GatewayModelObject & { id: string } => + typeof item.id === 'string' && item.id.trim().length > 0 + ) + .map((item) => ({ + id: item.id.trim(), + displayName: (item.display_name ?? item.id).trim(), + })); + } + + private fetchModels(apiKey: string): Promise { + return new Promise((resolve, reject) => { + const url = new URL(MODELS_PATH, GATEWAY_BASE_URL); + const options: https.RequestOptions = { + hostname: url.hostname, + port: url.port || 443, + path: url.pathname + url.search, + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: 'application/json', + }, + }; + + const req = https.request(options, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { + reject( + new Error( + `KiloCode gateway responded with HTTP ${res.statusCode}: ${sanitizeErrorBody(body)}` + ) + ); + return; + } + try { + resolve(JSON.parse(body) as GatewayModelsResponse); + } catch { + reject( + new Error(`KiloCode gateway returned non-JSON response: ${sanitizeErrorBody(body)}`) + ); + } + }); + res.on('error', reject); + }); + + req.setTimeout(REQUEST_TIMEOUT_MS, () => { + req.destroy(new Error(`KiloCode gateway request timed out after ${REQUEST_TIMEOUT_MS}ms`)); + }); + + req.on('error', reject); + req.end(); + }); + } +} diff --git a/src/features/member-work-sync/contracts/types.ts b/src/features/member-work-sync/contracts/types.ts index d2597252..4d6ade15 100644 --- a/src/features/member-work-sync/contracts/types.ts +++ b/src/features/member-work-sync/contracts/types.ts @@ -20,7 +20,7 @@ export type MemberWorkSyncActionableWorkPriority = | 'blocked' | 'needs_clarification'; -export type MemberWorkSyncProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode'; +export type MemberWorkSyncProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode' | 'kilocode'; export type MemberWorkSyncReviewObligation = 'review_pickup_required' | 'review_in_progress'; diff --git a/src/features/runtime-provider-management/renderer/ui/providerBrandIcons.tsx b/src/features/runtime-provider-management/renderer/ui/providerBrandIcons.tsx index 3bbda2e2..30fe686e 100644 --- a/src/features/runtime-provider-management/renderer/ui/providerBrandIcons.tsx +++ b/src/features/runtime-provider-management/renderer/ui/providerBrandIcons.tsx @@ -384,6 +384,7 @@ const BRAND_ALIASES: Record = { 'gitlab-duo': 'gitlab-duo', 'google-vertex': 'google-vertex', 'hugging-face': 'huggingface', + kilocode: 'kilo', 'mistral-ai': 'mistral', 'ollama-cloud': 'ollama-cloud', 'opencode-zen': 'opencode', diff --git a/src/features/workspace-trust/core/domain/WorkspaceTrustTypes.ts b/src/features/workspace-trust/core/domain/WorkspaceTrustTypes.ts index b93561cc..8c506986 100644 --- a/src/features/workspace-trust/core/domain/WorkspaceTrustTypes.ts +++ b/src/features/workspace-trust/core/domain/WorkspaceTrustTypes.ts @@ -1,4 +1,10 @@ -export type WorkspaceTrustProvider = 'claude' | 'anthropic' | 'codex' | 'gemini' | 'opencode'; +export type WorkspaceTrustProvider = + | 'claude' + | 'anthropic' + | 'codex' + | 'gemini' + | 'opencode' + | 'kilocode'; export type WorkspaceTrustWorkspaceSource = | 'team-root' diff --git a/src/main/index.ts b/src/main/index.ts index 746a21c2..54efaac7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -32,6 +32,10 @@ import { type CodexModelCatalogFeatureFacade, createCodexModelCatalogFeature, } from '@features/codex-model-catalog/main'; +import { + type KilocodeModelCatalogFeatureFacade, + createKilocodeModelCatalogFeature, +} from '@features/kilocode-model-catalog/main'; import { createMemberLogStreamFeature, registerMemberLogStreamIpc, @@ -899,6 +903,7 @@ let updaterService: UpdaterService; let sshConnectionManager: SshConnectionManager; let codexAccountFeature: CodexAccountFeatureFacade | null = null; let codexModelCatalogFeature: CodexModelCatalogFeatureFacade | null = null; +let kilocodeModelCatalogFeature: KilocodeModelCatalogFeatureFacade | null = null; let recentProjectsFeature: RecentProjectsFeatureFacade; let runtimeProviderManagementFeature: RuntimeProviderManagementFeatureFacade; let memberWorkSyncFeature: MemberWorkSyncFeatureFacade | null = null; @@ -2103,6 +2108,10 @@ async function initializeServices(): Promise { codexAccountFeature, }); providerConnectionService.setCodexModelCatalogFeature(codexModelCatalogFeature); + kilocodeModelCatalogFeature = createKilocodeModelCatalogFeature({ + logger: createLogger('Feature:KilocodeModelCatalog'), + }); + providerConnectionService.setKilocodeModelCatalogFeature(kilocodeModelCatalogFeature); // startProcessHealthPolling() is deferred to after window creation // (did-finish-load handler) to avoid thread pool contention at startup. @@ -2355,10 +2364,13 @@ async function shutdownServices(): Promise { await runShutdownStep('skills watcher stop', () => skillsWatcherService?.stopAll()); await runShutdownStep('provider connection feature detach', () => { providerConnectionService.setCodexModelCatalogFeature(null); + providerConnectionService.setKilocodeModelCatalogFeature(null); providerConnectionService.setCodexAccountFeature(null); }); await runShutdownStep('Codex model catalog dispose', () => codexModelCatalogFeature?.dispose()); codexModelCatalogFeature = null; + kilocodeModelCatalogFeature?.invalidate(); + kilocodeModelCatalogFeature = null; await runShutdownStep('Codex account dispose', () => codexAccountFeature?.dispose()); codexAccountFeature = null; await runShutdownStep('member work sync dispose', () => memberWorkSyncFeature?.dispose()); diff --git a/src/main/ipc/cliInstaller.ts b/src/main/ipc/cliInstaller.ts index 16d618f3..991d60c7 100644 --- a/src/main/ipc/cliInstaller.ts +++ b/src/main/ipc/cliInstaller.ts @@ -44,7 +44,12 @@ const cachedStatus = new Map< >(); let statusCacheGeneration = 0; const STATUS_CACHE_TTL_MS = 5_000; -const FRONTEND_MULTIMODEL_PROVIDER_IDS = new Set(['anthropic', 'codex', 'opencode']); +const FRONTEND_MULTIMODEL_PROVIDER_IDS = new Set([ + 'anthropic', + 'codex', + 'opencode', + 'kilocode', +]); function isFrontendMultimodelProviderId(providerId: CliProviderId): boolean { return FRONTEND_MULTIMODEL_PROVIDER_IDS.has(providerId); diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 1bbf7d43..f1403213 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -45,6 +45,7 @@ import { type ProviderModelAvailabilityContext, type ProviderModelAvailabilitySnapshot, } from '../runtime/CliProviderModelAvailabilityService'; +import { providerConnectionService } from '../runtime/ProviderConnectionService'; import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver'; import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '../team/cliFlavor'; @@ -71,7 +72,12 @@ const GCS_BASE = 'https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases'; const CLI_INSTALLER_PROGRESS_CHANNEL = 'cliInstaller:progress'; -const FRONTEND_MULTIMODEL_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'opencode']; +const FRONTEND_MULTIMODEL_PROVIDER_IDS: CliProviderId[] = [ + 'anthropic', + 'codex', + 'opencode', + 'kilocode', +]; const FRONTEND_MULTIMODEL_PROVIDER_ID_SET = new Set( FRONTEND_MULTIMODEL_PROVIDER_IDS ); @@ -86,6 +92,8 @@ function getProviderDisplayName(providerId: CliProviderId): string { return 'Gemini'; case 'opencode': return 'OpenCode (200+ models)'; + case 'kilocode': + return 'KiloCode'; } } @@ -919,6 +927,10 @@ export class CliInstallerService { background: false, }); + if (providerId === 'kilocode') { + return this.resolveKilocodeProviderStatus(); + } + const binaryPath = await ClaudeBinaryResolver.resolve(); if (!binaryPath) { return null; @@ -959,6 +971,10 @@ export class CliInstallerService { background: false, }); + if (providerId === 'kilocode') { + return this.resolveKilocodeProviderStatus(); + } + const binaryPath = await ClaudeBinaryResolver.resolve(); if (!binaryPath) { return null; @@ -1204,6 +1220,53 @@ export class CliInstallerService { result.authMethod = null; } + private async resolveKilocodeProviderStatus(): Promise { + const baseStatus: CliProviderStatus = { + providerId: 'kilocode', + displayName: 'KiloCode', + supported: false, + authenticated: false, + authMethod: null, + verificationState: 'verified', + modelVerificationState: 'idle', + statusMessage: null, + detailMessage: null, + models: [], + modelAvailability: [], + canLoginFromUi: true, + capabilities: { + teamLaunch: false, + oneShot: false, + extensions: createDefaultCliExtensionCapabilities(), + }, + selectedBackendId: null, + resolvedBackendId: null, + availableBackends: [], + externalRuntimeDiagnostics: [], + backend: null, + connection: null, + modelCatalog: null, + runtimeCapabilities: null, + subscriptionRateLimits: null, + }; + // enrichProviderStatus checks both the app key store and process.env for KILO_API_KEY + const enriched = await providerConnectionService.enrichProviderStatus(baseStatus); + const hasApiKey = Boolean(enriched.connection?.apiKeyConfigured); + const status: CliProviderStatus = { + ...enriched, + supported: hasApiKey, + authenticated: hasApiKey, + authMethod: hasApiKey ? 'api_key' : null, + statusMessage: hasApiKey ? null : 'Configure KILO_API_KEY to use KiloCode.', + capabilities: { + ...enriched.capabilities, + teamLaunch: hasApiKey, + }, + }; + this.updateLatestProviderStatus(status); + return status; + } + private markProvidersDeferred(result: CliInstallationStatus): void { if (result.flavor !== 'agent_teams_orchestrator') { return; @@ -1241,13 +1304,38 @@ export class CliInstallerService { if (result.flavor === 'agent_teams_orchestrator') { result.authStatusChecking = true; let statusTarget = result; - const applyProviders = (providersSnapshot: CliProviderStatus[], final: boolean): void => { + const buildFrontendProviders = async ( + providersSnapshot: CliProviderStatus[] + ): Promise => { + const providersById = new Map(providersSnapshot.map((provider) => [provider.providerId, provider])); + const frontendProviders: CliProviderStatus[] = []; + for (const providerId of FRONTEND_MULTIMODEL_PROVIDER_IDS) { + if (providerId === 'kilocode') { + frontendProviders.push(await this.resolveKilocodeProviderStatus()); + continue; + } + const provider = providersById.get(providerId); + if (provider) { + frontendProviders.push(provider); + } + } + return frontendProviders; + }; + const applyProviders = async ( + providersSnapshot: CliProviderStatus[], + final: boolean + ): Promise => { if (generation !== this.statusGatherGeneration) { return; } const target = statusTarget; - const frontendProviders = filterFrontendMultimodelProviders(providersSnapshot); + const frontendProviders = await buildFrontendProviders( + filterFrontendMultimodelProviders(providersSnapshot) + ); + if (generation !== this.statusGatherGeneration) { + return; + } target.providers = frontendProviders; target.authLoggedIn = hasFrontendAuthenticatedProvider(frontendProviders); target.authMethod = getFrontendAuthenticatedProvider(frontendProviders)?.authMethod ?? null; @@ -1260,10 +1348,10 @@ export class CliInstallerService { const completion = this.multimodelBridgeService .getProviderStatuses(binaryPath, (providersSnapshot) => { - applyProviders(providersSnapshot, false); + void applyProviders(providersSnapshot, false); }) .then((providers) => { - applyProviders(providers, true); + return applyProviders(providers, true); }) .catch((error) => { if (generation !== this.statusGatherGeneration) { diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 448519cc..d799ac8f 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -335,6 +335,8 @@ function getProviderDisplayName(providerId: CliProviderId): string { return 'Gemini'; case 'opencode': return 'OpenCode (200+ models)'; + case 'kilocode': + return 'KiloCode'; } } @@ -421,15 +423,14 @@ function mapRuntimeExtensionCapabilities( const defaults = capabilities ? createDefaultCliExtensionCapabilities() : createLegacyRuntimeFallbackCliExtensionCapabilities(); - const pluginStatus = - providerId === 'opencode' - ? 'unsupported' - : (capabilities?.plugins?.status ?? defaults.plugins.status); - const pluginReason = - providerId === 'opencode' - ? (capabilities?.plugins?.reason ?? - 'OpenCode does not support plugin management from Agent Teams.') - : (capabilities?.plugins?.reason ?? defaults.plugins.reason); + const isExternalRuntime = providerId === 'opencode' || providerId === 'kilocode'; + const pluginStatus = isExternalRuntime + ? 'unsupported' + : (capabilities?.plugins?.status ?? defaults.plugins.status); + const pluginReason = isExternalRuntime + ? (capabilities?.plugins?.reason ?? + `${getProviderDisplayName(providerId)} does not support plugin management from Agent Teams.`) + : (capabilities?.plugins?.reason ?? defaults.plugins.reason); return { plugins: { diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index 1a89b335..155bab03 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -23,6 +23,7 @@ import type { CodexModelCatalogFeatureFacade, CodexModelCatalogRequest, } from '@features/codex-model-catalog/main'; +import type { KilocodeModelCatalogFeatureFacade } from '@features/kilocode-model-catalog/main'; import type { CliProviderAuthMode, CliProviderConnectionInfo, @@ -65,12 +66,18 @@ const PROVIDER_CAPABILITIES: Record< supportsApiKey: false, configurableAuthModes: [], }, + kilocode: { + supportsOAuth: false, + supportsApiKey: true, + configurableAuthModes: ['api_key'], + }, }; const PROVIDER_API_KEY_ENV_VARS: Partial> = { anthropic: 'ANTHROPIC_API_KEY', codex: 'OPENAI_API_KEY', gemini: 'GEMINI_API_KEY', + kilocode: 'KILO_API_KEY', }; const ANTHROPIC_BASE_URL_ENV_VAR = 'ANTHROPIC_BASE_URL'; @@ -364,6 +371,10 @@ export class ProviderConnectionService { private codexAccountFeature: CodexAccountSnapshotReader | null = null; private codexModelCatalogFeature: Pick | null = null; + private kilocodeModelCatalogFeature: Pick< + KilocodeModelCatalogFeatureFacade, + 'getCatalog' + > | null = null; private readonly anthropicApiKeyVerificationCache = new Map< string, { result: AnthropicApiKeyVerificationResult; at: number } @@ -391,6 +402,12 @@ export class ProviderConnectionService { this.codexModelCatalogFeature = feature; } + setKilocodeModelCatalogFeature( + feature: Pick | null + ): void { + this.kilocodeModelCatalogFeature = feature; + } + async getCodexModelCatalog( request: CodexModelCatalogRequest = {} ): Promise { @@ -418,6 +435,10 @@ export class ProviderConnectionService { return this.configManager.getConfig().providerConnections.codex.preferredAuthMode; } + if (providerId === 'kilocode') { + return 'api_key'; + } + return null; } @@ -599,6 +620,16 @@ export class ProviderConnectionService { return env; } + if (providerId === 'kilocode') { + const apiKey = await this.resolveProviderApiKeyForEnv(env, 'kilocode', options); + if (apiKey) { + env.KILO_API_KEY = apiKey; + } else if (typeof env.KILO_API_KEY === 'string' && !env.KILO_API_KEY.trim()) { + delete env.KILO_API_KEY; + } + return env; + } + if (providerId !== 'codex') { return env; } @@ -645,7 +676,7 @@ export class ProviderConnectionService { options?: StoredApiKeyAccessOptions ): Promise { let nextEnv = env; - for (const providerId of ['anthropic', 'codex', 'gemini', 'opencode'] as const) { + for (const providerId of ['anthropic', 'codex', 'gemini', 'opencode', 'kilocode'] as const) { nextEnv = await this.applyConfiguredConnectionEnv(nextEnv, providerId, undefined, options); } return nextEnv; @@ -681,6 +712,14 @@ export class ProviderConnectionService { return env; } + if (providerId === 'kilocode') { + const apiKey = await this.resolveProviderApiKeyForEnv(env, 'kilocode', options); + if (apiKey) { + env.KILO_API_KEY = apiKey; + } + return env; + } + if (providerId !== 'codex') { return env; } @@ -722,7 +761,7 @@ export class ProviderConnectionService { options?: StoredApiKeyAccessOptions ): Promise { let nextEnv = env; - for (const providerId of ['anthropic', 'codex', 'gemini', 'opencode'] as const) { + for (const providerId of ['anthropic', 'codex', 'gemini', 'opencode', 'kilocode'] as const) { nextEnv = await this.augmentConfiguredConnectionEnv(nextEnv, providerId, undefined, options); } return nextEnv; @@ -765,6 +804,22 @@ export class ProviderConnectionService { ); } + if (providerId === 'kilocode') { + if (typeof env.KILO_API_KEY === 'string' && env.KILO_API_KEY.trim()) { + return null; + } + + if (await this.hasStoredApiKey('KILO_API_KEY')) { + return null; + } + + if (this.getExternalCredential('kilocode')?.value.trim()) { + return null; + } + + return 'KiloCode API key is not configured. Set KILO_API_KEY or add it in Provider Settings.'; + } + if (providerId !== 'codex') { return null; } @@ -847,7 +902,13 @@ export class ProviderConnectionService { async getConfiguredConnectionIssues( env: NodeJS.ProcessEnv, - providerIds: readonly CliProviderId[] = ['anthropic', 'codex', 'gemini', 'opencode'], + providerIds: readonly CliProviderId[] = [ + 'anthropic', + 'codex', + 'gemini', + 'opencode', + 'kilocode', + ], runtimeBackendOverrides?: Partial> ): Promise>> { const issues: Partial> = {}; @@ -916,6 +977,10 @@ export class ProviderConnectionService { return this.enrichAnthropicProviderStatus(withConnection); } + if (provider.providerId === 'kilocode') { + return this.enrichKilocodeProviderStatus(withConnection); + } + if (provider.providerId !== 'codex') { return withConnection; } @@ -980,6 +1045,33 @@ export class ProviderConnectionService { } } + private async enrichKilocodeProviderStatus( + provider: CliProviderStatus + ): Promise { + if (!this.kilocodeModelCatalogFeature || !provider.connection?.apiKeyConfigured) { + return provider; + } + try { + const catalog = await this.kilocodeModelCatalogFeature.getCatalog({ + apiKey: await this.resolveStoredOrExternalProviderApiKey('kilocode'), + }); + if (catalog.status === 'unavailable' || catalog.models.length === 0) { + return provider; + } + const models = catalog.models + .filter((m) => !m.hidden) + .map((m) => m.launchModel.trim()) + .filter(Boolean); + return { + ...provider, + models: models.length > 0 ? models : provider.models, + modelCatalog: catalog, + }; + } catch { + return provider; + } + } + private async enrichAnthropicProviderStatus( provider: CliProviderStatus ): Promise { @@ -1196,6 +1288,46 @@ export class ProviderConnectionService { return this.apiKeyService.lookupPreferred(envVarName); } + private async resolveStoredOrExternalProviderApiKey( + providerId: CliProviderId, + options?: StoredApiKeyAccessOptions + ): Promise { + const envVarName = PROVIDER_API_KEY_ENV_VARS[providerId]; + if (!envVarName) { + return null; + } + + const storedKey = await this.lookupStoredApiKeyValue(envVarName, options); + if (storedKey?.value.trim()) { + return storedKey.value.trim(); + } + + return this.getExternalCredential(providerId)?.value.trim() || null; + } + + private async resolveProviderApiKeyForEnv( + env: NodeJS.ProcessEnv, + providerId: CliProviderId, + options?: StoredApiKeyAccessOptions + ): Promise { + const envVarName = PROVIDER_API_KEY_ENV_VARS[providerId]; + if (!envVarName) { + return null; + } + + const storedKey = await this.lookupStoredApiKeyValue(envVarName, options); + if (storedKey?.value.trim()) { + return storedKey.value.trim(); + } + + const existingValue = env[envVarName]; + if (typeof existingValue === 'string' && existingValue.trim()) { + return existingValue.trim(); + } + + return this.getExternalCredential(providerId)?.value.trim() || null; + } + private getConfiguredCodexRuntimeBackend(runtimeBackendOverride?: string | null): 'codex-native' { if (runtimeBackendOverride === CODEX_NATIVE_BACKEND_ID) { return runtimeBackendOverride; @@ -1377,6 +1509,16 @@ export class ProviderConnectionService { } } + if (providerId === 'kilocode') { + const apiKey = this.getExternalEnvValue('KILO_API_KEY'); + if (apiKey) { + return { + label: 'Detected from KILO_API_KEY', + value: apiKey, + }; + } + } + return null; } diff --git a/src/main/services/runtime/providerRuntimeEnv.ts b/src/main/services/runtime/providerRuntimeEnv.ts index d2db5be6..63c23497 100644 --- a/src/main/services/runtime/providerRuntimeEnv.ts +++ b/src/main/services/runtime/providerRuntimeEnv.ts @@ -103,7 +103,12 @@ export function applyProviderRuntimeEnv( export function resolveRuntimeProviderId( providerId: RuntimeEnvProviderId | undefined ): CliProviderId { - if (providerId === 'codex' || providerId === 'gemini' || providerId === 'opencode') { + if ( + providerId === 'codex' || + providerId === 'gemini' || + providerId === 'opencode' || + providerId === 'kilocode' + ) { return providerId; } @@ -111,7 +116,10 @@ export function resolveRuntimeProviderId( } export function resolveTeamProviderId(providerId: TeamProviderId | undefined): TeamProviderId { - return providerId === 'codex' || providerId === 'gemini' || providerId === 'opencode' + return providerId === 'codex' || + providerId === 'gemini' || + providerId === 'opencode' || + providerId === 'kilocode' ? providerId : 'anthropic'; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 9da14501..e9088c87 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1311,6 +1311,8 @@ function getProviderRuntimeFailureLabel(providerId: TeamProviderId): string { return 'Gemini runtime'; case 'opencode': return 'OpenCode runtime'; + case 'kilocode': + return 'KiloCode runtime'; } } @@ -16569,6 +16571,17 @@ export class TeamProvisioningService { ? Array.from(new Set(providerModelChecks.map((check) => check.modelId))) : selectedModelIds; + if (providerId === 'kilocode') { + const kilocodeConnection = + await this.providerConnectionService.getConnectionInfo('kilocode'); + if (!kilocodeConnection.apiKeyConfigured) { + blockingMessages.push( + 'KiloCode: API key not configured. Set KILO_API_KEY or add it in Provider Settings.' + ); + } + continue; + } + if (providerId === 'opencode') { const adapter = this.getOpenCodeRuntimeAdapter(); if (!adapter) { diff --git a/src/main/services/team/runtime/TeamRuntimeAdapter.ts b/src/main/services/team/runtime/TeamRuntimeAdapter.ts index 22a2d909..cb732674 100644 --- a/src/main/services/team/runtime/TeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/TeamRuntimeAdapter.ts @@ -14,7 +14,13 @@ import type { TeamProvisioningSupportDiagnostic, } from '@shared/types'; -export const TEAM_RUNTIME_PROVIDER_IDS = ['anthropic', 'codex', 'gemini', 'opencode'] as const; +export const TEAM_RUNTIME_PROVIDER_IDS = [ + 'anthropic', + 'codex', + 'gemini', + 'opencode', + 'kilocode', +] as const; export type TeamRuntimeProviderId = (typeof TEAM_RUNTIME_PROVIDER_IDS)[number]; diff --git a/src/renderer/components/common/ProviderBrandLogo.tsx b/src/renderer/components/common/ProviderBrandLogo.tsx index f0cfbf73..ddd5e9bd 100644 --- a/src/renderer/components/common/ProviderBrandLogo.tsx +++ b/src/renderer/components/common/ProviderBrandLogo.tsx @@ -202,5 +202,7 @@ export const ProviderBrandLogo = ({ return ; case 'opencode': return ; + case 'kilocode': + return ; } }; diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index acd87928..d330cc1a 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -443,6 +443,8 @@ function getProviderLabel(providerId: CliProviderId): string { return 'Gemini'; case 'opencode': return 'OpenCode (200+ models)'; + case 'kilocode': + return 'KiloCode'; } } diff --git a/src/renderer/components/dashboard/providerDashboardRateLimits.ts b/src/renderer/components/dashboard/providerDashboardRateLimits.ts index 80d18b03..e0d09212 100644 --- a/src/renderer/components/dashboard/providerDashboardRateLimits.ts +++ b/src/renderer/components/dashboard/providerDashboardRateLimits.ts @@ -288,6 +288,7 @@ export function getDashboardRateLimitsForProvider( return getAnthropicDashboardRateLimits(provider); case 'gemini': case 'opencode': + case 'kilocode': return null; } } diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index df7e310c..9ebba874 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -62,7 +62,7 @@ const ProviderCapabilityCardSkeleton = ({ providerId, displayName, }: { - providerId: 'anthropic' | 'codex' | 'gemini' | 'opencode'; + providerId: 'anthropic' | 'codex' | 'gemini' | 'opencode' | 'kilocode'; displayName: string; }): React.JSX.Element => { const { t } = useAppTranslation('extensions'); diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index d494e068..ff6d89f5 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -68,7 +68,7 @@ import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contr import type { CliProviderAuthMode, CliProviderId, CliProviderStatus } from '@shared/types'; import type { ApiKeyEntry } from '@shared/types/extensions'; -type ApiKeyProviderId = 'anthropic' | 'codex' | 'gemini'; +type ApiKeyProviderId = 'anthropic' | 'codex' | 'gemini' | 'kilocode'; type PendingConnectionAction = 'auto' | 'oauth' | 'chatgpt' | 'api_key' | 'compatible' | null; interface ConnectionMethodCardOption { @@ -98,7 +98,7 @@ interface Props { const API_KEY_PROVIDER_CONFIG: Record< ApiKeyProviderId, { - envVarName: 'ANTHROPIC_API_KEY' | 'OPENAI_API_KEY' | 'GEMINI_API_KEY'; + envVarName: 'ANTHROPIC_API_KEY' | 'OPENAI_API_KEY' | 'GEMINI_API_KEY' | 'KILO_API_KEY'; name: string; title: string; description: string; @@ -129,6 +129,14 @@ const API_KEY_PROVIDER_CONFIG: Record< 'Use `GEMINI_API_KEY` for the Gemini API backend. CLI SDK and ADC do not require it.', placeholder: 'AIza...', }, + kilocode: { + envVarName: 'KILO_API_KEY', + name: 'KiloCode API Key', + title: 'API key', + description: + 'Use your KiloCode API key to authenticate with the KiloCode gateway and load available models.', + placeholder: 'kc-...', + }, }; const API_KEY_PROVIDER_TRANSLATION_KEYS = { @@ -151,7 +159,7 @@ const API_KEY_PROVIDER_TRANSLATION_KEYS = { placeholder: 'providerRuntime.apiKey.providers.gemini.placeholder', }, } as const satisfies Record< - ApiKeyProviderId, + Exclude, { name: string; title: string; @@ -165,7 +173,12 @@ const ANTHROPIC_COMPATIBLE_AUTH_TOKEN_NAME = 'Anthropic-compatible Auth Token'; const FIRST_PARTY_ANTHROPIC_HOSTS = new Set(['api.anthropic.com', 'api-staging.anthropic.com']); function isApiKeyProviderId(providerId: CliProviderId): providerId is ApiKeyProviderId { - return providerId === 'anthropic' || providerId === 'codex' || providerId === 'gemini'; + return ( + providerId === 'anthropic' || + providerId === 'codex' || + providerId === 'gemini' || + providerId === 'kilocode' + ); } function isCodexRuntimeInstalling( @@ -243,6 +256,8 @@ function getConnectionDescription( return t('providerRuntime.connection.descriptions.gemini'); case 'opencode': return t('providerRuntime.connection.descriptions.opencode'); + case 'kilocode': + return 'KiloCode uses an API key for authentication with the KiloCode gateway.'; } } @@ -259,6 +274,8 @@ function getRuntimeDescription( return t('providerRuntime.runtime.descriptions.gemini'); case 'opencode': return t('providerRuntime.runtime.descriptions.opencode'); + case 'kilocode': + return 'KiloCode uses its own managed runtime host. Configure an API key to use the KiloCode gateway.'; } } @@ -291,6 +308,10 @@ function getAuthModeDescription( } } + if (providerId === 'kilocode' && authMode === 'api_key') { + return 'Use a KiloCode API key for gateway access.'; + } + return ''; } @@ -1026,9 +1047,10 @@ export const ProviderRuntimeSettingsDialog = ({ ? selectedProvider.providerId : null; const apiKeyConfig = apiKeyProviderId ? API_KEY_PROVIDER_CONFIG[apiKeyProviderId] : null; - const apiKeyTranslationKeys = apiKeyProviderId - ? API_KEY_PROVIDER_TRANSLATION_KEYS[apiKeyProviderId] - : null; + const apiKeyTranslationKeys = + apiKeyProviderId && apiKeyProviderId !== 'kilocode' + ? API_KEY_PROVIDER_TRANSLATION_KEYS[apiKeyProviderId] + : null; const apiKeyDisplayConfig = apiKeyTranslationKeys ? { title: t(apiKeyTranslationKeys.title), @@ -1036,7 +1058,7 @@ export const ProviderRuntimeSettingsDialog = ({ name: t(apiKeyTranslationKeys.name), placeholder: t(apiKeyTranslationKeys.placeholder), } - : null; + : apiKeyConfig; const showApiKeyForm = selectedProvider && isApiKeyProviderId(selectedProvider.providerId) && diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx index c6e4c986..84c9a644 100644 --- a/src/renderer/components/settings/sections/CliStatusSection.tsx +++ b/src/renderer/components/settings/sections/CliStatusSection.tsx @@ -124,6 +124,8 @@ function getProviderLabel(providerId: CliProviderId): string { return 'Gemini'; case 'opencode': return 'OpenCode (200+ models)'; + case 'kilocode': + return 'KiloCode'; } } diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index 6baefabb..f02e984e 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -411,6 +411,7 @@ export const DateGroupedSessions = memo((): React.JSX.Element => { codex: 0, gemini: 0, opencode: 0, + kilocode: 0, }; for (const session of searchedSessions) { diff --git a/src/renderer/components/team/dialogs/teammateRuntimeCompatibility.tsx b/src/renderer/components/team/dialogs/teammateRuntimeCompatibility.tsx index 3fcf651d..cf194b60 100644 --- a/src/renderer/components/team/dialogs/teammateRuntimeCompatibility.tsx +++ b/src/renderer/components/team/dialogs/teammateRuntimeCompatibility.tsx @@ -65,6 +65,7 @@ const PROVIDER_LABELS: Record = { codex: 'Codex', gemini: 'Gemini', opencode: 'OpenCode', + kilocode: 'KiloCode', }; function getProviderLabel(providerId: TeamProviderId): string { diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index e4410c3c..9c1bd901 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -28,14 +28,24 @@ const CODEX_PROVIDER_INSTALL_REFRESH_ATTEMPTS = 3; const CODEX_PROVIDER_INSTALL_REFRESH_RETRY_DELAY_MS = 700; export const MULTIMODEL_PROVIDER_IDS: CliProviderId[] = isGeminiUiFrozen() + ? ['anthropic', 'codex', 'opencode', 'kilocode'] + : ['anthropic', 'codex', 'gemini', 'opencode', 'kilocode']; +const MULTIMODEL_PROVIDER_HYDRATION_IDS: CliProviderId[] = isGeminiUiFrozen() ? ['anthropic', 'codex', 'opencode'] : ['anthropic', 'codex', 'gemini', 'opencode']; const MULTIMODEL_PROVIDER_ID_SET = new Set(MULTIMODEL_PROVIDER_IDS); +const MULTIMODEL_PROVIDER_HYDRATION_ID_SET = new Set( + MULTIMODEL_PROVIDER_HYDRATION_IDS +); function isActiveMultimodelProviderId(providerId: CliProviderId): boolean { return MULTIMODEL_PROVIDER_ID_SET.has(providerId); } +function isHydratableMultimodelProviderId(providerId: CliProviderId): boolean { + return MULTIMODEL_PROVIDER_HYDRATION_ID_SET.has(providerId); +} + export function createLoadingMultimodelCliStatus(): CliInstallationStatus { const providers: CliProviderStatus[] = MULTIMODEL_PROVIDER_IDS.map((providerId) => ({ providerId, @@ -259,7 +269,7 @@ export function getIncompleteMultimodelProviderIds( return status.providers .filter( (provider) => - isActiveMultimodelProviderId(provider.providerId) && + isHydratableMultimodelProviderId(provider.providerId) && !isHydratedMultimodelProviderStatus(provider) ) .map((provider) => provider.providerId); @@ -275,7 +285,7 @@ export function getModelOnlyFallbackProviderIds( return status.providers .filter( (provider) => - isActiveMultimodelProviderId(provider.providerId) && + isHydratableMultimodelProviderId(provider.providerId) && isModelOnlyFallbackProviderStatus(provider) ) .map((provider) => provider.providerId); @@ -293,7 +303,7 @@ export function reconcileMultimodelProviderLoading( const providersById = new Map( status.providers.map((provider) => [provider.providerId, provider]) ); - return MULTIMODEL_PROVIDER_IDS.reduce>>( + return MULTIMODEL_PROVIDER_HYDRATION_IDS.reduce>>( (nextLoading, providerId) => { const provider = providersById.get(providerId); return { @@ -565,7 +575,9 @@ function isMultimodelCliStatus( function hasActiveProviderStatusLoading( providerLoading: Partial> ): boolean { - return MULTIMODEL_PROVIDER_IDS.some((providerId) => providerLoading[providerId] === true); + return MULTIMODEL_PROVIDER_HYDRATION_IDS.some( + (providerId) => providerLoading[providerId] === true + ); } function getAuthenticatedProvider(providers: CliProviderStatus[]): CliProviderStatus | null { @@ -604,6 +616,8 @@ function getProviderDisplayName(providerId: CliProviderId): string { return 'Gemini'; case 'opencode': return 'OpenCode (200+ models)'; + case 'kilocode': + return 'KiloCode'; } } @@ -757,7 +771,7 @@ export const createCliInstallerSlice: StateCreator [ + MULTIMODEL_PROVIDER_HYDRATION_IDS.map((providerId) => [ providerId, shouldMarkIncompleteProvidersLoading && initialStatus.installed && @@ -808,14 +822,14 @@ export const createCliInstallerSlice: StateCreator [ + MULTIMODEL_PROVIDER_HYDRATION_IDS.map((providerId) => [ providerId, !isHydratedMultimodelProviderStatus( nextCliStatus.providers.find((provider) => provider.providerId === providerId) ), ]) ) as Partial>; - pendingProviderIds = MULTIMODEL_PROVIDER_IDS.filter( + pendingProviderIds = MULTIMODEL_PROVIDER_HYDRATION_IDS.filter( (providerId) => nextProviderLoading[providerId] === true ); const nextAuthState = isMultimodelCliStatus(nextCliStatus) @@ -867,7 +881,7 @@ export const createCliInstallerSlice: StateCreator + MULTIMODEL_PROVIDER_HYDRATION_IDS.map((providerId) => get().fetchCliProviderStatus(providerId, { silent: false, epoch, @@ -911,7 +925,7 @@ export const createCliInstallerSlice: StateCreator = { codex: 'Codex', gemini: 'Gemini', opencode: 'OpenCode', + kilocode: 'KiloCode', }; const ANTHROPIC_ALIAS_LABELS = { @@ -140,6 +141,7 @@ const TEAM_PROVIDER_MODEL_OPTIONS: Record> = { @@ -149,6 +151,9 @@ const TEAM_PROVIDER_MODEL_ORDER: Record opencode: new Map( TEAM_PROVIDER_MODEL_OPTIONS.opencode.map((option, index) => [option.value, index]) ), + kilocode: new Map( + TEAM_PROVIDER_MODEL_OPTIONS.kilocode.map((option, index) => [option.value, index]) + ), }; function getKnownTeamProviderModelOption( @@ -337,6 +342,9 @@ export function getTeamModelBadgeLabel( if (providerId === 'opencode') { return getTeamModelLabel(trimmed) ?? trimmed; } + if (providerId === 'kilocode') { + return getTeamModelLabel(trimmed) ?? trimmed; + } return trimmed; } diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index 391f51e7..08a16268 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -33,7 +33,7 @@ export type CliPlatform = export type CliFlavor = 'claude' | 'agent_teams_orchestrator'; -export type CliProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode'; +export type CliProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode' | 'kilocode'; export type CliProviderAuthMode = 'auto' | 'oauth' | 'chatgpt' | 'api_key'; export const CLI_PROVIDER_STATUS_DEFERRED_MESSAGE = 'Provider status will refresh when needed.'; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index cff49c08..aa5dc98c 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -966,7 +966,7 @@ export interface TeamViewSnapshot { } export type EffortLevel = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'; -export type TeamProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode'; +export type TeamProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode' | 'kilocode'; export type TeamProviderBackendId = | 'auto' | 'adapter' diff --git a/src/shared/utils/teamProvider.ts b/src/shared/utils/teamProvider.ts index c0857f31..e93d35b2 100644 --- a/src/shared/utils/teamProvider.ts +++ b/src/shared/utils/teamProvider.ts @@ -3,7 +3,13 @@ import { parseOpenCodeQualifiedModelRef } from './opencodeModelRef'; import type { TeamProviderId } from '@shared/types'; export function isTeamProviderId(value: unknown): value is TeamProviderId { - return value === 'anthropic' || value === 'codex' || value === 'gemini' || value === 'opencode'; + return ( + value === 'anthropic' || + value === 'codex' || + value === 'gemini' || + value === 'opencode' || + value === 'kilocode' + ); } export function normalizeOptionalTeamProviderId(value: unknown): TeamProviderId | undefined { @@ -33,6 +39,15 @@ export function inferTeamProviderIdFromModel( return 'opencode'; } + if ( + normalized.startsWith('kilocode/') || + normalizedWithoutExtendedContextSuffix.startsWith('kilocode/') || + normalized.startsWith('kilo/') || + normalizedWithoutExtendedContextSuffix.startsWith('kilo/') + ) { + return 'kilocode'; + } + if ( normalized.startsWith('gpt-') || normalized.startsWith('codex') || diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts index 4ba73b75..05df5ac2 100644 --- a/test/main/services/infrastructure/CliInstallerService.test.ts +++ b/test/main/services/infrastructure/CliInstallerService.test.ts @@ -239,6 +239,7 @@ describe('CliInstallerService', () => { 'anthropic', 'codex', 'opencode', + 'kilocode', ]); expect(openCodeStatus).toMatchObject({ displayName: 'OpenCode (200+ models)', @@ -335,6 +336,7 @@ describe('CliInstallerService', () => { 'anthropic', 'codex', 'opencode', + 'kilocode', ]); expect(status.authLoggedIn).toBe(false); expect(status.authMethod).toBeNull(); @@ -380,7 +382,7 @@ describe('CliInstallerService', () => { expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled(); expect(status.authStatusChecking).toBe(false); expect(status.authLoggedIn).toBe(false); - expect(status.providers).toHaveLength(3); + expect(status.providers).toHaveLength(4); expect( status.providers.every( (provider) => provider.statusMessage === 'Provider status will refresh when needed.' @@ -1213,13 +1215,12 @@ describe('CliInstallerService', () => { createTestProviderStatus('codex', false, null), createTestProviderStatus('opencode', false, null), ]); - await Promise.resolve(); - await Promise.resolve(); - - const latest = service.getLatestStatusSnapshot(); - expect(latest?.authStatusChecking).toBe(false); - expect(latest?.authLoggedIn).toBe(true); - expect(latest?.authMethod).toBe('oauth_token'); + await vi.waitFor(() => { + const latest = service.getLatestStatusSnapshot(); + expect(latest?.authStatusChecking).toBe(false); + expect(latest?.authLoggedIn).toBe(true); + expect(latest?.authMethod).toBe('oauth_token'); + }); expect(status.authStatusChecking).toBe(true); expect(status.authLoggedIn).toBe(false); expect(status.providers.every((provider) => provider.statusMessage === 'Checking...')).toBe( diff --git a/test/main/services/runtime/ProviderConnectionService.test.ts b/test/main/services/runtime/ProviderConnectionService.test.ts index 2edded8e..372acb07 100644 --- a/test/main/services/runtime/ProviderConnectionService.test.ts +++ b/test/main/services/runtime/ProviderConnectionService.test.ts @@ -40,6 +40,7 @@ describe('ProviderConnectionService', () => { const originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY; const originalAnthropicAuthToken = process.env.ANTHROPIC_AUTH_TOKEN; const originalAnthropicBaseUrl = process.env.ANTHROPIC_BASE_URL; + const originalKiloApiKey = process.env.KILO_API_KEY; function createConfig( authMode: 'auto' | 'oauth' | 'api_key' = 'auto', @@ -136,6 +137,7 @@ describe('ProviderConnectionService', () => { delete process.env.ANTHROPIC_API_KEY; delete process.env.ANTHROPIC_AUTH_TOKEN; delete process.env.ANTHROPIC_BASE_URL; + delete process.env.KILO_API_KEY; }); afterEach(() => { @@ -168,6 +170,12 @@ describe('ProviderConnectionService', () => { } else { process.env.ANTHROPIC_BASE_URL = originalAnthropicBaseUrl; } + + if (originalKiloApiKey === undefined) { + delete process.env.KILO_API_KEY; + } else { + process.env.KILO_API_KEY = originalKiloApiKey; + } }); it('removes Anthropic environment credentials when OAuth mode is selected', async () => { @@ -639,6 +647,126 @@ describe('ProviderConnectionService', () => { expect(result.GEMINI_API_KEY).toBe('gemini-stored-key'); }); + it('injects stored KiloCode API keys for runtime launches', async () => { + const lookupPreferred = vi.fn().mockResolvedValue({ + envVarName: 'KILO_API_KEY', + value: 'kilo-stored-key', + }); + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred, + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + + const result = await service.applyConfiguredConnectionEnv({}, 'kilocode'); + + expect(lookupPreferred).toHaveBeenCalledWith('KILO_API_KEY'); + expect(result.KILO_API_KEY).toBe('kilo-stored-key'); + await expect(service.getConfiguredConnectionIssue(result, 'kilocode')).resolves.toBeNull(); + }); + + it('reports a missing KiloCode API key before runtime launches', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + + const issue = await service.getConfiguredConnectionIssue({}, 'kilocode'); + + expect(issue).toContain('KiloCode API key is not configured'); + }); + + it('passes stored KiloCode API keys to catalog hydration', async () => { + const lookupPreferred = vi.fn().mockResolvedValue({ + envVarName: 'KILO_API_KEY', + value: 'kilo-stored-key', + }); + const getCatalog = vi.fn().mockResolvedValue({ + schemaVersion: 1, + providerId: 'kilocode', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-05-25T00:00:00.000Z', + staleAt: '2026-05-25T00:10:00.000Z', + defaultModelId: 'kilo/test', + defaultLaunchModel: 'kilo/test', + models: [ + { + id: 'kilo/test', + launchModel: 'kilo/test', + displayName: 'Kilo Test', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'app-server', + }, + ], + diagnostics: { + configReadState: 'skipped', + appServerState: 'healthy', + message: null, + code: null, + }, + }); + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred, + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + service.setKilocodeModelCatalogFeature({ getCatalog }); + + const enriched = await service.enrichProviderStatus({ + providerId: 'kilocode', + displayName: 'KiloCode', + supported: true, + authenticated: true, + authMethod: 'api_key', + verificationState: 'verified', + modelVerificationState: 'idle', + statusMessage: null, + models: [], + modelAvailability: [], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: false, + extensions: { + plugins: { supported: false, status: 'unsupported' }, + mcp: { supported: false, status: 'unsupported' }, + skills: { supported: false, status: 'unsupported' }, + apiKeys: { supported: true, status: 'supported' }, + }, + }, + backend: null, + } as never); + + expect(getCatalog).toHaveBeenCalledWith({ apiKey: 'kilo-stored-key' }); + expect(enriched.models).toEqual(['kilo/test']); + }); + it('reports a missing Anthropic API key when api_key mode is selected', async () => { const { ProviderConnectionService } = await import('@main/services/runtime/ProviderConnectionService'); diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts index b6168105..f9053661 100644 --- a/test/renderer/components/cli/CliStatusVisibility.test.ts +++ b/test/renderer/components/cli/CliStatusVisibility.test.ts @@ -2696,7 +2696,7 @@ describe('CLI status visibility during completed install state', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('Providers: 1/3 connected'); + expect(host.textContent).toContain('Providers: 1/4 connected'); expect(host.textContent).toContain('5h left'); expect(host.textContent).toContain('1w left'); expect(host.textContent).toContain('resets'); diff --git a/test/renderer/components/extensions/skills/SkillsPanel.test.ts b/test/renderer/components/extensions/skills/SkillsPanel.test.ts index b71bae74..cfab8053 100644 --- a/test/renderer/components/extensions/skills/SkillsPanel.test.ts +++ b/test/renderer/components/extensions/skills/SkillsPanel.test.ts @@ -1,11 +1,12 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; + +import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; import type { CliInstallationStatus } from '@shared/types'; import type { SkillCatalogItem } from '@shared/types/extensions'; -import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; interface StoreState { fetchSkillsCatalog: ReturnType; @@ -559,7 +560,7 @@ describe('SkillsPanel', () => { }); expect(host.textContent).toContain( - 'Shared skills in `.claude`, `.cursor`, and `.agents` are available to Anthropic, Codex, and OpenCode (200+ models).' + 'Shared skills in `.claude`, `.cursor`, and `.agents` are available to Anthropic, Codex, OpenCode (200+ models), and KiloCode.' ); expect(host.textContent).toContain('Codex only'); diff --git a/test/setup.ts b/test/setup.ts index 04627612..0ebb1f8f 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -62,6 +62,49 @@ vi.mock('@sentry/electron/main', () => sentryNoOp); vi.mock('@sentry/electron/renderer', () => sentryNoOp); vi.mock('@sentry/react', () => sentryNoOp); +function createInMemoryStorage(): Storage { + const values = new Map(); + + return { + get length() { + return values.size; + }, + clear() { + values.clear(); + }, + getItem(key: string) { + return values.get(key) ?? null; + }, + key(index: number) { + return Array.from(values.keys())[index] ?? null; + }, + removeItem(key: string) { + values.delete(key); + }, + setItem(key: string, value: string) { + values.set(key, String(value)); + }, + }; +} + +function hasStorageApi(value: unknown): value is Storage { + return ( + typeof value === 'object' && + value !== null && + typeof (value as Storage).getItem === 'function' && + typeof (value as Storage).setItem === 'function' && + typeof (value as Storage).removeItem === 'function' && + typeof (value as Storage).clear === 'function' + ); +} + +if (!hasStorageApi(globalThis.localStorage)) { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: createInMemoryStorage(), + }); +} + // Mock HOME for tests that need a predictable home path. It must be writable: // some services persist state in best-effort background writes after a test has // already reset path overrides.