/** * @license * Copyright 2025 Vybestack LLC * SPDX-License-Identifier: Apache-2.0 */ import { promises as fs } from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { Storage, writeProfileFile, type ProfileWriteResult, } from '@vybestack/llxprt-code-settings'; import { PROVIDER_OPTIONS } from './constants.js'; import { WizardStep } from './types.js'; import type { WizardState, ConnectionTestResult } from './types.js'; function expandTilde(filePath: string): string { // Handle ~/ for home directory if (filePath.startsWith('~/')) { return path.join(os.homedir(), filePath.slice(2)); } // Handle bare ~ for home directory if (filePath === '~') { return os.homedir(); } // Handle ./ for current directory (resolve to absolute path) if (filePath.startsWith('./')) { return path.resolve(filePath); } // Handle / for absolute path (already absolute) if (filePath.startsWith('/')) { return filePath; } // Relative path - resolve to absolute return path.resolve(filePath); } export function needsBaseUrlConfig(provider: string | null): boolean { if (!provider) return false; const providerOption = PROVIDER_OPTIONS.find((p) => p.value === provider); const result = providerOption?.needsBaseUrl ?? false; return result; } export function generateProfileNameSuggestions( config: WizardState['config'], ): string[] { const suggestions: string[] = []; // Suggestion 1: provider-model (cleaned) if (config.provider && config.model) { const cleanProvider = config.provider.replace(/[^a-z0-9]/gi, '-'); const cleanModel = config.model.replace(/[^a-z0-9.]/gi, '-'); suggestions.push(`${cleanProvider}-${cleanModel}`); } // Suggestion 2: provider-custom if (config.provider) { suggestions.push(`${config.provider}-custom`); } // Suggestion 3: model-only if (config.model) { const cleanModel = config.model.replace(/[^a-z0-9.]/gi, '-'); suggestions.push(cleanModel); } return suggestions.slice(0, 3); // Max 3 suggestions } export function buildProfileJSON(state: WizardState): Record { const ephemeralSettings: Record = {}; const modelParams: Record = {}; // Add base URL if present if (state.config.baseUrl) { ephemeralSettings['base-url'] = state.config.baseUrl; } // Add authentication if (state.config.auth.type === 'apikey') { ephemeralSettings['auth-key'] = state.config.auth.value; } else if (state.config.auth.type === 'keyfile') { ephemeralSettings['auth-keyfile'] = state.config.auth.value; } // Add parameters if configured if (state.config.params) { if (state.config.params.temperature !== undefined) { modelParams.temperature = state.config.params.temperature; } if (state.config.params.maxTokens !== undefined) { modelParams.max_tokens = state.config.params.maxTokens; } if (state.config.params.contextLimit !== undefined) { ephemeralSettings['context-limit'] = state.config.params.contextLimit; } } const profile: Record = { version: 1, provider: state.config.provider === 'custom' ? 'openai' : state.config.provider, model: state.config.model, modelParams, ephemeralSettings, }; if (state.config.auth.type === 'oauth') { profile.auth = { type: 'oauth', buckets: state.config.auth.buckets && state.config.auth.buckets.length > 0 ? state.config.auth.buckets : ['default'], }; } return profile; } function isExistingFileError(error: unknown): boolean { return ( typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST' ); } export async function saveProfile( name: string, config: Record, opts: { overwrite?: boolean } = {}, ): Promise<{ success: boolean; error?: string; path?: string; alreadyExists?: boolean; }> { try { const profilesDir = path.join(Storage.getGlobalConfigDir(), 'profiles'); const data = JSON.stringify(config, null, 2); const writeMode = opts.overwrite === true ? 'overwrite' : 'create'; const result: ProfileWriteResult = await writeProfileFile( profilesDir, name, data, writeMode, ); if (result.kind === 'exists') { return { success: false, alreadyExists: true, error: 'Profile name already exists', path: result.path, }; } return { success: true, path: result.path }; } catch (error) { if (isExistingFileError(error)) { return { success: false, alreadyExists: true, error: 'Profile name already exists', }; } return { success: false, error: error instanceof Error ? error.message : String(error), }; } } function formatAuthDisplay(auth: WizardState['config']['auth']): string { switch (auth.type) { case 'apikey': return 'API key (stored in profile)'; case 'keyfile': return `Key file (${auth.value})`; case 'oauth': return 'OAuth (lazy authentication)'; default: return 'None'; } } export function formatConfigSummary(state: WizardState): string { const lines: string[] = []; // Provider const providerDisplay = state.config.provider === 'custom' ? 'OpenAI-compatible' : state.config.provider; lines.push(`Provider: ${providerDisplay}`); // Base URL (if present) if (state.config.baseUrl) { lines.push(`Base URL: ${state.config.baseUrl}`); } // Model lines.push(`Model: ${state.config.model}`); // Auth lines.push(`Auth: ${formatAuthDisplay(state.config.auth)}`); // Parameters (if configured) if (state.config.params) { if (state.config.params.temperature !== undefined) { lines.push(`Temperature: ${state.config.params.temperature}`); } if (state.config.params.maxTokens !== undefined) { lines.push(`Max Tokens: ${state.config.params.maxTokens}`); } if (state.config.params.contextLimit !== undefined) { lines.push(`Context Limit: ${state.config.params.contextLimit}`); } } return lines.join('\n'); } export async function testConnectionWithTimeout( provider: string, baseUrl: string | undefined, model: string, authKind: 'apikey' | 'keyfile', authValue: string, timeoutMs = 30000, ): Promise { // Create timeout sentinel that resolves (not rejects) to avoid unhandled rejections const TIMEOUT_SENTINEL = { success: false, timedOut: true } as const; let timeoutId: NodeJS.Timeout | undefined; const timeoutPromise = new Promise((resolve) => { timeoutId = setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs); }); // Wrap testConnection to handle its own rejections const testPromise = testConnection( provider, baseUrl, model, authKind, authValue, ) .then((res) => res) .catch( (err): ConnectionTestResult => ({ success: false, error: err instanceof Error ? err.message : String(err), }), ); // Race the promises const result = await Promise.race([testPromise, timeoutPromise]); // Clear timeout to prevent it from firing if (timeoutId !== undefined) { clearTimeout(timeoutId); } return result; } /** * Tests connection to the provider with the given configuration. * * NOTE: This is currently a placeholder implementation that only validates * the API key is non-empty. A full implementation would: * 1. Create a temporary provider instance with the given configuration * 2. Make a minimal API call (e.g., list models, get account info) * 3. Verify the response is successful * * This requires adding a testProviderConnection() method to RuntimeApi * that can create isolated provider instances without affecting the * active runtime state. * * @see Design doc section "Connection Testing" (lines 673-704) */ async function testConnection( _provider: string, _baseUrl: string | undefined, _model: string, authKind: 'apikey' | 'keyfile', authValue: string, ): Promise { try { // Read key from file if keyfile type const apiKey = authKind === 'keyfile' ? await fs .readFile(expandTilde(authValue), 'utf-8') .then((k) => k.trim()) : authValue; // Basic validation: ensure we have a non-empty key if (!apiKey || apiKey.trim().length === 0) { return { success: false, error: 'API key is empty' }; } // Follow-up (#1569): Implement actual API testing // This requires adding a testProviderConnection() method to the runtime // that can create an isolated provider instance and make a test request // without affecting the active runtime state. // // Example implementation: // const runtime = getRuntimeApi(); // const testResult = await runtime.testProviderConnection({ // provider, // baseUrl, // model, // apiKey, // }); // return { success: testResult.ok }; // For now, return success if we have a non-empty key return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), }; } } export function getNextStep( current: WizardStep, state: WizardState, ): WizardStep { switch (current) { case WizardStep.PROVIDER_SELECT: // Show base URL only for local/custom providers if (needsBaseUrlConfig(state.config.provider)) { return WizardStep.BASE_URL_CONFIG; } return WizardStep.MODEL_SELECT; case WizardStep.BASE_URL_CONFIG: return WizardStep.MODEL_SELECT; case WizardStep.MODEL_SELECT: return WizardStep.AUTHENTICATION; case WizardStep.AUTHENTICATION: return WizardStep.ADVANCED_PARAMS; case WizardStep.ADVANCED_PARAMS: return WizardStep.SAVE_PROFILE; case WizardStep.SAVE_PROFILE: return WizardStep.SUCCESS_SUMMARY; default: return current; } } export function getPreviousStep(state: WizardState): WizardStep { // Pop from step history const prevStep = state.stepHistory.at(-2); return prevStep ?? WizardStep.PROVIDER_SELECT; } export function getStepPosition(state: WizardState): { current: number; total: number; } { // Current position is based on how many steps we've taken const current = state.stepHistory.length; // Total steps depends on whether we need base URL step // 1. Provider Select // 2. Base URL (conditional) // 3. Model Select // 4. Authentication // 5. Advanced Params // 6. Save Profile // 7. Success Summary const baseSteps = 6; // All steps except BASE_URL_CONFIG const needsBaseUrl = needsBaseUrlConfig(state.config.provider); const total = needsBaseUrl ? baseSteps + 1 : baseSteps; return { current, total }; }