From cc10485f0cc2cf5b0ff672d7614931c35ec67ee4 Mon Sep 17 00:00:00 2001 From: Jan Date: Mon, 25 May 2026 20:12:37 +0200 Subject: [PATCH] feat(kilocode): add provider support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add KiloCode as a first-class provider with HTTP-based model catalog Implements KiloCode (kilo.ai gateway) support following repo design principles, independently of the OpenCode implementation. Key changes: - Add 'kilocode' to CliProviderId, TeamProviderId, MemberWorkSyncProviderId - Create kilocode-model-catalog feature: HTTP client fetching models from kilo.ai /models endpoint (not /v1/models — different gateway path) - Add KILO_API_KEY env var for authentication - Wire kilocode into provider routing, capabilities, and UI labels - Add 'kilo' brand icon alias in providerBrandIcons (auto-fetches from models.dev) - KiloCode status is managed via the HTTP gateway, not the multimodel bridge * Fix: preserve non-bridge providers (kilocode) when updating provider status The multimodel bridge only returns status for anthropic/codex/gemini/opencode. When checkAuthStatus replaced result.providers with the bridge response, kilocode was lost from the provider list and never appeared in the UI. Now merge bridge providers with the initial list, keeping any provider not covered by the bridge so kilocode shows up in the Extensions panel. * Fix: resolve KiloCode status after bridge merge, skip bridge refresh for non-bridge providers - resolveKilocodeStatus() gives kilocode a settled verificationState:'verified' status so isHydratedMultimodelProviderStatus() returns true and the loading spinner stops - Status reflects KILO_API_KEY presence: authenticated+supported when set, else clear message - fetchCliStatus() now skips fetchCliProviderStatus for non-bridge providers (kilocode) so the Claude Code CLI is not queried for kilocode, preventing error status overwrites * Add KiloCode to API key provider system in settings dialog isApiKeyProviderId now includes kilocode, so the API key form renders in the Provider Settings dialog instead of showing an empty modal. Adds KILO_API_KEY config with placeholder and description. * Fix KiloCode models endpoint: /api/gateway/models per docs * Fix: short-circuit getProviderStatus/verifyProviderModels for kilocode The Claude Code CLI only accepts anthropic and codex for --provider. Calling it with kilocode caused the blinking modal error. resolveKilocodeProviderStatus() returns status directly from env without touching the CLI binary — no bridge, no --provider flag. * Fix: resolveKilocodeProviderStatus reads from app key store via enrichProviderStatus process.env.KILO_API_KEY was only set for users who configured it in their shell environment. The UI stores the key in the app's encrypted key store (ApiKeyService), which enrichProviderStatus checks via hasStoredProviderApiKey. Now resolveKilocodeProviderStatus() calls providerConnectionService.enrichProviderStatus() so both the app key store and env var are checked — the same way anthropic/gemini work. * Wire KiloCode model catalog into provider status — models now load from gateway - ProviderConnectionService: add setKilocodeModelCatalogFeature() and enrichKilocodeProviderStatus() which fetches models from the gateway API and populates provider.models when the API key is configured - main/index.ts: create KilocodeModelCatalogFeature at startup and inject it into ProviderConnectionService, same lifecycle as Codex catalog * Fix: skip Claude CLI probe for kilocode in prepareForProvisioning The generic probe path calls probeClaudeRuntime with CLAUDE_CODE_ENTRY_PROVIDER=kilocode which causes the CLI to hang — freezing the Create Team dialog until timeout. Add an explicit kilocode case that short-circuits to an API key presence check (via providerConnectionService.getConnectionInfo) without touching the Claude binary, same pattern as the opencode adapter bypass. * Fix vitest localStorage fallback * test(kilocode): update provider visibility expectations --------- Co-authored-by: 777genius --- .../kilocode-model-catalog/contracts/dto.ts | 11 ++ .../kilocode-model-catalog/contracts/index.ts | 6 + .../domain/kilocodeModelCatalogFallback.ts | 32 +++ src/features/kilocode-model-catalog/index.ts | 8 + .../createKilocodeModelCatalogFeature.ts | 186 ++++++++++++++++++ .../kilocode-model-catalog/main/index.ts | 5 + .../InMemoryKilocodeModelCatalogCache.ts | 37 ++++ .../infrastructure/KilocodeGatewayClient.ts | 101 ++++++++++ .../member-work-sync/contracts/types.ts | 2 +- .../renderer/ui/providerBrandIcons.tsx | 1 + .../core/domain/WorkspaceTrustTypes.ts | 8 +- src/main/index.ts | 12 ++ src/main/ipc/cliInstaller.ts | 7 +- .../infrastructure/CliInstallerService.ts | 98 ++++++++- .../runtime/ClaudeMultimodelBridgeService.ts | 19 +- .../runtime/ProviderConnectionService.ts | 148 +++++++++++++- .../services/runtime/providerRuntimeEnv.ts | 12 +- .../services/team/TeamProvisioningService.ts | 13 ++ .../team/runtime/TeamRuntimeAdapter.ts | 8 +- .../components/common/ProviderBrandLogo.tsx | 2 + .../components/dashboard/CliStatusBanner.tsx | 2 + .../dashboard/providerDashboardRateLimits.ts | 1 + .../extensions/ExtensionStoreView.tsx | 2 +- .../runtime/ProviderRuntimeSettingsDialog.tsx | 38 +++- .../settings/sections/CliStatusSection.tsx | 2 + .../sidebar/DateGroupedSessions.tsx | 1 + .../dialogs/teammateRuntimeCompatibility.tsx | 1 + .../store/slices/cliInstallerSlice.ts | 32 ++- src/renderer/utils/teamModelCatalog.ts | 8 + src/shared/types/cliInstaller.ts | 2 +- src/shared/types/team.ts | 2 +- src/shared/utils/teamProvider.ts | 17 +- .../CliInstallerService.test.ts | 17 +- .../runtime/ProviderConnectionService.test.ts | 128 ++++++++++++ .../cli/CliStatusVisibility.test.ts | 2 +- .../extensions/skills/SkillsPanel.test.ts | 5 +- test/setup.ts | 43 ++++ 37 files changed, 964 insertions(+), 55 deletions(-) create mode 100644 src/features/kilocode-model-catalog/contracts/dto.ts create mode 100644 src/features/kilocode-model-catalog/contracts/index.ts create mode 100644 src/features/kilocode-model-catalog/core/domain/kilocodeModelCatalogFallback.ts create mode 100644 src/features/kilocode-model-catalog/index.ts create mode 100644 src/features/kilocode-model-catalog/main/composition/createKilocodeModelCatalogFeature.ts create mode 100644 src/features/kilocode-model-catalog/main/index.ts create mode 100644 src/features/kilocode-model-catalog/main/infrastructure/InMemoryKilocodeModelCatalogCache.ts create mode 100644 src/features/kilocode-model-catalog/main/infrastructure/KilocodeGatewayClient.ts 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.