feat(kilocode): add provider support

* 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 <quantjumppro@gmail.com>
This commit is contained in:
Jan 2026-05-25 20:12:37 +02:00 committed by GitHub
parent 9758fafd10
commit cc10485f0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 964 additions and 55 deletions

View file

@ -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;

View file

@ -0,0 +1,6 @@
export type {
KilocodeModelCatalogDto,
KilocodeModelCatalogItemDto,
KilocodeModelCatalogSourceDto,
KilocodeModelCatalogStatusDto,
} from './dto';

View file

@ -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',
},
];
}

View file

@ -0,0 +1,8 @@
export type {
KilocodeModelCatalogDto,
KilocodeModelCatalogItemDto,
KilocodeModelCatalogSourceDto,
KilocodeModelCatalogStatusDto,
} from './contracts';
export type { KilocodeModelCatalogFeatureFacade, KilocodeModelCatalogRequest } from './main';
export { createKilocodeModelCatalogFeature } from './main';

View file

@ -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<Logger, 'warn'>;
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<KilocodeModelCatalogDto>;
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<string, Promise<KilocodeModelCatalogDto>>();
const client = new KilocodeGatewayClient();
async function getCatalog(
request: KilocodeModelCatalogRequest = {}
): Promise<KilocodeModelCatalogDto> {
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<KilocodeModelCatalogDto> => {
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();
},
};
}

View file

@ -0,0 +1,5 @@
export type {
KilocodeModelCatalogFeatureFacade,
KilocodeModelCatalogRequest,
} from './composition/createKilocodeModelCatalogFeature';
export { createKilocodeModelCatalogFeature } from './composition/createKilocodeModelCatalogFeature';

View file

@ -0,0 +1,37 @@
import type { KilocodeModelCatalogDto } from '../../contracts';
interface CacheEntry {
value: KilocodeModelCatalogDto;
observedAt: number;
}
export class InMemoryKilocodeModelCatalogCache {
private readonly entries = new Map<string, CacheEntry>();
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();
}
}

View file

@ -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<KilocodeGatewayModel[]> {
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<GatewayModelsResponse> {
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();
});
}
}

View file

@ -20,7 +20,7 @@ export type MemberWorkSyncActionableWorkPriority =
| 'blocked' | 'blocked'
| 'needs_clarification'; | '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'; export type MemberWorkSyncReviewObligation = 'review_pickup_required' | 'review_in_progress';

View file

@ -384,6 +384,7 @@ const BRAND_ALIASES: Record<string, string> = {
'gitlab-duo': 'gitlab-duo', 'gitlab-duo': 'gitlab-duo',
'google-vertex': 'google-vertex', 'google-vertex': 'google-vertex',
'hugging-face': 'huggingface', 'hugging-face': 'huggingface',
kilocode: 'kilo',
'mistral-ai': 'mistral', 'mistral-ai': 'mistral',
'ollama-cloud': 'ollama-cloud', 'ollama-cloud': 'ollama-cloud',
'opencode-zen': 'opencode', 'opencode-zen': 'opencode',

View file

@ -1,4 +1,10 @@
export type WorkspaceTrustProvider = 'claude' | 'anthropic' | 'codex' | 'gemini' | 'opencode'; export type WorkspaceTrustProvider =
| 'claude'
| 'anthropic'
| 'codex'
| 'gemini'
| 'opencode'
| 'kilocode';
export type WorkspaceTrustWorkspaceSource = export type WorkspaceTrustWorkspaceSource =
| 'team-root' | 'team-root'

View file

@ -32,6 +32,10 @@ import {
type CodexModelCatalogFeatureFacade, type CodexModelCatalogFeatureFacade,
createCodexModelCatalogFeature, createCodexModelCatalogFeature,
} from '@features/codex-model-catalog/main'; } from '@features/codex-model-catalog/main';
import {
type KilocodeModelCatalogFeatureFacade,
createKilocodeModelCatalogFeature,
} from '@features/kilocode-model-catalog/main';
import { import {
createMemberLogStreamFeature, createMemberLogStreamFeature,
registerMemberLogStreamIpc, registerMemberLogStreamIpc,
@ -899,6 +903,7 @@ let updaterService: UpdaterService;
let sshConnectionManager: SshConnectionManager; let sshConnectionManager: SshConnectionManager;
let codexAccountFeature: CodexAccountFeatureFacade | null = null; let codexAccountFeature: CodexAccountFeatureFacade | null = null;
let codexModelCatalogFeature: CodexModelCatalogFeatureFacade | null = null; let codexModelCatalogFeature: CodexModelCatalogFeatureFacade | null = null;
let kilocodeModelCatalogFeature: KilocodeModelCatalogFeatureFacade | null = null;
let recentProjectsFeature: RecentProjectsFeatureFacade; let recentProjectsFeature: RecentProjectsFeatureFacade;
let runtimeProviderManagementFeature: RuntimeProviderManagementFeatureFacade; let runtimeProviderManagementFeature: RuntimeProviderManagementFeatureFacade;
let memberWorkSyncFeature: MemberWorkSyncFeatureFacade | null = null; let memberWorkSyncFeature: MemberWorkSyncFeatureFacade | null = null;
@ -2103,6 +2108,10 @@ async function initializeServices(): Promise<void> {
codexAccountFeature, codexAccountFeature,
}); });
providerConnectionService.setCodexModelCatalogFeature(codexModelCatalogFeature); providerConnectionService.setCodexModelCatalogFeature(codexModelCatalogFeature);
kilocodeModelCatalogFeature = createKilocodeModelCatalogFeature({
logger: createLogger('Feature:KilocodeModelCatalog'),
});
providerConnectionService.setKilocodeModelCatalogFeature(kilocodeModelCatalogFeature);
// startProcessHealthPolling() is deferred to after window creation // startProcessHealthPolling() is deferred to after window creation
// (did-finish-load handler) to avoid thread pool contention at startup. // (did-finish-load handler) to avoid thread pool contention at startup.
@ -2355,10 +2364,13 @@ async function shutdownServices(): Promise<void> {
await runShutdownStep('skills watcher stop', () => skillsWatcherService?.stopAll()); await runShutdownStep('skills watcher stop', () => skillsWatcherService?.stopAll());
await runShutdownStep('provider connection feature detach', () => { await runShutdownStep('provider connection feature detach', () => {
providerConnectionService.setCodexModelCatalogFeature(null); providerConnectionService.setCodexModelCatalogFeature(null);
providerConnectionService.setKilocodeModelCatalogFeature(null);
providerConnectionService.setCodexAccountFeature(null); providerConnectionService.setCodexAccountFeature(null);
}); });
await runShutdownStep('Codex model catalog dispose', () => codexModelCatalogFeature?.dispose()); await runShutdownStep('Codex model catalog dispose', () => codexModelCatalogFeature?.dispose());
codexModelCatalogFeature = null; codexModelCatalogFeature = null;
kilocodeModelCatalogFeature?.invalidate();
kilocodeModelCatalogFeature = null;
await runShutdownStep('Codex account dispose', () => codexAccountFeature?.dispose()); await runShutdownStep('Codex account dispose', () => codexAccountFeature?.dispose());
codexAccountFeature = null; codexAccountFeature = null;
await runShutdownStep('member work sync dispose', () => memberWorkSyncFeature?.dispose()); await runShutdownStep('member work sync dispose', () => memberWorkSyncFeature?.dispose());

View file

@ -44,7 +44,12 @@ const cachedStatus = new Map<
>(); >();
let statusCacheGeneration = 0; let statusCacheGeneration = 0;
const STATUS_CACHE_TTL_MS = 5_000; const STATUS_CACHE_TTL_MS = 5_000;
const FRONTEND_MULTIMODEL_PROVIDER_IDS = new Set<CliProviderId>(['anthropic', 'codex', 'opencode']); const FRONTEND_MULTIMODEL_PROVIDER_IDS = new Set<CliProviderId>([
'anthropic',
'codex',
'opencode',
'kilocode',
]);
function isFrontendMultimodelProviderId(providerId: CliProviderId): boolean { function isFrontendMultimodelProviderId(providerId: CliProviderId): boolean {
return FRONTEND_MULTIMODEL_PROVIDER_IDS.has(providerId); return FRONTEND_MULTIMODEL_PROVIDER_IDS.has(providerId);

View file

@ -45,6 +45,7 @@ import {
type ProviderModelAvailabilityContext, type ProviderModelAvailabilityContext,
type ProviderModelAvailabilitySnapshot, type ProviderModelAvailabilitySnapshot,
} from '../runtime/CliProviderModelAvailabilityService'; } from '../runtime/CliProviderModelAvailabilityService';
import { providerConnectionService } from '../runtime/ProviderConnectionService';
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver'; import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '../team/cliFlavor'; 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'; 'https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases';
const CLI_INSTALLER_PROGRESS_CHANNEL = 'cliInstaller:progress'; 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<CliProviderId>( const FRONTEND_MULTIMODEL_PROVIDER_ID_SET = new Set<CliProviderId>(
FRONTEND_MULTIMODEL_PROVIDER_IDS FRONTEND_MULTIMODEL_PROVIDER_IDS
); );
@ -86,6 +92,8 @@ function getProviderDisplayName(providerId: CliProviderId): string {
return 'Gemini'; return 'Gemini';
case 'opencode': case 'opencode':
return 'OpenCode (200+ models)'; return 'OpenCode (200+ models)';
case 'kilocode':
return 'KiloCode';
} }
} }
@ -919,6 +927,10 @@ export class CliInstallerService {
background: false, background: false,
}); });
if (providerId === 'kilocode') {
return this.resolveKilocodeProviderStatus();
}
const binaryPath = await ClaudeBinaryResolver.resolve(); const binaryPath = await ClaudeBinaryResolver.resolve();
if (!binaryPath) { if (!binaryPath) {
return null; return null;
@ -959,6 +971,10 @@ export class CliInstallerService {
background: false, background: false,
}); });
if (providerId === 'kilocode') {
return this.resolveKilocodeProviderStatus();
}
const binaryPath = await ClaudeBinaryResolver.resolve(); const binaryPath = await ClaudeBinaryResolver.resolve();
if (!binaryPath) { if (!binaryPath) {
return null; return null;
@ -1204,6 +1220,53 @@ export class CliInstallerService {
result.authMethod = null; result.authMethod = null;
} }
private async resolveKilocodeProviderStatus(): Promise<CliProviderStatus> {
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 { private markProvidersDeferred(result: CliInstallationStatus): void {
if (result.flavor !== 'agent_teams_orchestrator') { if (result.flavor !== 'agent_teams_orchestrator') {
return; return;
@ -1241,13 +1304,38 @@ export class CliInstallerService {
if (result.flavor === 'agent_teams_orchestrator') { if (result.flavor === 'agent_teams_orchestrator') {
result.authStatusChecking = true; result.authStatusChecking = true;
let statusTarget = result; let statusTarget = result;
const applyProviders = (providersSnapshot: CliProviderStatus[], final: boolean): void => { const buildFrontendProviders = async (
providersSnapshot: CliProviderStatus[]
): Promise<CliProviderStatus[]> => {
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<void> => {
if (generation !== this.statusGatherGeneration) { if (generation !== this.statusGatherGeneration) {
return; return;
} }
const target = statusTarget; const target = statusTarget;
const frontendProviders = filterFrontendMultimodelProviders(providersSnapshot); const frontendProviders = await buildFrontendProviders(
filterFrontendMultimodelProviders(providersSnapshot)
);
if (generation !== this.statusGatherGeneration) {
return;
}
target.providers = frontendProviders; target.providers = frontendProviders;
target.authLoggedIn = hasFrontendAuthenticatedProvider(frontendProviders); target.authLoggedIn = hasFrontendAuthenticatedProvider(frontendProviders);
target.authMethod = getFrontendAuthenticatedProvider(frontendProviders)?.authMethod ?? null; target.authMethod = getFrontendAuthenticatedProvider(frontendProviders)?.authMethod ?? null;
@ -1260,10 +1348,10 @@ export class CliInstallerService {
const completion = this.multimodelBridgeService const completion = this.multimodelBridgeService
.getProviderStatuses(binaryPath, (providersSnapshot) => { .getProviderStatuses(binaryPath, (providersSnapshot) => {
applyProviders(providersSnapshot, false); void applyProviders(providersSnapshot, false);
}) })
.then((providers) => { .then((providers) => {
applyProviders(providers, true); return applyProviders(providers, true);
}) })
.catch((error) => { .catch((error) => {
if (generation !== this.statusGatherGeneration) { if (generation !== this.statusGatherGeneration) {

View file

@ -335,6 +335,8 @@ function getProviderDisplayName(providerId: CliProviderId): string {
return 'Gemini'; return 'Gemini';
case 'opencode': case 'opencode':
return 'OpenCode (200+ models)'; return 'OpenCode (200+ models)';
case 'kilocode':
return 'KiloCode';
} }
} }
@ -421,15 +423,14 @@ function mapRuntimeExtensionCapabilities(
const defaults = capabilities const defaults = capabilities
? createDefaultCliExtensionCapabilities() ? createDefaultCliExtensionCapabilities()
: createLegacyRuntimeFallbackCliExtensionCapabilities(); : createLegacyRuntimeFallbackCliExtensionCapabilities();
const pluginStatus = const isExternalRuntime = providerId === 'opencode' || providerId === 'kilocode';
providerId === 'opencode' const pluginStatus = isExternalRuntime
? 'unsupported' ? 'unsupported'
: (capabilities?.plugins?.status ?? defaults.plugins.status); : (capabilities?.plugins?.status ?? defaults.plugins.status);
const pluginReason = const pluginReason = isExternalRuntime
providerId === 'opencode' ? (capabilities?.plugins?.reason ??
? (capabilities?.plugins?.reason ?? `${getProviderDisplayName(providerId)} does not support plugin management from Agent Teams.`)
'OpenCode does not support plugin management from Agent Teams.') : (capabilities?.plugins?.reason ?? defaults.plugins.reason);
: (capabilities?.plugins?.reason ?? defaults.plugins.reason);
return { return {
plugins: { plugins: {

View file

@ -23,6 +23,7 @@ import type {
CodexModelCatalogFeatureFacade, CodexModelCatalogFeatureFacade,
CodexModelCatalogRequest, CodexModelCatalogRequest,
} from '@features/codex-model-catalog/main'; } from '@features/codex-model-catalog/main';
import type { KilocodeModelCatalogFeatureFacade } from '@features/kilocode-model-catalog/main';
import type { import type {
CliProviderAuthMode, CliProviderAuthMode,
CliProviderConnectionInfo, CliProviderConnectionInfo,
@ -65,12 +66,18 @@ const PROVIDER_CAPABILITIES: Record<
supportsApiKey: false, supportsApiKey: false,
configurableAuthModes: [], configurableAuthModes: [],
}, },
kilocode: {
supportsOAuth: false,
supportsApiKey: true,
configurableAuthModes: ['api_key'],
},
}; };
const PROVIDER_API_KEY_ENV_VARS: Partial<Record<CliProviderId, string>> = { const PROVIDER_API_KEY_ENV_VARS: Partial<Record<CliProviderId, string>> = {
anthropic: 'ANTHROPIC_API_KEY', anthropic: 'ANTHROPIC_API_KEY',
codex: 'OPENAI_API_KEY', codex: 'OPENAI_API_KEY',
gemini: 'GEMINI_API_KEY', gemini: 'GEMINI_API_KEY',
kilocode: 'KILO_API_KEY',
}; };
const ANTHROPIC_BASE_URL_ENV_VAR = 'ANTHROPIC_BASE_URL'; const ANTHROPIC_BASE_URL_ENV_VAR = 'ANTHROPIC_BASE_URL';
@ -364,6 +371,10 @@ export class ProviderConnectionService {
private codexAccountFeature: CodexAccountSnapshotReader | null = null; private codexAccountFeature: CodexAccountSnapshotReader | null = null;
private codexModelCatalogFeature: Pick<CodexModelCatalogFeatureFacade, 'getCatalog'> | null = private codexModelCatalogFeature: Pick<CodexModelCatalogFeatureFacade, 'getCatalog'> | null =
null; null;
private kilocodeModelCatalogFeature: Pick<
KilocodeModelCatalogFeatureFacade,
'getCatalog'
> | null = null;
private readonly anthropicApiKeyVerificationCache = new Map< private readonly anthropicApiKeyVerificationCache = new Map<
string, string,
{ result: AnthropicApiKeyVerificationResult; at: number } { result: AnthropicApiKeyVerificationResult; at: number }
@ -391,6 +402,12 @@ export class ProviderConnectionService {
this.codexModelCatalogFeature = feature; this.codexModelCatalogFeature = feature;
} }
setKilocodeModelCatalogFeature(
feature: Pick<KilocodeModelCatalogFeatureFacade, 'getCatalog'> | null
): void {
this.kilocodeModelCatalogFeature = feature;
}
async getCodexModelCatalog( async getCodexModelCatalog(
request: CodexModelCatalogRequest = {} request: CodexModelCatalogRequest = {}
): Promise<CodexModelCatalogDto | null> { ): Promise<CodexModelCatalogDto | null> {
@ -418,6 +435,10 @@ export class ProviderConnectionService {
return this.configManager.getConfig().providerConnections.codex.preferredAuthMode; return this.configManager.getConfig().providerConnections.codex.preferredAuthMode;
} }
if (providerId === 'kilocode') {
return 'api_key';
}
return null; return null;
} }
@ -599,6 +620,16 @@ export class ProviderConnectionService {
return env; 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') { if (providerId !== 'codex') {
return env; return env;
} }
@ -645,7 +676,7 @@ export class ProviderConnectionService {
options?: StoredApiKeyAccessOptions options?: StoredApiKeyAccessOptions
): Promise<NodeJS.ProcessEnv> { ): Promise<NodeJS.ProcessEnv> {
let nextEnv = env; 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); nextEnv = await this.applyConfiguredConnectionEnv(nextEnv, providerId, undefined, options);
} }
return nextEnv; return nextEnv;
@ -681,6 +712,14 @@ export class ProviderConnectionService {
return env; 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') { if (providerId !== 'codex') {
return env; return env;
} }
@ -722,7 +761,7 @@ export class ProviderConnectionService {
options?: StoredApiKeyAccessOptions options?: StoredApiKeyAccessOptions
): Promise<NodeJS.ProcessEnv> { ): Promise<NodeJS.ProcessEnv> {
let nextEnv = env; 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); nextEnv = await this.augmentConfiguredConnectionEnv(nextEnv, providerId, undefined, options);
} }
return nextEnv; 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') { if (providerId !== 'codex') {
return null; return null;
} }
@ -847,7 +902,13 @@ export class ProviderConnectionService {
async getConfiguredConnectionIssues( async getConfiguredConnectionIssues(
env: NodeJS.ProcessEnv, env: NodeJS.ProcessEnv,
providerIds: readonly CliProviderId[] = ['anthropic', 'codex', 'gemini', 'opencode'], providerIds: readonly CliProviderId[] = [
'anthropic',
'codex',
'gemini',
'opencode',
'kilocode',
],
runtimeBackendOverrides?: Partial<Record<CliProviderId, string>> runtimeBackendOverrides?: Partial<Record<CliProviderId, string>>
): Promise<Partial<Record<CliProviderId, string>>> { ): Promise<Partial<Record<CliProviderId, string>>> {
const issues: Partial<Record<CliProviderId, string>> = {}; const issues: Partial<Record<CliProviderId, string>> = {};
@ -916,6 +977,10 @@ export class ProviderConnectionService {
return this.enrichAnthropicProviderStatus(withConnection); return this.enrichAnthropicProviderStatus(withConnection);
} }
if (provider.providerId === 'kilocode') {
return this.enrichKilocodeProviderStatus(withConnection);
}
if (provider.providerId !== 'codex') { if (provider.providerId !== 'codex') {
return withConnection; return withConnection;
} }
@ -980,6 +1045,33 @@ export class ProviderConnectionService {
} }
} }
private async enrichKilocodeProviderStatus(
provider: CliProviderStatus
): Promise<CliProviderStatus> {
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( private async enrichAnthropicProviderStatus(
provider: CliProviderStatus provider: CliProviderStatus
): Promise<CliProviderStatus> { ): Promise<CliProviderStatus> {
@ -1196,6 +1288,46 @@ export class ProviderConnectionService {
return this.apiKeyService.lookupPreferred(envVarName); return this.apiKeyService.lookupPreferred(envVarName);
} }
private async resolveStoredOrExternalProviderApiKey(
providerId: CliProviderId,
options?: StoredApiKeyAccessOptions
): Promise<string | null> {
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<string | null> {
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' { private getConfiguredCodexRuntimeBackend(runtimeBackendOverride?: string | null): 'codex-native' {
if (runtimeBackendOverride === CODEX_NATIVE_BACKEND_ID) { if (runtimeBackendOverride === CODEX_NATIVE_BACKEND_ID) {
return runtimeBackendOverride; 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; return null;
} }

View file

@ -103,7 +103,12 @@ export function applyProviderRuntimeEnv(
export function resolveRuntimeProviderId( export function resolveRuntimeProviderId(
providerId: RuntimeEnvProviderId | undefined providerId: RuntimeEnvProviderId | undefined
): CliProviderId { ): CliProviderId {
if (providerId === 'codex' || providerId === 'gemini' || providerId === 'opencode') { if (
providerId === 'codex' ||
providerId === 'gemini' ||
providerId === 'opencode' ||
providerId === 'kilocode'
) {
return providerId; return providerId;
} }
@ -111,7 +116,10 @@ export function resolveRuntimeProviderId(
} }
export function resolveTeamProviderId(providerId: TeamProviderId | undefined): TeamProviderId { export function resolveTeamProviderId(providerId: TeamProviderId | undefined): TeamProviderId {
return providerId === 'codex' || providerId === 'gemini' || providerId === 'opencode' return providerId === 'codex' ||
providerId === 'gemini' ||
providerId === 'opencode' ||
providerId === 'kilocode'
? providerId ? providerId
: 'anthropic'; : 'anthropic';
} }

View file

@ -1311,6 +1311,8 @@ function getProviderRuntimeFailureLabel(providerId: TeamProviderId): string {
return 'Gemini runtime'; return 'Gemini runtime';
case 'opencode': case 'opencode':
return 'OpenCode runtime'; return 'OpenCode runtime';
case 'kilocode':
return 'KiloCode runtime';
} }
} }
@ -16569,6 +16571,17 @@ export class TeamProvisioningService {
? Array.from(new Set(providerModelChecks.map((check) => check.modelId))) ? Array.from(new Set(providerModelChecks.map((check) => check.modelId)))
: selectedModelIds; : 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') { if (providerId === 'opencode') {
const adapter = this.getOpenCodeRuntimeAdapter(); const adapter = this.getOpenCodeRuntimeAdapter();
if (!adapter) { if (!adapter) {

View file

@ -14,7 +14,13 @@ import type {
TeamProvisioningSupportDiagnostic, TeamProvisioningSupportDiagnostic,
} from '@shared/types'; } 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]; export type TeamRuntimeProviderId = (typeof TEAM_RUNTIME_PROVIDER_IDS)[number];

View file

@ -202,5 +202,7 @@ export const ProviderBrandLogo = ({
return <GeminiBrandLogo className={className} />; return <GeminiBrandLogo className={className} />;
case 'opencode': case 'opencode':
return <OpenCodeBrandLogo className={className} />; return <OpenCodeBrandLogo className={className} />;
case 'kilocode':
return <OpenCodeBrandLogo className={className} />;
} }
}; };

View file

@ -443,6 +443,8 @@ function getProviderLabel(providerId: CliProviderId): string {
return 'Gemini'; return 'Gemini';
case 'opencode': case 'opencode':
return 'OpenCode (200+ models)'; return 'OpenCode (200+ models)';
case 'kilocode':
return 'KiloCode';
} }
} }

View file

@ -288,6 +288,7 @@ export function getDashboardRateLimitsForProvider(
return getAnthropicDashboardRateLimits(provider); return getAnthropicDashboardRateLimits(provider);
case 'gemini': case 'gemini':
case 'opencode': case 'opencode':
case 'kilocode':
return null; return null;
} }
} }

View file

@ -62,7 +62,7 @@ const ProviderCapabilityCardSkeleton = ({
providerId, providerId,
displayName, displayName,
}: { }: {
providerId: 'anthropic' | 'codex' | 'gemini' | 'opencode'; providerId: 'anthropic' | 'codex' | 'gemini' | 'opencode' | 'kilocode';
displayName: string; displayName: string;
}): React.JSX.Element => { }): React.JSX.Element => {
const { t } = useAppTranslation('extensions'); const { t } = useAppTranslation('extensions');

View file

@ -68,7 +68,7 @@ import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contr
import type { CliProviderAuthMode, CliProviderId, CliProviderStatus } from '@shared/types'; import type { CliProviderAuthMode, CliProviderId, CliProviderStatus } from '@shared/types';
import type { ApiKeyEntry } from '@shared/types/extensions'; 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; type PendingConnectionAction = 'auto' | 'oauth' | 'chatgpt' | 'api_key' | 'compatible' | null;
interface ConnectionMethodCardOption { interface ConnectionMethodCardOption {
@ -98,7 +98,7 @@ interface Props {
const API_KEY_PROVIDER_CONFIG: Record< const API_KEY_PROVIDER_CONFIG: Record<
ApiKeyProviderId, 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; name: string;
title: string; title: string;
description: 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.', 'Use `GEMINI_API_KEY` for the Gemini API backend. CLI SDK and ADC do not require it.',
placeholder: 'AIza...', 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 = { const API_KEY_PROVIDER_TRANSLATION_KEYS = {
@ -151,7 +159,7 @@ const API_KEY_PROVIDER_TRANSLATION_KEYS = {
placeholder: 'providerRuntime.apiKey.providers.gemini.placeholder', placeholder: 'providerRuntime.apiKey.providers.gemini.placeholder',
}, },
} as const satisfies Record< } as const satisfies Record<
ApiKeyProviderId, Exclude<ApiKeyProviderId, 'kilocode'>,
{ {
name: string; name: string;
title: 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']); const FIRST_PARTY_ANTHROPIC_HOSTS = new Set(['api.anthropic.com', 'api-staging.anthropic.com']);
function isApiKeyProviderId(providerId: CliProviderId): providerId is ApiKeyProviderId { 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( function isCodexRuntimeInstalling(
@ -243,6 +256,8 @@ function getConnectionDescription(
return t('providerRuntime.connection.descriptions.gemini'); return t('providerRuntime.connection.descriptions.gemini');
case 'opencode': case 'opencode':
return t('providerRuntime.connection.descriptions.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'); return t('providerRuntime.runtime.descriptions.gemini');
case 'opencode': case 'opencode':
return t('providerRuntime.runtime.descriptions.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 ''; return '';
} }
@ -1026,9 +1047,10 @@ export const ProviderRuntimeSettingsDialog = ({
? selectedProvider.providerId ? selectedProvider.providerId
: null; : null;
const apiKeyConfig = apiKeyProviderId ? API_KEY_PROVIDER_CONFIG[apiKeyProviderId] : null; const apiKeyConfig = apiKeyProviderId ? API_KEY_PROVIDER_CONFIG[apiKeyProviderId] : null;
const apiKeyTranslationKeys = apiKeyProviderId const apiKeyTranslationKeys =
? API_KEY_PROVIDER_TRANSLATION_KEYS[apiKeyProviderId] apiKeyProviderId && apiKeyProviderId !== 'kilocode'
: null; ? API_KEY_PROVIDER_TRANSLATION_KEYS[apiKeyProviderId]
: null;
const apiKeyDisplayConfig = apiKeyTranslationKeys const apiKeyDisplayConfig = apiKeyTranslationKeys
? { ? {
title: t(apiKeyTranslationKeys.title), title: t(apiKeyTranslationKeys.title),
@ -1036,7 +1058,7 @@ export const ProviderRuntimeSettingsDialog = ({
name: t(apiKeyTranslationKeys.name), name: t(apiKeyTranslationKeys.name),
placeholder: t(apiKeyTranslationKeys.placeholder), placeholder: t(apiKeyTranslationKeys.placeholder),
} }
: null; : apiKeyConfig;
const showApiKeyForm = const showApiKeyForm =
selectedProvider && selectedProvider &&
isApiKeyProviderId(selectedProvider.providerId) && isApiKeyProviderId(selectedProvider.providerId) &&

View file

@ -124,6 +124,8 @@ function getProviderLabel(providerId: CliProviderId): string {
return 'Gemini'; return 'Gemini';
case 'opencode': case 'opencode':
return 'OpenCode (200+ models)'; return 'OpenCode (200+ models)';
case 'kilocode':
return 'KiloCode';
} }
} }

View file

@ -411,6 +411,7 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
codex: 0, codex: 0,
gemini: 0, gemini: 0,
opencode: 0, opencode: 0,
kilocode: 0,
}; };
for (const session of searchedSessions) { for (const session of searchedSessions) {

View file

@ -65,6 +65,7 @@ const PROVIDER_LABELS: Record<TeamProviderId, string> = {
codex: 'Codex', codex: 'Codex',
gemini: 'Gemini', gemini: 'Gemini',
opencode: 'OpenCode', opencode: 'OpenCode',
kilocode: 'KiloCode',
}; };
function getProviderLabel(providerId: TeamProviderId): string { function getProviderLabel(providerId: TeamProviderId): string {

View file

@ -28,14 +28,24 @@ const CODEX_PROVIDER_INSTALL_REFRESH_ATTEMPTS = 3;
const CODEX_PROVIDER_INSTALL_REFRESH_RETRY_DELAY_MS = 700; const CODEX_PROVIDER_INSTALL_REFRESH_RETRY_DELAY_MS = 700;
export const MULTIMODEL_PROVIDER_IDS: CliProviderId[] = isGeminiUiFrozen() 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', 'opencode']
: ['anthropic', 'codex', 'gemini', 'opencode']; : ['anthropic', 'codex', 'gemini', 'opencode'];
const MULTIMODEL_PROVIDER_ID_SET = new Set<CliProviderId>(MULTIMODEL_PROVIDER_IDS); const MULTIMODEL_PROVIDER_ID_SET = new Set<CliProviderId>(MULTIMODEL_PROVIDER_IDS);
const MULTIMODEL_PROVIDER_HYDRATION_ID_SET = new Set<CliProviderId>(
MULTIMODEL_PROVIDER_HYDRATION_IDS
);
function isActiveMultimodelProviderId(providerId: CliProviderId): boolean { function isActiveMultimodelProviderId(providerId: CliProviderId): boolean {
return MULTIMODEL_PROVIDER_ID_SET.has(providerId); return MULTIMODEL_PROVIDER_ID_SET.has(providerId);
} }
function isHydratableMultimodelProviderId(providerId: CliProviderId): boolean {
return MULTIMODEL_PROVIDER_HYDRATION_ID_SET.has(providerId);
}
export function createLoadingMultimodelCliStatus(): CliInstallationStatus { export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
const providers: CliProviderStatus[] = MULTIMODEL_PROVIDER_IDS.map((providerId) => ({ const providers: CliProviderStatus[] = MULTIMODEL_PROVIDER_IDS.map((providerId) => ({
providerId, providerId,
@ -259,7 +269,7 @@ export function getIncompleteMultimodelProviderIds(
return status.providers return status.providers
.filter( .filter(
(provider) => (provider) =>
isActiveMultimodelProviderId(provider.providerId) && isHydratableMultimodelProviderId(provider.providerId) &&
!isHydratedMultimodelProviderStatus(provider) !isHydratedMultimodelProviderStatus(provider)
) )
.map((provider) => provider.providerId); .map((provider) => provider.providerId);
@ -275,7 +285,7 @@ export function getModelOnlyFallbackProviderIds(
return status.providers return status.providers
.filter( .filter(
(provider) => (provider) =>
isActiveMultimodelProviderId(provider.providerId) && isHydratableMultimodelProviderId(provider.providerId) &&
isModelOnlyFallbackProviderStatus(provider) isModelOnlyFallbackProviderStatus(provider)
) )
.map((provider) => provider.providerId); .map((provider) => provider.providerId);
@ -293,7 +303,7 @@ export function reconcileMultimodelProviderLoading(
const providersById = new Map( const providersById = new Map(
status.providers.map((provider) => [provider.providerId, provider]) status.providers.map((provider) => [provider.providerId, provider])
); );
return MULTIMODEL_PROVIDER_IDS.reduce<Partial<Record<CliProviderId, boolean>>>( return MULTIMODEL_PROVIDER_HYDRATION_IDS.reduce<Partial<Record<CliProviderId, boolean>>>(
(nextLoading, providerId) => { (nextLoading, providerId) => {
const provider = providersById.get(providerId); const provider = providersById.get(providerId);
return { return {
@ -565,7 +575,9 @@ function isMultimodelCliStatus(
function hasActiveProviderStatusLoading( function hasActiveProviderStatusLoading(
providerLoading: Partial<Record<CliProviderId, boolean>> providerLoading: Partial<Record<CliProviderId, boolean>>
): boolean { ): 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 { function getAuthenticatedProvider(providers: CliProviderStatus[]): CliProviderStatus | null {
@ -604,6 +616,8 @@ function getProviderDisplayName(providerId: CliProviderId): string {
return 'Gemini'; return 'Gemini';
case 'opencode': case 'opencode':
return 'OpenCode (200+ models)'; return 'OpenCode (200+ models)';
case 'kilocode':
return 'KiloCode';
} }
} }
@ -757,7 +771,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
: createLoadingMultimodelCliStatus(); : createLoadingMultimodelCliStatus();
const shouldMarkIncompleteProvidersLoading = hydrateProviders || providerStatusMode === 'defer'; const shouldMarkIncompleteProvidersLoading = hydrateProviders || providerStatusMode === 'defer';
const providerLoading = Object.fromEntries( const providerLoading = Object.fromEntries(
MULTIMODEL_PROVIDER_IDS.map((providerId) => [ MULTIMODEL_PROVIDER_HYDRATION_IDS.map((providerId) => [
providerId, providerId,
shouldMarkIncompleteProvidersLoading && shouldMarkIncompleteProvidersLoading &&
initialStatus.installed && initialStatus.installed &&
@ -808,14 +822,14 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
const nextCliStatus = mergeCliStatusPreservingHydratedProviders(state.cliStatus, metadata); const nextCliStatus = mergeCliStatusPreservingHydratedProviders(state.cliStatus, metadata);
const nextProviderLoading = Object.fromEntries( const nextProviderLoading = Object.fromEntries(
MULTIMODEL_PROVIDER_IDS.map((providerId) => [ MULTIMODEL_PROVIDER_HYDRATION_IDS.map((providerId) => [
providerId, providerId,
!isHydratedMultimodelProviderStatus( !isHydratedMultimodelProviderStatus(
nextCliStatus.providers.find((provider) => provider.providerId === providerId) nextCliStatus.providers.find((provider) => provider.providerId === providerId)
), ),
]) ])
) as Partial<Record<CliProviderId, boolean>>; ) as Partial<Record<CliProviderId, boolean>>;
pendingProviderIds = MULTIMODEL_PROVIDER_IDS.filter( pendingProviderIds = MULTIMODEL_PROVIDER_HYDRATION_IDS.filter(
(providerId) => nextProviderLoading[providerId] === true (providerId) => nextProviderLoading[providerId] === true
); );
const nextAuthState = isMultimodelCliStatus(nextCliStatus) const nextAuthState = isMultimodelCliStatus(nextCliStatus)
@ -867,7 +881,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
try { try {
if (hydrateProviders) { if (hydrateProviders) {
await Promise.allSettled( await Promise.allSettled(
MULTIMODEL_PROVIDER_IDS.map((providerId) => MULTIMODEL_PROVIDER_HYDRATION_IDS.map((providerId) =>
get().fetchCliProviderStatus(providerId, { get().fetchCliProviderStatus(providerId, {
silent: false, silent: false,
epoch, epoch,
@ -911,7 +925,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
}); });
if (status.installed) { if (status.installed) {
for (const provider of status.providers) { for (const provider of status.providers) {
if (!isActiveMultimodelProviderId(provider.providerId)) { if (!isHydratableMultimodelProviderId(provider.providerId)) {
continue; continue;
} }
void get().fetchCliProviderStatus(provider.providerId, { void get().fetchCliProviderStatus(provider.providerId, {

View file

@ -42,6 +42,7 @@ const TEAM_PROVIDER_LABELS: Record<SupportedProviderId, string> = {
codex: 'Codex', codex: 'Codex',
gemini: 'Gemini', gemini: 'Gemini',
opencode: 'OpenCode', opencode: 'OpenCode',
kilocode: 'KiloCode',
}; };
const ANTHROPIC_ALIAS_LABELS = { const ANTHROPIC_ALIAS_LABELS = {
@ -140,6 +141,7 @@ const TEAM_PROVIDER_MODEL_OPTIONS: Record<SupportedProviderId, readonly TeamProv
}, },
], ],
opencode: [{ value: '', label: 'Default', badgeLabel: 'Default' }], opencode: [{ value: '', label: 'Default', badgeLabel: 'Default' }],
kilocode: [{ value: '', label: 'Default', badgeLabel: 'Default' }],
}; };
const TEAM_PROVIDER_MODEL_ORDER: Record<SupportedProviderId, Map<string, number>> = { const TEAM_PROVIDER_MODEL_ORDER: Record<SupportedProviderId, Map<string, number>> = {
@ -149,6 +151,9 @@ const TEAM_PROVIDER_MODEL_ORDER: Record<SupportedProviderId, Map<string, number>
opencode: new Map( opencode: new Map(
TEAM_PROVIDER_MODEL_OPTIONS.opencode.map((option, index) => [option.value, index]) 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( function getKnownTeamProviderModelOption(
@ -337,6 +342,9 @@ export function getTeamModelBadgeLabel(
if (providerId === 'opencode') { if (providerId === 'opencode') {
return getTeamModelLabel(trimmed) ?? trimmed; return getTeamModelLabel(trimmed) ?? trimmed;
} }
if (providerId === 'kilocode') {
return getTeamModelLabel(trimmed) ?? trimmed;
}
return trimmed; return trimmed;
} }

View file

@ -33,7 +33,7 @@ export type CliPlatform =
export type CliFlavor = 'claude' | 'agent_teams_orchestrator'; 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 type CliProviderAuthMode = 'auto' | 'oauth' | 'chatgpt' | 'api_key';
export const CLI_PROVIDER_STATUS_DEFERRED_MESSAGE = 'Provider status will refresh when needed.'; export const CLI_PROVIDER_STATUS_DEFERRED_MESSAGE = 'Provider status will refresh when needed.';

View file

@ -966,7 +966,7 @@ export interface TeamViewSnapshot {
} }
export type EffortLevel = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'; 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 = export type TeamProviderBackendId =
| 'auto' | 'auto'
| 'adapter' | 'adapter'

View file

@ -3,7 +3,13 @@ import { parseOpenCodeQualifiedModelRef } from './opencodeModelRef';
import type { TeamProviderId } from '@shared/types'; import type { TeamProviderId } from '@shared/types';
export function isTeamProviderId(value: unknown): value is TeamProviderId { 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 { export function normalizeOptionalTeamProviderId(value: unknown): TeamProviderId | undefined {
@ -33,6 +39,15 @@ export function inferTeamProviderIdFromModel(
return 'opencode'; return 'opencode';
} }
if (
normalized.startsWith('kilocode/') ||
normalizedWithoutExtendedContextSuffix.startsWith('kilocode/') ||
normalized.startsWith('kilo/') ||
normalizedWithoutExtendedContextSuffix.startsWith('kilo/')
) {
return 'kilocode';
}
if ( if (
normalized.startsWith('gpt-') || normalized.startsWith('gpt-') ||
normalized.startsWith('codex') || normalized.startsWith('codex') ||

View file

@ -239,6 +239,7 @@ describe('CliInstallerService', () => {
'anthropic', 'anthropic',
'codex', 'codex',
'opencode', 'opencode',
'kilocode',
]); ]);
expect(openCodeStatus).toMatchObject({ expect(openCodeStatus).toMatchObject({
displayName: 'OpenCode (200+ models)', displayName: 'OpenCode (200+ models)',
@ -335,6 +336,7 @@ describe('CliInstallerService', () => {
'anthropic', 'anthropic',
'codex', 'codex',
'opencode', 'opencode',
'kilocode',
]); ]);
expect(status.authLoggedIn).toBe(false); expect(status.authLoggedIn).toBe(false);
expect(status.authMethod).toBeNull(); expect(status.authMethod).toBeNull();
@ -380,7 +382,7 @@ describe('CliInstallerService', () => {
expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled(); expect(resolveInteractiveShellEnvBestEffortMock).not.toHaveBeenCalled();
expect(status.authStatusChecking).toBe(false); expect(status.authStatusChecking).toBe(false);
expect(status.authLoggedIn).toBe(false); expect(status.authLoggedIn).toBe(false);
expect(status.providers).toHaveLength(3); expect(status.providers).toHaveLength(4);
expect( expect(
status.providers.every( status.providers.every(
(provider) => provider.statusMessage === 'Provider status will refresh when needed.' (provider) => provider.statusMessage === 'Provider status will refresh when needed.'
@ -1213,13 +1215,12 @@ describe('CliInstallerService', () => {
createTestProviderStatus('codex', false, null), createTestProviderStatus('codex', false, null),
createTestProviderStatus('opencode', false, null), createTestProviderStatus('opencode', false, null),
]); ]);
await Promise.resolve(); await vi.waitFor(() => {
await Promise.resolve(); const latest = service.getLatestStatusSnapshot();
expect(latest?.authStatusChecking).toBe(false);
const latest = service.getLatestStatusSnapshot(); expect(latest?.authLoggedIn).toBe(true);
expect(latest?.authStatusChecking).toBe(false); expect(latest?.authMethod).toBe('oauth_token');
expect(latest?.authLoggedIn).toBe(true); });
expect(latest?.authMethod).toBe('oauth_token');
expect(status.authStatusChecking).toBe(true); expect(status.authStatusChecking).toBe(true);
expect(status.authLoggedIn).toBe(false); expect(status.authLoggedIn).toBe(false);
expect(status.providers.every((provider) => provider.statusMessage === 'Checking...')).toBe( expect(status.providers.every((provider) => provider.statusMessage === 'Checking...')).toBe(

View file

@ -40,6 +40,7 @@ describe('ProviderConnectionService', () => {
const originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY; const originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY;
const originalAnthropicAuthToken = process.env.ANTHROPIC_AUTH_TOKEN; const originalAnthropicAuthToken = process.env.ANTHROPIC_AUTH_TOKEN;
const originalAnthropicBaseUrl = process.env.ANTHROPIC_BASE_URL; const originalAnthropicBaseUrl = process.env.ANTHROPIC_BASE_URL;
const originalKiloApiKey = process.env.KILO_API_KEY;
function createConfig( function createConfig(
authMode: 'auto' | 'oauth' | 'api_key' = 'auto', authMode: 'auto' | 'oauth' | 'api_key' = 'auto',
@ -136,6 +137,7 @@ describe('ProviderConnectionService', () => {
delete process.env.ANTHROPIC_API_KEY; delete process.env.ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_AUTH_TOKEN; delete process.env.ANTHROPIC_AUTH_TOKEN;
delete process.env.ANTHROPIC_BASE_URL; delete process.env.ANTHROPIC_BASE_URL;
delete process.env.KILO_API_KEY;
}); });
afterEach(() => { afterEach(() => {
@ -168,6 +170,12 @@ describe('ProviderConnectionService', () => {
} else { } else {
process.env.ANTHROPIC_BASE_URL = originalAnthropicBaseUrl; 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 () => { 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'); 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 () => { it('reports a missing Anthropic API key when api_key mode is selected', async () => {
const { ProviderConnectionService } = const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService'); await import('@main/services/runtime/ProviderConnectionService');

View file

@ -2696,7 +2696,7 @@ describe('CLI status visibility during completed install state', () => {
await Promise.resolve(); 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('5h left');
expect(host.textContent).toContain('1w left'); expect(host.textContent).toContain('1w left');
expect(host.textContent).toContain('resets'); expect(host.textContent).toContain('resets');

View file

@ -1,11 +1,12 @@
import React, { act } from 'react'; import React, { act } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
import type { CliInstallationStatus } from '@shared/types'; import type { CliInstallationStatus } from '@shared/types';
import type { SkillCatalogItem } from '@shared/types/extensions'; import type { SkillCatalogItem } from '@shared/types/extensions';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
interface StoreState { interface StoreState {
fetchSkillsCatalog: ReturnType<typeof vi.fn>; fetchSkillsCatalog: ReturnType<typeof vi.fn>;
@ -559,7 +560,7 @@ describe('SkillsPanel', () => {
}); });
expect(host.textContent).toContain( 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'); expect(host.textContent).toContain('Codex only');

View file

@ -62,6 +62,49 @@ vi.mock('@sentry/electron/main', () => sentryNoOp);
vi.mock('@sentry/electron/renderer', () => sentryNoOp); vi.mock('@sentry/electron/renderer', () => sentryNoOp);
vi.mock('@sentry/react', () => sentryNoOp); vi.mock('@sentry/react', () => sentryNoOp);
function createInMemoryStorage(): Storage {
const values = new Map<string, string>();
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: // 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 // some services persist state in best-effort background writes after a test has
// already reset path overrides. // already reset path overrides.